diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d7a5bc09c..dc5221fa6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,6 +18,7 @@ on: - "packages/client/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" + - "packages/plugin/**" - "packages/protocol/**" - "packages/relay/**" - "packages/server/**" diff --git a/.github/workflows/nix-update-hash.yml b/.github/workflows/nix-update-hash.yml index 0acf9ee21..0aae8f81c 100644 --- a/.github/workflows/nix-update-hash.yml +++ b/.github/workflows/nix-update-hash.yml @@ -10,6 +10,7 @@ on: - "package.json" - "package-lock.json" - "packages/highlight/**" + - "packages/plugin/**" - "packages/server/**" - "packages/relay/**" - "packages/cli/**" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6da838518..bf198d1c9 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -12,6 +12,7 @@ on: - "packages/app/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" + - "packages/plugin/**" - "packages/protocol/**" - "packages/client/**" - "packages/server/**" diff --git a/CLAUDE.md b/CLAUDE.md index 2792cebf0..3068dd3fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 562b898ca..cd2591ed4 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -23,6 +23,7 @@ RUN set -eux; \ npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..a59cb996e --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,173 @@ +# Local plugins + +Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources 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. + +## Install a directory source + +Create a typecheckable plugin project, install its development dependencies, then install it into +the daemon. `init` only writes the project files; it does not run the package manager. + +```bash +paseo plugin init /absolute/path/to/my-plugin +cd /absolute/path/to/my-plugin +npm install +npm run typecheck +paseo plugin install /absolute/path/to/my-plugin +paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id +paseo plugin ls +``` + +The daemon stores directory sources under the root `plugins` object: + +```json +{ + "pluginsEnabled": true, + "plugins": { + "my-plugin": { + "source": "directory", + "path": "/absolute/path/to/my-plugin", + "enabled": true + } + } +} +``` + +The plugin system is disabled unless `pluginsEnabled` is `true`. + +The directory contains an identity-only manifest, one entry point, and local typechecking support: + +```text +my-plugin/ + paseo-plugin.json + index.tsx + paseo-plugin.d.ts + package.json + tsconfig.json +``` + +Paseo compiles TSX when loading the plugin, so these packages are development dependencies only. +The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a +public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes. + +```json +{ + "id": "my-plugin" +} +``` + +The config key is the runtime plugin ID. The manifest ID is the default selected during install; +`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the +runtime does not compare the two IDs. The same directory can be installed under several config +keys. + +Source changes are explicit. Run `paseo plugin reload ` to stop and fully tear down the old +plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore +the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its +configuration, never its source directory. The global `pluginsEnabled` switch remains available. + +## Contribute behavior and UI + +Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler +removes UI registrations from the server bundle and RPC registrations from the client bundle before +resolving dependencies. + +```tsx +import { Text } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; +import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), +}); + +function Greeting() { + const greet = useRpc(greetRpc); + const query = useQuery({ + queryKey: ["greeting", "Paseo"], + queryFn: () => greet({ name: "Paseo" }), + }); + return {query.data?.message}; +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` })); + plugin.addSurface("main", Greeting); + plugin.addSidebarItem({ + id: "main", + title: "Greeting", + icon: "MessageCircle", + surface: "main", + }); + return () => undefined; +} +``` + +The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded, +disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code. +Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs, +and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown. + +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. + +## Contribute composer attachments + +Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu, +search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots; +credentials and vendor API calls stay in the daemon handler. + +```tsx +const searchIssues = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + text: z.string(), + resourceType: z.string(), + }), + ), + }), +}); + +const issues = defineAttachmentSource({ + id: "issues", + title: "Acme issue", + icon: "CircleDot", + pickerTitle: "Attach Acme issue", + searchPlaceholder: "Search by identifier or title", + search: searchIssues, +}); + +export default function contribute(plugin: PluginContext) { + plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); + plugin.addAttachmentSource(issues); + return () => undefined; +} +``` + +Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources +on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral +external-resource presentation, so it remains readable if the plugin is removed or an older peer +drops the optional presentation fields. + +See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete +attachment-source example. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index ddbe6bd11..3e19b6cc9 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-qPIKchcbGkJHFSqgeM/BUSmu7TiVfKy3IEgUcMNNJYc= +sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM= diff --git a/package-lock.json b/package-lock.json index 7a691c1df..64ddb2ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "workspaces": [ "packages/expo-two-way-audio", "packages/highlight", + "packages/plugin", "packages/protocol", "packages/client", "packages/server", @@ -9568,6 +9569,10 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@paseo/plugin": { + "resolved": "packages/plugin", + "link": true + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -36119,6 +36124,7 @@ "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", "@mattermost/react-native-paste-input": "2.0.1", + "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", @@ -38582,6 +38588,22 @@ "vitest": "^4.1.6" } }, + "packages/plugin": { + "name": "@paseo/plugin", + "version": "0.3.0", + "devDependencies": { + "@types/node": "^20.9.0", + "@types/react": "~19.2.0", + "react": "19.1.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6", + "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "19.1.0", + "zod": "^4.4.3" + } + }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.4.0", @@ -38820,6 +38842,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.4.0", "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", @@ -38827,11 +38850,13 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@paseo/plugin": "*", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "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", @@ -38995,6 +39020,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", @@ -39149,6 +39636,47 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "packages/server/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "packages/server/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", diff --git a/package.json b/package.json index 74abe0598..840beb831 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "workspaces": [ "packages/expo-two-way-audio", "packages/highlight", + "packages/plugin", "packages/protocol", "packages/client", "packages/server", @@ -46,19 +47,21 @@ "build": "npm run build --workspaces --if-present", "build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight", + "build:plugin": "npm run build --workspace=@paseo/plugin", + "build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin", "build:relay": "npm run build --workspace=@getpaseo/relay", "build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay", "build:protocol": "npm run build --workspace=@getpaseo/protocol", "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:client": "npm run build --workspace=@getpaseo/client", "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", - "build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"", - "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", + "build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"", + "build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", - "build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", - "build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", + "build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", + "build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", "watch:protocol": "npm run watch --workspace=@getpaseo/protocol", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "typecheck": "npm run typecheck --workspaces --if-present", diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts new file mode 100644 index 000000000..da4b042d4 --- /dev/null +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -0,0 +1,209 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { + expectSettingsHeader, + openHostSection, + openSettingsHost, +} from "../support/helpers/settings"; + +function observePluginCatalog(page: Page) { + let responses = 0; + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + if (typeof payload !== "string") return; + try { + const envelope = JSON.parse(payload) as { + type?: unknown; + message?: { type?: unknown }; + }; + const message = envelope.type === "session" ? envelope.message : envelope; + if (message?.type === "plugin.catalog.get.response") responses += 1; + } catch { + return; + } + }); + }); + return { + waitForInitialFetch: async () => { + await expect.poll(() => responses).toBeGreaterThan(0); + }, + }; +} + +function pluginSource(title: string, renderError?: string): string { + return `import React from "react"; +import { Text } from "react-native"; +export default function contribute(plugin) { + function Surface() { + ${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""} + const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0"); + return ${title} cleanup {cleanups}; + } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" }); + ${ + renderError + ? `function HealthySurface() { return Healthy contribution; } + plugin.addSurface("healthy", HealthySurface); + plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });` + : "" + } + return () => { + const storage = globalThis.sessionStorage; + if (storage) { + const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0"); + storage.setItem("paseo-plugin-cleanups", String(cleanups + 1)); + } + }; +}`; +} + +async function openPluginSettings(page: Page): Promise { + const serverId = getServerId(); + await openSettings(page); + await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "plugins"); + await expectSettingsHeader(page, "Plugins"); +} + +async function reloadPlugin(page: Page): Promise { + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible(); +} + +async function expectContributionBody(page: Page, expectedBody: string): Promise { + const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true }); + await expect(body).toHaveCount(1); + await expect(body).toBeVisible(); +} + +async function openContribution(page: Page, title: string, expectedBody: string): Promise { + await page.getByRole("button", { name: title, exact: true }).click(); + await expectContributionBody(page, expectedBody); +} + +async function leavePluginSettings(page: Page): Promise { + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page).not.toHaveURL(/\/settings\//); +} + +async function openContributionFromSettings( + page: Page, + title: string, + expectedBody: string, +): Promise { + await leavePluginSettings(page); + await openContribution(page, title, expectedBody); +} + +async function reloadActiveContribution( + page: Page, + client: Awaited>, + expectedBody: string, +): Promise { + await client.reloadPlugin("e2e-plugin"); + await expectContributionBody(page, expectedBody); +} + +async function expectContributionRemoved(page: Page, title: string): Promise { + await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible(); +} + +test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({ + page, +}) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" })); + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1")); + + try { + const catalog = observePluginCatalog(page); + await gotoAppShell(page); + await openPluginSettings(page); + await catalog.waitForInitialFetch(); + await client.patchDaemonConfig({ pluginsEnabled: false }); + await page.getByRole("switch", { name: "Enable plugins" }).click(); + await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible(); + await page.getByLabel("Plugin directory").fill(directory); + await page.getByLabel("Plugin installation ID").focus(); + await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin"); + await page.getByRole("button", { name: "Install directory" }).click(); + await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0"); + + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2")); + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2"); + await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible(); + + await writeFile( + path.join(directory, "index.tsx"), + pluginSource("Broken surface", "render exploded"), + ); + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded"); + await openContribution(page, "Healthy surface", "Healthy contribution"); + await openContribution(page, "Broken surface", "Plugin failed: render exploded"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface")); + await reloadActiveContribution(page, client, "Recovered surface cleanup 4"); + await expect( + page.getByRole("button", { name: "Broken surface", exact: true }), + ).not.toBeVisible(); + + await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await openPluginSettings(page); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed"); + await expect(page.getByLabel("e2e-plugin failed")).toBeVisible(); + await leavePluginSettings(page); + await expectContributionRemoved(page, "Recovered surface"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3")); + await openPluginSettings(page); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5"); + + await openPluginSettings(page); + await page.getByRole("button", { name: "Disable", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible(); + await leavePluginSettings(page); + await expectContributionRemoved(page, "Plugin v3"); + + await openPluginSettings(page); + await page.getByRole("button", { name: "Enable", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6"); + + await openPluginSettings(page); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Remove", exact: true }).click(); + await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible(); + + await page.getByLabel("Plugin directory").fill(directory); + await page.getByRole("button", { name: "Install directory" }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await gotoAppShell(page); + await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7"); + } finally { + await client.removePlugin("e2e-plugin").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await client.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 5dccd0cca..f53cb50c1 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick< | "on" | "patchDaemonConfig" | "removeProject" + | "removePlugin" + | "reloadPlugin" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 2af795375..43c0579d1 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -41,6 +41,7 @@ type HostSection = | "providers" | "usage" | "terminals" + | "plugins" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { diff --git a/packages/app/package.json b/packages/app/package.json index 89e183cf6..e92d61d14 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -49,6 +49,7 @@ "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", "@mattermost/react-native-paste-input": "2.0.1", + "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 431d1cc66..d4b6d9b48 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -127,6 +127,7 @@ import { } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { PluginCatalogSync } from "@/plugins"; import { ensureOsNotificationPermission, WEB_NOTIFICATION_CLICK_EVENT, @@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( + ); } diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 4cd3428e0..b40299f5e 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -41,6 +41,7 @@ function KnownHostRoute() { + ); diff --git a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx new file mode 100644 index 000000000..c92e97c3a --- /dev/null +++ b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx @@ -0,0 +1,5 @@ +import { PluginSurfaceScreen } from "@/plugins"; + +export default function PluginSurfaceRoute() { + return ; +} diff --git a/packages/app/src/attachments/attachment-pill-content.test.tsx b/packages/app/src/attachments/attachment-pill-content.test.tsx new file mode 100644 index 000000000..bfd33a329 --- /dev/null +++ b/packages/app/src/attachments/attachment-pill-content.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { getAgentAttachmentPillContent } from "./attachment-pill-content"; + +describe("agent attachment pill content", () => { + it("presents external resources with their provider identity", () => { + const content = getAgentAttachmentPillContent( + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + i18n.t, + ); + + expect(content.title).toBe("Plugin attachments"); + expect(content.subtitle).toBe("Linear issue ENG-123"); + }); +}); diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx index 8acc585fb..ed37c0d78 100644 --- a/packages/app/src/attachments/attachment-pill-content.tsx +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { TFunction } from "i18next"; +import React from "react"; import { CircleDot, FileText, @@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent( subtitle: `Issue #${attachment.number}`, }; case "text": + if (attachment.externalResource) { + return { + icon: attachmentGithubIssueIcon, + title: attachment.externalResource.title, + subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`, + }; + } return { icon: attachmentFileIcon, title: attachment.title ?? t("message.attachments.textAttachment"), diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 7e53567b2..1d953c390 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -3,6 +3,7 @@ import type { ForgeSearchItem, UploadedFileAttachment, } from "@getpaseo/protocol/messages"; +import type { PluginResourceComposerAttachment } from "@/plugins/attachments"; export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file"; @@ -106,6 +107,7 @@ export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } | WorkspaceFileComposerAttachment + | PluginResourceComposerAttachment | { kind: "forge_issue"; item: ForgeSearchItem } | { kind: "forge_change_request"; item: ForgeSearchItem } // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 9c0b2dd18..6e97175cb 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -75,6 +75,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"; type SidebarTheme = ReturnType["theme"]; @@ -681,6 +682,7 @@ function MobileSidebar({ testID="sidebar-schedules" variant="compact" /> + + diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 743f5ebff..4e66d8ef2 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -863,6 +863,35 @@ describe("openComposerAttachment", () => { }); expect(externalUrlCalls).toEqual([issueItem.url]); }); + + it("opens plugin resource URLs through the external url opener", () => { + const externalUrlCalls: string[] = []; + openComposerAttachment({ + attachment: { + kind: "plugin_resource", + pluginId: "linear", + sourceId: "issues", + sourceTitle: "Linear issue", + sourceIcon: "CircleDot", + item: { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + text: "Linear issue ENG-123: Plugin attachments", + resourceType: "issue", + }, + }, + setLightboxMetadata: () => { + throw new Error("unexpected lightbox call"); + }, + openWorkspaceAttachment: () => false, + openExternalUrl: (url) => { + externalUrlCalls.push(url); + }, + }); + expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]); + }); }); describe("toggleGithubAttachment", () => { diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index c1c15bb80..cedce553b 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -10,6 +10,7 @@ import { buildLegacyGitHubAttachmentFromSearchItem, } from "@/utils/review-attachments"; import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file"; +import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments"; export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github"; @@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit( continue; } + if (attachment.kind === "plugin_resource") { + agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment)); + continue; + } + if (isWorkspaceAttachment(attachment)) { if (attachment.kind === "browser_element" && attachment.attachment.screenshot) { images.push(attachment.attachment.screenshot); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index f2b6c6788..3315411a3 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -126,6 +126,7 @@ import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { readClipboardImage } from "./clipboard-image"; import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image"; +import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { appendWorkspaceFileAttachment, @@ -441,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R onRemove, }); } + if (attachment.kind === "plugin_resource") { + return ( + + ); + } return ( (null); const attachButtonRef = useRef(null); const messageInputRef = useRef(null); + const pluginAttachments = usePluginAttachmentPicker({ + serverId, + client, + connected: isConnected, + attachments, + onChangeAttachments: setSelectedAttachments, + anchorRef: attachButtonRef, + }); const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading); const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, @@ -1961,6 +1984,7 @@ export function Composer({ setIsGithubPickerOpen(true); }, }, + ...pluginAttachments.menuItems, { id: "file", label: t("composer.attachments.addFile"), @@ -1971,7 +1995,14 @@ export function Composer({ }, ); return items; - }, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]); + }, [ + forgePresentation, + handlePasteImage, + handlePickFile, + handlePickImage, + pluginAttachments.menuItems, + t, + ]); const handleToggleGithubItem = useCallback( (item: ForgeSearchItem) => { @@ -2231,6 +2262,7 @@ export function Composer({ emptyText={githubEmptyText} renderOption={renderGithubPickerOption} /> + {pluginAttachments.picker} diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 003323719..e1e28ee3b 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -4,6 +4,7 @@ import { useQueries, useQuery, type QueryKey, + type QueryClient, type UseQueryOptions, type UseQueryResult, } from "@tanstack/react-query"; @@ -47,8 +48,11 @@ export function useFetchQuery< TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(input: FetchQueryInput): UseQueryResult { - return useQuery(fetchQueryOptions(input)); +>( + input: FetchQueryInput, + queryClient?: QueryClient, +): UseQueryResult { + return useQuery(fetchQueryOptions(input), queryClient); } export function useFetchQueries( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0247a2ba4..039ed1c4d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { common: { @@ -1785,8 +1786,10 @@ export const ar: TranslationResources = { providers: "مقدمي الخدمات", usage: "الاستخدام", terminals: "Terminals", + plugins: "Plugins", host: "نظرة عامة", }, + plugins: pluginSettings.ar, metadataGeneration: { title: "إنشاء البيانات الوصفية", description: diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b655cd397..733929c11 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1796,8 +1796,56 @@ export const en = { providers: "Providers", usage: "Usage", terminals: "Terminals", + plugins: "Plugins", host: "Overview", }, + plugins: { + title: "Plugins", + trustedTitle: "Plugins are trusted code", + trustedDescription: + "They run on this host and inside the app without sandboxing. Install only code you trust.", + globalTitle: "Enable plugins", + globalHint: "Global switch for every configured plugin", + directoryLabel: "Plugin directory", + directoryPlaceholder: "/absolute/path/on/host", + idLabel: "Plugin installation ID", + idHint: "Leave blank to use paseo-plugin.json", + idPlaceholder: "Manifest default", + install: "Install directory", + installing: "Installing…", + status: { running: "running", disabled: "disabled", failed: "failed" }, + actions: { + reload: "Reload", + reloading: "Reloading…", + enable: "Enable", + enabling: "Enabling…", + disable: "Disable", + disabling: "Disabling…", + remove: "Remove", + removing: "Removing…", + }, + feedback: { + installed: "Installed {{id}}", + reload: "Reloaded {{id}}", + enable: "Enabled {{id}}", + disable: "Disabled {{id}}", + remove: "Removed {{id}}", + kept: "Kept {{id}}", + globalEnabled: "Plugins enabled", + globalDisabled: "Plugins disabled", + }, + removeConfirmTitle: "Remove {{id}}?", + removeConfirmMessage: "This removes the configuration. The source directory is not deleted.", + states: { + offlineTitle: "Plugin host is offline", + offlineDescription: "Reconnect to this host to manage its plugins.", + updateTitle: "Update this host to manage plugins", + loading: "Loading plugins…", + errorTitle: "Unable to load plugins", + retry: "Retry", + empty: "No plugins configured", + }, + }, metadataGeneration: { title: "Metadata generation", description: diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 911c3977f..d6f8b9efb 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { common: { @@ -1828,8 +1829,10 @@ export const es: TranslationResources = { providers: "Proveedores", usage: "Uso", terminals: "Terminals", + plugins: "Plugins", host: "Resumen", }, + plugins: pluginSettings.es, metadataGeneration: { title: "Generación de metadatos", description: diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ac39cbd04..a2075164c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { common: { @@ -1832,8 +1833,10 @@ export const fr: TranslationResources = { providers: "Fournisseurs", usage: "Utilisation", terminals: "Terminals", + plugins: "Plugins", host: "Aperçu", }, + plugins: pluginSettings.fr, metadataGeneration: { title: "Génération de métadonnées", description: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 915bb6aaf..f22347533 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { common: { @@ -1801,8 +1802,10 @@ export const ja: TranslationResources = { providers: "プロバイダー", usage: "使用状況", terminals: "ターミナル", + plugins: "プラグイン", host: "概要", }, + plugins: pluginSettings.ja, metadataGeneration: { title: "メタデータ生成", description: diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 17bb85dbb..7d3d8f507 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { common: { @@ -1795,8 +1796,10 @@ export const ko: TranslationResources = { providers: "프로바이더", usage: "사용량", terminals: "터미널", + plugins: "플러그인", host: "개요", }, + plugins: pluginSettings.ko, metadataGeneration: { title: "메타데이터 생성", description: diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts new file mode 100644 index 000000000..946599062 --- /dev/null +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -0,0 +1,380 @@ +const shared = { + directoryPlaceholder: "/absolute/path/on/host", +} as const; + +export const pluginSettings = { + ar: { + ...shared, + title: "الإضافات", + trustedTitle: "الإضافات تعليمات برمجية موثوقة", + trustedDescription: + "تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.", + globalTitle: "تفعيل الإضافات", + globalHint: "مفتاح عام لكل الإضافات المضبوطة", + directoryLabel: "مجلد الإضافة", + idLabel: "معرّف تثبيت الإضافة", + idHint: "اتركه فارغًا لاستخدام paseo-plugin.json", + idPlaceholder: "القيمة الافتراضية للبيان", + install: "تثبيت المجلد", + installing: "جارٍ التثبيت…", + status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, + actions: { + reload: "إعادة تحميل", + reloading: "جارٍ إعادة التحميل…", + enable: "تفعيل", + enabling: "جارٍ التفعيل…", + disable: "تعطيل", + disabling: "جارٍ التعطيل…", + remove: "إزالة", + removing: "جارٍ الإزالة…", + }, + feedback: { + installed: "تم تثبيت {{id}}", + reload: "تمت إعادة تحميل {{id}}", + enable: "تم تفعيل {{id}}", + disable: "تم تعطيل {{id}}", + remove: "تمت إزالة {{id}}", + kept: "تم الإبقاء على {{id}}", + globalEnabled: "تم تفعيل الإضافات", + globalDisabled: "تم تعطيل الإضافات", + }, + removeConfirmTitle: "إزالة {{id}}؟", + removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.", + states: { + offlineTitle: "مضيف الإضافات غير متصل", + offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.", + updateTitle: "حدّث هذا المضيف لإدارة الإضافات", + loading: "جارٍ تحميل الإضافات…", + errorTitle: "تعذّر تحميل الإضافات", + retry: "إعادة المحاولة", + empty: "لا توجد إضافات مضبوطة", + }, + }, + es: { + ...shared, + title: "Plugins", + trustedTitle: "Los plugins son código de confianza", + trustedDescription: + "Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.", + globalTitle: "Activar plugins", + globalHint: "Interruptor global para todos los plugins configurados", + directoryLabel: "Directorio del plugin", + idLabel: "ID de instalación del plugin", + idHint: "Déjalo vacío para usar paseo-plugin.json", + idPlaceholder: "Valor del manifiesto", + install: "Instalar directorio", + installing: "Instalando…", + status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, + actions: { + reload: "Recargar", + reloading: "Recargando…", + enable: "Activar", + enabling: "Activando…", + disable: "Desactivar", + disabling: "Desactivando…", + remove: "Eliminar", + removing: "Eliminando…", + }, + feedback: { + installed: "{{id}} instalado", + reload: "{{id}} recargado", + enable: "{{id}} activado", + disable: "{{id}} desactivado", + remove: "{{id}} eliminado", + kept: "Se conservó {{id}}", + globalEnabled: "Plugins activados", + globalDisabled: "Plugins desactivados", + }, + removeConfirmTitle: "¿Eliminar {{id}}?", + removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.", + states: { + offlineTitle: "El host de plugins está sin conexión", + offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.", + updateTitle: "Actualiza este host para gestionar plugins", + loading: "Cargando plugins…", + errorTitle: "No se pudieron cargar los plugins", + retry: "Reintentar", + empty: "No hay plugins configurados", + }, + }, + fr: { + ...shared, + title: "Plugins", + trustedTitle: "Les plugins sont du code de confiance", + trustedDescription: + "Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.", + globalTitle: "Activer les plugins", + globalHint: "Interrupteur global pour tous les plugins configurés", + directoryLabel: "Dossier du plugin", + idLabel: "ID d’installation du plugin", + idHint: "Laissez vide pour utiliser paseo-plugin.json", + idPlaceholder: "Valeur du manifeste", + install: "Installer le dossier", + installing: "Installation…", + status: { running: "actif", disabled: "désactivé", failed: "échec" }, + actions: { + reload: "Recharger", + reloading: "Rechargement…", + enable: "Activer", + enabling: "Activation…", + disable: "Désactiver", + disabling: "Désactivation…", + remove: "Supprimer", + removing: "Suppression…", + }, + feedback: { + installed: "{{id}} installé", + reload: "{{id}} rechargé", + enable: "{{id}} activé", + disable: "{{id}} désactivé", + remove: "{{id}} supprimé", + kept: "{{id}} conservé", + globalEnabled: "Plugins activés", + globalDisabled: "Plugins désactivés", + }, + removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", + states: { + offlineTitle: "L’hôte des plugins est hors ligne", + offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.", + updateTitle: "Mettez cet hôte à jour pour gérer les plugins", + loading: "Chargement des plugins…", + errorTitle: "Impossible de charger les plugins", + retry: "Réessayer", + empty: "Aucun plugin configuré", + }, + }, + ja: { + ...shared, + title: "プラグイン", + trustedTitle: "プラグインは信頼済みコードです", + trustedDescription: + "ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。", + globalTitle: "プラグインを有効化", + globalHint: "設定済みプラグインすべての共通スイッチ", + directoryLabel: "プラグインディレクトリ", + idLabel: "プラグインのインストール ID", + idHint: "paseo-plugin.json を使う場合は空欄にします", + idPlaceholder: "マニフェストの既定値", + install: "ディレクトリをインストール", + installing: "インストール中…", + status: { running: "実行中", disabled: "無効", failed: "失敗" }, + actions: { + reload: "再読み込み", + reloading: "再読み込み中…", + enable: "有効化", + enabling: "有効化中…", + disable: "無効化", + disabling: "無効化中…", + remove: "削除", + removing: "削除中…", + }, + feedback: { + installed: "{{id}} をインストールしました", + reload: "{{id}} を再読み込みしました", + enable: "{{id}} を有効にしました", + disable: "{{id}} を無効にしました", + remove: "{{id}} を削除しました", + kept: "{{id}} を保持しました", + globalEnabled: "プラグインを有効にしました", + globalDisabled: "プラグインを無効にしました", + }, + removeConfirmTitle: "{{id}} を削除しますか?", + removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。", + states: { + offlineTitle: "プラグインホストはオフラインです", + offlineDescription: "プラグインを管理するにはホストに再接続してください。", + updateTitle: "プラグインを管理するにはホストを更新してください", + loading: "プラグインを読み込み中…", + errorTitle: "プラグインを読み込めません", + retry: "再試行", + empty: "設定済みプラグインはありません", + }, + }, + ko: { + ...shared, + title: "플러그인", + trustedTitle: "플러그인은 신뢰하는 코드입니다", + trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", + globalTitle: "플러그인 활성화", + globalHint: "설정된 모든 플러그인의 전역 스위치", + directoryLabel: "플러그인 디렉터리", + idLabel: "플러그인 설치 ID", + idHint: "paseo-plugin.json을 사용하려면 비워 두세요", + idPlaceholder: "매니페스트 기본값", + install: "디렉터리 설치", + installing: "설치 중…", + status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, + actions: { + reload: "다시 로드", + reloading: "다시 로드 중…", + enable: "활성화", + enabling: "활성화 중…", + disable: "비활성화", + disabling: "비활성화 중…", + remove: "제거", + removing: "제거 중…", + }, + feedback: { + installed: "{{id}} 설치됨", + reload: "{{id}} 다시 로드됨", + enable: "{{id}} 활성화됨", + disable: "{{id}} 비활성화됨", + remove: "{{id}} 제거됨", + kept: "{{id}} 유지됨", + globalEnabled: "플러그인 활성화됨", + globalDisabled: "플러그인 비활성화됨", + }, + removeConfirmTitle: "{{id}}을(를) 제거할까요?", + removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.", + states: { + offlineTitle: "플러그인 호스트가 오프라인입니다", + offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.", + updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요", + loading: "플러그인 로드 중…", + errorTitle: "플러그인을 로드할 수 없습니다", + retry: "다시 시도", + empty: "설정된 플러그인이 없습니다", + }, + }, + "pt-BR": { + ...shared, + title: "Plugins", + trustedTitle: "Plugins são código confiável", + trustedDescription: + "Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.", + globalTitle: "Ativar plugins", + globalHint: "Controle global para todos os plugins configurados", + directoryLabel: "Diretório do plugin", + idLabel: "ID de instalação do plugin", + idHint: "Deixe vazio para usar paseo-plugin.json", + idPlaceholder: "Padrão do manifesto", + install: "Instalar diretório", + installing: "Instalando…", + status: { running: "em execução", disabled: "desativado", failed: "falhou" }, + actions: { + reload: "Recarregar", + reloading: "Recarregando…", + enable: "Ativar", + enabling: "Ativando…", + disable: "Desativar", + disabling: "Desativando…", + remove: "Remover", + removing: "Removendo…", + }, + feedback: { + installed: "{{id}} instalado", + reload: "{{id}} recarregado", + enable: "{{id}} ativado", + disable: "{{id}} desativado", + remove: "{{id}} removido", + kept: "{{id}} mantido", + globalEnabled: "Plugins ativados", + globalDisabled: "Plugins desativados", + }, + removeConfirmTitle: "Remover {{id}}?", + removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.", + states: { + offlineTitle: "O host de plugins está offline", + offlineDescription: "Reconecte este host para gerenciar seus plugins.", + updateTitle: "Atualize este host para gerenciar plugins", + loading: "Carregando plugins…", + errorTitle: "Não foi possível carregar os plugins", + retry: "Tentar novamente", + empty: "Nenhum plugin configurado", + }, + }, + ru: { + ...shared, + title: "Плагины", + trustedTitle: "Плагины — доверенный код", + trustedDescription: + "Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.", + globalTitle: "Включить плагины", + globalHint: "Общий переключатель для всех настроенных плагинов", + directoryLabel: "Каталог плагина", + idLabel: "ID установки плагина", + idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json", + idPlaceholder: "Значение из манифеста", + install: "Установить каталог", + installing: "Установка…", + status: { running: "работает", disabled: "отключён", failed: "ошибка" }, + actions: { + reload: "Перезагрузить", + reloading: "Перезагрузка…", + enable: "Включить", + enabling: "Включение…", + disable: "Отключить", + disabling: "Отключение…", + remove: "Удалить", + removing: "Удаление…", + }, + feedback: { + installed: "{{id}} установлен", + reload: "{{id}} перезагружен", + enable: "{{id}} включён", + disable: "{{id}} отключён", + remove: "{{id}} удалён", + kept: "{{id}} сохранён", + globalEnabled: "Плагины включены", + globalDisabled: "Плагины отключены", + }, + removeConfirmTitle: "Удалить {{id}}?", + removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.", + states: { + offlineTitle: "Хост плагинов не в сети", + offlineDescription: "Подключите хост снова, чтобы управлять плагинами.", + updateTitle: "Обновите хост для управления плагинами", + loading: "Загрузка плагинов…", + errorTitle: "Не удалось загрузить плагины", + retry: "Повторить", + empty: "Нет настроенных плагинов", + }, + }, + "zh-CN": { + ...shared, + title: "插件", + trustedTitle: "插件是受信任的代码", + trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", + globalTitle: "启用插件", + globalHint: "所有已配置插件的全局开关", + directoryLabel: "插件目录", + idLabel: "插件安装 ID", + idHint: "留空以使用 paseo-plugin.json", + idPlaceholder: "清单默认值", + install: "安装目录", + installing: "正在安装…", + status: { running: "运行中", disabled: "已禁用", failed: "失败" }, + actions: { + reload: "重新加载", + reloading: "正在重新加载…", + enable: "启用", + enabling: "正在启用…", + disable: "禁用", + disabling: "正在禁用…", + remove: "移除", + removing: "正在移除…", + }, + feedback: { + installed: "已安装 {{id}}", + reload: "已重新加载 {{id}}", + enable: "已启用 {{id}}", + disable: "已禁用 {{id}}", + remove: "已移除 {{id}}", + kept: "已保留 {{id}}", + globalEnabled: "插件已启用", + globalDisabled: "插件已禁用", + }, + removeConfirmTitle: "移除 {{id}}?", + removeConfirmMessage: "这会移除配置,但不会删除源目录。", + states: { + offlineTitle: "插件主机离线", + offlineDescription: "重新连接此主机以管理其插件。", + updateTitle: "更新此主机以管理插件", + loading: "正在加载插件…", + errorTitle: "无法加载插件", + retry: "重试", + empty: "没有已配置的插件", + }, + }, +} as const; diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index fea908d4b..ea2404a05 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { common: { @@ -1814,8 +1815,10 @@ export const ptBR: TranslationResources = { providers: "Provedores", usage: "Uso", terminals: "Terminais", + plugins: "Plugins", host: "Visão geral", }, + plugins: pluginSettings["pt-BR"], metadataGeneration: { title: "Geração de metadados", description: diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3305774cb..189875688 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { common: { @@ -1819,8 +1820,10 @@ export const ru: TranslationResources = { providers: "Провайдеры", usage: "Использование", terminals: "Terminals", + plugins: "Plugins", host: "Обзор", }, + plugins: pluginSettings.ru, metadataGeneration: { title: "Создание метаданных", description: diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bc02aa5bf..b93bb98cb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { common: { @@ -1765,8 +1766,10 @@ export const zhCN: TranslationResources = { providers: "Providers", usage: "使用情况", terminals: "Terminals", + plugins: "插件", host: "概览", }, + plugins: pluginSettings["zh-CN"], metadataGeneration: { title: "元数据生成", description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型", diff --git a/packages/app/src/plugins/attachments/index.ts b/packages/app/src/plugins/attachments/index.ts new file mode 100644 index 000000000..785bd8cd5 --- /dev/null +++ b/packages/app/src/plugins/attachments/index.ts @@ -0,0 +1,5 @@ +export { + PluginResourceComposerAttachmentSchema, + pluginResourceAttachmentToAgentAttachment, + type PluginResourceComposerAttachment, +} from "./model"; diff --git a/packages/app/src/plugins/attachments/model.test.ts b/packages/app/src/plugins/attachments/model.test.ts new file mode 100644 index 000000000..3aeb112ea --- /dev/null +++ b/packages/app/src/plugins/attachments/model.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + createPluginResourceAttachment, + PluginResourceComposerAttachmentSchema, + pluginResourceAttachmentToAgentAttachment, + togglePluginResourceAttachment, +} from "./model"; +import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; + +const source = { + pluginId: "linear", + sourceId: "issues", + sourceTitle: "Linear issue", + sourceIcon: "CircleDot", +}; + +const item = { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + subtitle: "In progress · Mohamed", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + text: "Linear issue ENG-123: Plugin attachments\nStatus: In progress", + resourceType: "issue", +}; + +describe("plugin resource attachments", () => { + it("creates a draft-safe composer attachment", () => { + const attachment = createPluginResourceAttachment(source, item); + + expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({ + kind: "plugin_resource", + ...source, + item, + }); + }); + + it("toggles one resource by plugin, source, and stable item id", () => { + const attachment = createPluginResourceAttachment(source, item); + + expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]); + expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]); + }); + + it("submits through the backward-compatible text attachment", () => { + const attachment = createPluginResourceAttachment(source, item); + + const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment); + expect(agentAttachment).toEqual({ + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: item.text, + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: item.url, + }, + }); + expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({ + images: [], + attachments: [agentAttachment], + }); + }); +}); diff --git a/packages/app/src/plugins/attachments/model.ts b/packages/app/src/plugins/attachments/model.ts new file mode 100644 index 000000000..c9b2a34d4 --- /dev/null +++ b/packages/app/src/plugins/attachments/model.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@paseo/plugin"; +import type { UserComposerAttachment } from "@/attachments/types"; + +export const PluginResourceComposerAttachmentSchema = z.object({ + kind: z.literal("plugin_resource"), + pluginId: z.string().min(1), + sourceId: z.string().min(1), + sourceTitle: z.string().min(1), + sourceIcon: z.string().min(1), + item: PluginAttachmentItemSchema, +}); + +export type PluginResourceComposerAttachment = z.infer< + typeof PluginResourceComposerAttachmentSchema +>; + +export interface PluginResourceSourceIdentity { + pluginId: string; + sourceId: string; + sourceTitle: string; + sourceIcon: string; +} + +export function createPluginResourceAttachment( + source: PluginResourceSourceIdentity, + item: PluginAttachmentItem, +): PluginResourceComposerAttachment { + return { + kind: "plugin_resource", + ...source, + item, + }; +} + +export function togglePluginResourceAttachment( + current: UserComposerAttachment[], + attachment: PluginResourceComposerAttachment, +): UserComposerAttachment[] { + const matches = (candidate: UserComposerAttachment) => + candidate.kind === "plugin_resource" && + candidate.pluginId === attachment.pluginId && + candidate.sourceId === attachment.sourceId && + candidate.item.id === attachment.item.id; + if (current.some(matches)) { + return current.filter((candidate) => !matches(candidate)); + } + return [...current, attachment]; +} + +export function pluginResourceAttachmentToAgentAttachment( + attachment: PluginResourceComposerAttachment, +): AgentAttachment { + return { + type: "text", + mimeType: "text/plain", + title: `${attachment.item.identifier} ${attachment.item.title}`, + text: attachment.item.text, + externalResource: { + provider: attachment.pluginId, + providerLabel: attachment.sourceTitle, + resourceType: attachment.item.resourceType, + id: attachment.item.id, + identifier: attachment.item.identifier, + title: attachment.item.title, + url: attachment.item.url, + }, + }; +} diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx new file mode 100644 index 000000000..7ccbb79da --- /dev/null +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -0,0 +1,179 @@ +import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react"; +import { View } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@paseo/plugin"; +import { searchPluginAttachments } from "@paseo/plugin/host"; +import type { LucideIcon } from "lucide-react-native"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { AttachmentMenuItem } from "@/composer/input/input"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useFetchQuery } from "@/data/query"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { resolvePluginIcon } from "../icons"; +import { useInstalledPlugins } from "../registry"; +import type { InstalledPlugin } from "../types"; +import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model"; + +const SEARCH_STALE_TIME_MS = 30_000; +const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = []; +const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { + return ; +} + +const ThemedSourceIcon = withUnistyles(SourceIcon); + +interface InstalledAttachmentSource { + plugin: InstalledPlugin; + source: PluginAttachmentSourceContribution; + key: string; +} + +interface PluginAttachmentPickerInput { + serverId: string; + client: DaemonClient | null; + connected: boolean; + attachments: UserComposerAttachment[]; + onChangeAttachments: (attachments: UserComposerAttachment[]) => void; + anchorRef: RefObject; +} + +interface PluginAttachmentPickerBinding { + menuItems: AttachmentMenuItem[]; + picker: ReactElement | null; +} + +function searchEmptyText(error: unknown, isFetching: boolean): string { + if (error instanceof Error) return error.message; + if (error) return String(error); + return isFetching ? "Searching..." : "No results"; +} + +function installedAttachmentSources( + plugins: InstalledPlugin[], + serverId: string, +): InstalledAttachmentSource[] { + return plugins + .filter((plugin) => plugin.serverId === serverId) + .flatMap((plugin) => + plugin.attachmentSources.map((source) => ({ + plugin, + source, + key: `${plugin.id}/${source.id}`, + })), + ); +} + +function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] { + return items.map((item) => ({ + id: item.id, + label: `${item.identifier} ${item.title}`, + description: item.subtitle, + })); +} + +export function usePluginAttachmentPicker( + input: PluginAttachmentPickerInput, +): PluginAttachmentPickerBinding { + const plugins = useInstalledPlugins(); + const sources = useMemo( + () => installedAttachmentSources(plugins, input.serverId), + [input.serverId, plugins], + ); + const [activeKey, setActiveKey] = useState(null); + const [query, setQuery] = useState(""); + const active = sources.find((candidate) => candidate.key === activeKey) ?? null; + const trimmedQuery = query.trim(); + const search = useFetchQuery( + { + queryKey: [ + "plugin-attachment-search", + input.serverId, + active?.plugin.id ?? "", + active?.source.id ?? "", + trimmedQuery, + ], + queryFn: async () => { + if (!input.client || !active) throw new Error("Plugin host is offline"); + const client = input.client; + return searchPluginAttachments( + active.source, + (method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput), + trimmedQuery, + ); + }, + enabled: input.connected && active !== null, + dataShape: "list", + staleTimeMs: SEARCH_STALE_TIME_MS, + }, + active?.plugin.queryClient, + ); + const items = search.error + ? EMPTY_ATTACHMENT_ITEMS + : (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS); + const options = useMemo(() => attachmentOptions(items), [items]); + const close = useCallback(() => { + setActiveKey(null); + setQuery(""); + }, []); + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) close(); + }, + [close], + ); + const handleSelect = useCallback( + (itemId: string) => { + if (!active) return; + const item = items.find((candidate) => candidate.id === itemId); + if (!item) return; + const attachment = createPluginResourceAttachment( + { + pluginId: active.plugin.id, + sourceId: active.source.id, + sourceTitle: active.source.title, + sourceIcon: active.source.icon, + }, + item, + ); + input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment)); + close(); + }, + [active, close, input, items], + ); + const menuItems = useMemo( + () => + sources.map(({ key, source }) => { + const Icon = resolvePluginIcon(source.icon); + return { + id: `plugin:${key}`, + label: `Attach ${source.title}`, + icon: , + onSelect: () => setActiveKey(key), + }; + }), + [sources], + ); + if (!active) return { menuItems, picker: null }; + return { + menuItems, + picker: ( + + ), + }; +} diff --git a/packages/app/src/plugins/attachments/pill.tsx b/packages/app/src/plugins/attachments/pill.tsx new file mode 100644 index 000000000..18fb926b5 --- /dev/null +++ b/packages/app/src/plugins/attachments/pill.tsx @@ -0,0 +1,65 @@ +import { useCallback, useMemo } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; +import type { ComposerAttachment } from "@/attachments/types"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { resolvePluginIcon } from "../icons"; +import type { PluginResourceComposerAttachment } from "./model"; + +const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function ResourceIcon({ + Icon, + color = "", +}: { + Icon: ReturnType; + color?: string; +}) { + return ; +} + +const ThemedResourceIcon = withUnistyles(ResourceIcon); + +interface PluginResourceAttachmentPillProps { + attachment: PluginResourceComposerAttachment; + index: number; + disabled: boolean; + onOpen: (attachment: ComposerAttachment) => void; + onRemove: (index: number) => void; + openLabel: (kind: string, identifier: string) => string; + removeLabel: (kind: string, identifier: string) => string; +} + +export function PluginResourceAttachmentPill({ + attachment, + index, + disabled, + onOpen, + onRemove, + openLabel, + removeLabel, +}: PluginResourceAttachmentPillProps) { + const Icon = resolvePluginIcon(attachment.sourceIcon); + const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]); + const handleRemove = useCallback(() => onRemove(index), [index, onRemove]); + const icon = useMemo( + () => , + [Icon], + ); + return ( + + + + ); +} diff --git a/packages/app/src/plugins/catalog-sync.test.tsx b/packages/app/src/plugins/catalog-sync.test.tsx new file mode 100644 index 000000000..bbb5ce684 --- /dev/null +++ b/packages/app/src/plugins/catalog-sync.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +const connection = vi.hoisted(() => ({ connected: true, supported: true })); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeIsConnected: () => connection.connected, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: () => connection.supported, +})); + +import { PluginCatalogSync } from "./catalog-sync"; +import { pluginRegistry } from "./registry"; + +function bundle(): string { + return `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; }; + } }; })`; +} + +const unsubscribe = vi.fn(); +const catalogClient = { + getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]), + on: vi.fn(() => unsubscribe), +} as unknown as DaemonClient; + +describe("PluginCatalogSync", () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + connection.connected = true; + connection.supported = true; + unsubscribe.mockReset(); + vi.mocked(catalogClient.getPluginCatalog).mockClear(); + vi.mocked(catalogClient.on).mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + pluginRegistry.removeHost("host-a"); + container.remove(); + Reflect.deleteProperty(globalThis, "__catalogSyncCleanups"); + vi.unstubAllGlobals(); + }); + + async function renderSync(): Promise { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + } + + it("tears down through disconnect and unmount boundaries exactly once each", async () => { + await renderSync(); + const first = pluginRegistry.getSnapshot()[0]; + expect(first?.id).toBe("example"); + first?.queryClient.setQueryData(["owned"], "state"); + + connection.connected = false; + await renderSync(); + + expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1); + expect(first?.queryClient.getQueryCache().getAll()).toEqual([]); + expect(pluginRegistry.getSnapshot()).toEqual([]); + + connection.connected = true; + await renderSync(); + expect(pluginRegistry.getSnapshot()).toHaveLength(1); + + act(() => root.unmount()); + root = createRoot(container); + + expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2); + expect(pluginRegistry.getSnapshot()).toEqual([]); + expect(unsubscribe).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx new file mode 100644 index 000000000..763b858ca --- /dev/null +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -0,0 +1,62 @@ +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; + let refreshQueue = Promise.resolve(); + if (!supported) { + pluginRegistry.removeHost(serverId); + return; + } + if (!connected) { + pluginRegistry.removeHost(serverId); + return; + } + const refresh = (replacePluginId?: string) => { + refreshQueue = refreshQueue.then(() => + client + .getPluginCatalog() + .then((catalog) => { + if (!cancelled) { + pluginRegistry.installCatalog(serverId, catalog, { replacePluginId }); + } + return undefined; + }) + .catch((error) => { + if (!cancelled) { + console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); + } + return undefined; + }), + ); + return refreshQueue; + }; + void refresh(); + const unsubscribe = client.on("status", (message) => { + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [client, connected, serverId, supported]); + + useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); + return null; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts new file mode 100644 index 000000000..48831cc73 --- /dev/null +++ b/packages/app/src/plugins/evaluate.test.ts @@ -0,0 +1,111 @@ +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 function() {}; }; + 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("collects a declarative attachment source", () => { + const plugin = evaluatePluginClientBundle( + "linear", + bundle(` + plugin.addAttachmentSource({ + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: { name: "issues.search", input: {}, output: {} }, + }); + `), + ); + + expect(plugin.attachmentSources).toEqual([ + { + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: { name: "issues.search", input: {}, output: {} }, + }, + ]); + }); + + it("rejects duplicate attachment source ids", () => { + expect(() => + evaluatePluginClientBundle( + "linear", + bundle(` + const source = { + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search", + search: { name: "issues.search", input: {}, output: {} }, + }; + plugin.addAttachmentSource(source); + plugin.addAttachmentSource(source); + `), + ), + ).toThrow("Duplicate attachment source: issues"); + }); + + 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", + ); + }); + + it("requires a cleanup function", () => { + expect(() => + evaluatePluginClientBundle("example", `(function() { return { default: function() {} }; })`), + ).toThrow("must return a cleanup function"); + }); + + it("does not publish partial contributions when setup fails", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + throw new Error("setup exploded"); + } }; })`, + ), + ).toThrow("setup exploded"); + }); +}); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts new file mode 100644 index 000000000..717167950 --- /dev/null +++ b/packages/app/src/plugins/evaluate.ts @@ -0,0 +1,135 @@ +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 { + defineAttachmentSource, + defineRpc, + type PluginAttachmentSourceContribution, + type PluginSidebarContribution, + type PluginSurfaceProps, + useRpc, +} from "@paseo/plugin"; +import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host"; +import type { EvaluatedPlugin } 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: [], + attachmentSources: [], + }; + const surfaceIds = new Set(); + const sidebarItemIds = new Set(); + const attachmentSourceIds = new Set(); + const pluginContext = createPluginContext({ + addSurface(surfaceId: string, Component: ComponentType) { + const normalizedId = requireId(surfaceId, "surface id"); + if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); + if (typeof Component !== "function") + throw new Error(`Surface ${normalizedId} is not a component`); + surfaceIds.add(normalizedId); + collector.surfaces.push({ id: normalizedId, Component }); + }, + addSidebarItem(contribution: PluginSidebarContribution) { + const normalizedId = requireId(contribution.id, "sidebar item id"); + if (sidebarItemIds.has(normalizedId)) + throw new Error(`Duplicate sidebar item: ${normalizedId}`); + if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`); + if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); + resolvePluginIcon(contribution.icon.trim()); + sidebarItemIds.add(normalizedId); + collector.sidebarItems.push({ + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }); + }, + addAttachmentSource(contribution: PluginAttachmentSourceContribution) { + const normalizedId = requireId(contribution.id, "attachment source id"); + if (attachmentSourceIds.has(normalizedId)) { + throw new Error(`Duplicate attachment source: ${normalizedId}`); + } + const title = contribution.title.trim(); + const icon = contribution.icon.trim(); + const pickerTitle = contribution.pickerTitle.trim(); + const searchPlaceholder = contribution.searchPlaceholder.trim(); + const method = contribution.search.name.trim(); + if (!title) throw new Error(`Attachment source ${normalizedId} has no title`); + if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`); + if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`); + if (!searchPlaceholder) { + throw new Error(`Attachment source ${normalizedId} has no search placeholder`); + } + if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`); + resolvePluginIcon(icon); + attachmentSourceIds.add(normalizedId); + collector.attachmentSources.push({ + id: normalizedId, + title, + icon, + pickerTitle, + searchPlaceholder, + search: { ...contribution.search, name: method }, + }); + }, + }); + 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 { defineAttachmentSource, 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`); + } + const cleanup = setup(pluginContext); + if (typeof cleanup !== "function") { + throw new Error(`Plugin ${id} contribution must return a cleanup function`); + } + + try { + for (const item of collector.sidebarItems) { + if (!surfaceIds.has(item.surface)) { + throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); + } + } + } catch (error) { + try { + cleanup(); + } catch (cleanupError) { + console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); + } + throw error; + } + return { + id, + cleanup, + surfaces: collector.surfaces, + sidebarItems: collector.sidebarItems, + attachmentSources: collector.attachmentSources, + }; +} diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts new file mode 100644 index 000000000..de36d962d --- /dev/null +++ b/packages/app/src/plugins/icons.ts @@ -0,0 +1,11 @@ +import * as LucideIcons from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; + +export function resolvePluginIcon(name: string): LucideIcon { + const candidate = Reflect.get(LucideIcons, name); + const isComponent = + typeof candidate === "function" || + (typeof candidate === "object" && candidate !== null && "$$typeof" in candidate); + if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`); + return candidate as LucideIcon; +} diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts new file mode 100644 index 000000000..dc74f3e07 --- /dev/null +++ b/packages/app/src/plugins/index.ts @@ -0,0 +1,5 @@ +export { PluginCatalogSync } from "./catalog-sync"; +export { PluginSidebarItems } from "./sidebar-items"; +export { PluginSurfaceScreen } from "./surface-screen"; +export { usePluginAttachmentPicker } from "./attachments/picker"; +export { PluginResourceAttachmentPill } from "./attachments/pill"; diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts new file mode 100644 index 000000000..1b1a56343 --- /dev/null +++ b/packages/app/src/plugins/registry.test.ts @@ -0,0 +1,140 @@ +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 function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; }; + }; + return module.exports; + })`; +} + +afterEach(() => { + pluginRegistry.removeHost("host-a"); + pluginRegistry.removeHost("host-b"); + Reflect.deleteProperty(globalThis, "__pluginCleanups"); +}); + +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).toBe(first); + 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(); + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + }); + + 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(); + }); + + it("cleans up the old installation before evaluating its replacement", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const replacement = `(function() { return { default: function(plugin) { + globalThis.__cleanupSeenByReplacement = globalThis.__pluginCleanups || 0; + plugin.addSurface("main", function() { return null; }); + return function() {}; + } }; })`; + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: replacement }]); + + expect(Reflect.get(globalThis, "__cleanupSeenByReplacement")).toBe(1); + Reflect.deleteProperty(globalThis, "__cleanupSeenByReplacement"); + }); + + it("replaces an unchanged bundle when explicit reload names the plugin", () => { + const clientBundle = bundle("same"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["attachment-search"], "cached"); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], { + replacePluginId: "example", + }); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second).not.toBe(first); + expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined(); + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + }); + + it.each(["disable", "remove"])("cleans a catalog entry exactly once on %s", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + + pluginRegistry.installCatalog("host-a", []); + pluginRegistry.installCatalog("host-a", []); + + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + expect(pluginRegistry.getSnapshot()).toEqual([]); + }); + + it("cancels an in-flight attachment query during teardown", async () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const plugin = pluginRegistry.getSnapshot()[0]; + if (!plugin) throw new Error("Expected installed plugin"); + let observeAbort: () => void = () => undefined; + const aborted = new Promise((resolve) => { + observeAbort = resolve; + }); + void plugin.queryClient + .fetchQuery({ + queryKey: ["plugin-attachment-search", "example"], + queryFn: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + observeAbort(); + reject(new Error("aborted")); + }); + }), + }) + .catch(() => undefined); + + pluginRegistry.installCatalog("host-a", []); + + await aborted; + expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]); + }); + + it("continues host teardown when cleanup throws", () => { + const throwing = `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + return function() { throw new Error("cleanup exploded"); }; + } }; })`; + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: throwing }]); + pluginRegistry.getSnapshot()[0]?.queryClient.setQueryData(["counter"], 3); + + expect(() => pluginRegistry.removeHost("host-a")).not.toThrow(); + expect(pluginRegistry.getSnapshot()).toEqual([]); + }); +}); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts new file mode 100644 index 000000000..32d4e1d52 --- /dev/null +++ b/packages/app/src/plugins/registry.ts @@ -0,0 +1,139 @@ +import { useSyncExternalStore } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { evaluatePluginClientBundle } from "./evaluate"; +import type { InstalledPlugin } from "./types"; + +interface CatalogPlugin { + id: string; + clientBundle: string; +} + +class PluginRegistry { + private readonly byHost = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshot: InstalledPlugin[] = []; + private readonly disposed = new WeakSet(); + private readonly evaluationErrors = new Map(); + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): InstalledPlugin[] => this.snapshot; + + getEvaluationError(serverId: string, pluginId: string): string | undefined { + return this.evaluationErrors.get(`${serverId}/${pluginId}`); + } + + installCatalog( + serverId: string, + catalog: CatalogPlugin[], + options: { replacePluginId?: string } = {}, + ): void { + const previous = this.byHost.get(serverId) ?? []; + const preserved = catalog.flatMap((entry) => { + const existing = previous.find( + (plugin) => + plugin.id !== options.replacePluginId && + plugin.id === entry.id && + plugin.clientBundle === entry.clientBundle, + ); + return existing ? [existing] : []; + }); + const removed = previous.filter((plugin) => !preserved.includes(plugin)); + if (removed.length > 0) { + this.byHost.set(serverId, preserved); + this.publish(); + for (const plugin of removed) this.dispose(plugin); + } + const installed = catalog.flatMap((entry) => { + const key = `${serverId}/${entry.id}`; + try { + const existing = preserved.find( + (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, + ); + if (existing) { + this.evaluationErrors.delete(key); + return [existing]; + } + const queryClient = new QueryClient(); + const evaluated = [ + { + ...evaluatePluginClientBundle(entry.id, entry.clientBundle), + serverId, + clientBundle: entry.clientBundle, + queryClient, + }, + ]; + this.evaluationErrors.delete(key); + return evaluated; + } catch (error) { + this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); + console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); + return []; + } + }); + const configuredIds = new Set(catalog.map((entry) => entry.id)); + for (const key of this.evaluationErrors.keys()) { + if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) { + this.evaluationErrors.delete(key); + } + } + 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) this.dispose(plugin); + for (const key of this.evaluationErrors.keys()) { + if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); + } + this.byHost.delete(serverId); + this.publish(); + } + + private dispose(plugin: InstalledPlugin): void { + if (this.disposed.has(plugin)) return; + this.disposed.add(plugin); + plugin.queryClient.clear(); + try { + plugin.cleanup(); + } catch (error) { + console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error); + } + } + + private publish(): void { + this.snapshot = [...this.byHost.values()] + .flat() + .sort((left, right) => + `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), + ); + for (const listener of this.listeners) listener(); + } +} + +export const pluginRegistry = new PluginRegistry(); + +export function useInstalledPlugins(): InstalledPlugin[] { + return useSyncExternalStore( + pluginRegistry.subscribe, + pluginRegistry.getSnapshot, + pluginRegistry.getSnapshot, + ); +} + +export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null { + return ( + useInstalledPlugins().find( + (plugin) => plugin.serverId === serverId && plugin.id === pluginId, + ) ?? null + ); +} + +export function usePluginInstallations(pluginId: string): InstalledPlugin[] { + return useInstalledPlugins().filter((plugin) => plugin.id === pluginId); +} diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts new file mode 100644 index 000000000..f59ebd602 --- /dev/null +++ b/packages/app/src/plugins/routes.ts @@ -0,0 +1,17 @@ +export function buildPluginSurfaceRoute( + serverId: string, + pluginId: string, + contributionId: string, +): `/h/${string}/plugin/${string}/${string}` { + return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`; +} + +export function hostIdFromPathname(pathname: string): string | null { + const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1]; + if (!encoded) return null; + try { + return decodeURIComponent(encoded); + } catch { + return null; + } +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts new file mode 100644 index 000000000..81998a4f2 --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -0,0 +1,45 @@ +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", + cleanup: () => undefined, + serverId, + clientBundle: serverId, + queryClient: new QueryClient(), + surfaces: [{ id: "surface", Component: () => null }], + sidebarItems: [ + { + id: contributionId, + title: "Example", + icon: "Blocks", + surface: "surface", + }, + ], + attachmentSources: [], + }; +} + +describe("groupPluginSidebarContributions", () => { + it("coalesces the same plugin contribution across hosts", () => { + const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([ + "host-a", + "host-b", + ]); + }); + + it("keeps different contribution ids separate", () => { + const groups = groupPluginSidebarContributions([ + installed("host-a", "main"), + installed("host-b", "settings"), + ]); + + expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]); + }); +}); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts new file mode 100644 index 000000000..e61d37c8f --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -0,0 +1,48 @@ +import type { InstalledPlugin, PluginSidebarContribution } from "./types"; + +const preferredHosts = new Map(); + +export interface PluginSidebarTarget { + plugin: InstalledPlugin; + item: PluginSidebarContribution; +} + +export interface PluginSidebarGroup { + key: string; + pluginId: string; + contributionId: string; + title: string; + icon: string; + targets: PluginSidebarTarget[]; +} + +export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] { + const groups = new Map(); + for (const plugin of plugins) { + for (const item of plugin.sidebarItems) { + const key = `${plugin.id}/${item.id}`; + const existing = groups.get(key); + if (existing) { + existing.targets.push({ plugin, item }); + } else { + groups.set(key, { + key, + pluginId: plugin.id, + contributionId: item.id, + title: item.title, + icon: item.icon, + targets: [{ plugin, item }], + }); + } + } + } + return [...groups.values()]; +} + +export function getPreferredPluginContributionHost(groupKey: string): string | null { + return preferredHosts.get(groupKey) ?? null; +} + +export function rememberPluginContributionHost(groupKey: string, serverId: string): void { + preferredHosts.set(groupKey, serverId); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx new file mode 100644 index 000000000..f2cc614cc --- /dev/null +++ b/packages/app/src/plugins/sidebar-items.tsx @@ -0,0 +1,80 @@ +import { router, usePathname } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { resolvePluginIcon } from "./icons"; +import { useInstalledPlugins } from "./registry"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes"; +import { + groupPluginSidebarContributions, + getPreferredPluginContributionHost, + rememberPluginContributionHost, + type PluginSidebarGroup, + type PluginSidebarTarget, +} from "./sidebar-groups"; + +function selectTarget( + group: PluginSidebarGroup, + currentHostId: string | null, +): PluginSidebarTarget { + const current = group.targets.find((target) => target.plugin.serverId === currentHostId); + if (current) return current; + const rememberedHostId = getPreferredPluginContributionHost(group.key); + const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId); + return remembered ?? group.targets[0]; +} + +export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { + const plugins = useInstalledPlugins(); + const pathname = usePathname(); + const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]); + const currentHostId = hostIdFromPathname(pathname); + + return groups.map((group) => ( + + )); +} + +function PluginSidebarItemRow({ + group, + currentHostId, + pathname, + onBeforeNavigate, +}: { + group: PluginSidebarGroup; + currentHostId: string | null; + pathname: string; + onBeforeNavigate?: () => void; +}) { + const target = selectTarget(group, currentHostId); + const route = buildPluginSurfaceRoute( + target.plugin.serverId, + group.pluginId, + group.contributionId, + ); + const isActive = group.targets.some( + (candidate) => + pathname === + buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId), + ); + const navigate = useCallback(() => { + rememberPluginContributionHost(group.key, target.plugin.serverId); + onBeforeNavigate?.(); + router.push(route); + }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); + return ( + + ); +} diff --git a/packages/app/src/plugins/surface-error-boundary.test.tsx b/packages/app/src/plugins/surface-error-boundary.test.tsx new file mode 100644 index 000000000..2e137eb0d --- /dev/null +++ b/packages/app/src/plugins/surface-error-boundary.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import React, { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Text } from "react-native"; +import { SurfaceErrorBoundary } from "./surface-error-boundary"; + +let oldSurfaceCleanups = 0; +function FailingSurface() { + const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(true); + return () => { + oldSurfaceCleanups += 1; + }; + }, []); + if (failed) throw new Error("old render exploded"); + return Old surface mounted; +} +function CorrectedSurface() { + return Corrected surface; +} +const oldInstallation = {}; +const correctedInstallation = {}; + +describe("SurfaceErrorBoundary", () => { + const roots: Array> = []; + const containers: HTMLElement[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + for (const container of containers.splice(0)) container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + oldSurfaceCleanups = 0; + }); + + it("recovers a mounted failed route when its installation and surface are replaced", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const container = document.createElement("div"); + containers.push(container); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + await act(async () => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe("Plugin failed: old render exploded"); + expect(oldSurfaceCleanups).toBe(1); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toBe("Corrected surface"); + expect(oldSurfaceCleanups).toBe(1); + }); +}); diff --git a/packages/app/src/plugins/surface-error-boundary.tsx b/packages/app/src/plugins/surface-error-boundary.tsx new file mode 100644 index 000000000..46e306fe8 --- /dev/null +++ b/packages/app/src/plugins/surface-error-boundary.tsx @@ -0,0 +1,51 @@ +import React, { Component, type ErrorInfo, type ReactNode } from "react"; +import { Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +interface SurfaceErrorBoundaryProps { + children: ReactNode; + installation: object; + Surface: unknown; +} + +interface SurfaceErrorBoundaryState { + error: string | null; +} + +export class SurfaceErrorBoundary extends Component< + SurfaceErrorBoundaryProps, + 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); + } + + componentDidUpdate(previous: SurfaceErrorBoundaryProps): void { + if ( + this.state.error && + (previous.installation !== this.props.installation || previous.Surface !== this.props.Surface) + ) { + this.setState({ error: null }); + } + } + + render() { + if (this.state.error) { + return Plugin failed: {this.state.error}; + } + return this.props.children; + } +} + +const styles = StyleSheet.create((theme) => ({ + errorText: { + color: theme.colors.statusDanger, + padding: theme.spacing[4], + }, +})); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx new file mode 100644 index 000000000..dd6802eaf --- /dev/null +++ b/packages/app/src/plugins/surface-screen.tsx @@ -0,0 +1,270 @@ +import { router, useLocalSearchParams } from "expo-router"; +import { QueryClientProvider } from "@tanstack/react-query"; +import type { PluginSurfaceProps } from "@paseo/plugin"; +import { PluginRpcProvider } from "@paseo/plugin/host"; +import { ChevronDown, X } from "lucide-react-native"; +import { useCallback, useMemo, useRef, useState, type ComponentType } 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 { SurfaceErrorBoundary } from "./surface-error-boundary"; + +const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; +const EMPTY_THEME_DTO: Record = {}; +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const pluginThemeMapping = (theme: Theme) => ({ + themeDto: JSON.parse(JSON.stringify(theme)) as Record, +}); +const ThemedX = withUnistyles(X); +const ThemedChevronDown = withUnistyles(ChevronDown); + +function routeParam(value: string | string[] | undefined): string { + return typeof value === "string" ? value : ""; +} + +function PluginHeaderIcon({ + Icon, + color = "", +}: { + Icon: ReturnType; + color?: string; +}) { + return ; +} + +const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); + +function SurfaceRenderer({ + Surface, + invoke, + plugin, + layout, + host, + themeDto = EMPTY_THEME_DTO, +}: { + Surface: ComponentType; + invoke(method: string, input: unknown): Promise; + plugin: NonNullable>; + layout: PluginSurfaceProps["layout"]; + host: PluginSurfaceProps["host"]; + themeDto?: Record; +}) { + return ( + + + + + + ); +} + +const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); + +function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} + +function PluginHostSwitcher({ + serverId, + pluginId, + contributionId, + serverIds, +}: { + serverId: string; + pluginId: string; + contributionId: string; + serverIds: string[]; +}) { + const allHosts = useHosts(); + const hosts = useMemo( + () => allHosts.filter((host) => serverIds.includes(host.serverId)), + [allHosts, serverIds], + ); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const selectHost = useCallback( + (nextServerId: string) => { + rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId)); + }, + [contributionId, pluginId], + ); + const openPicker = useCallback(() => setOpen(true), []); + const show = serverIds.length > 1 && hosts.length > 1; + if (!show) return null; + + return ( + + + + + {selectedLabel} + + + + + + ); +} + +export function PluginSurfaceScreen() { + const params = useLocalSearchParams<{ + serverId?: string | string[]; + pluginId?: string | string[]; + surfaceId?: string | string[]; + }>(); + const serverId = routeParam(params.serverId); + const pluginId = routeParam(params.pluginId); + const contributionId = routeParam(params.surfaceId); + const plugin = useInstalledPlugin(serverId, pluginId); + const installations = usePluginInstallations(pluginId); + const hosts = useHosts(); + const client = useHostRuntimeClient(serverId); + const compact = useIsCompactFormFactor(); + const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null; + const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null; + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const contributionServerIds = useMemo( + () => + installations + .filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId)) + .map((entry) => entry.serverId), + [contributionId, installations], + ); + const title = sidebarItem?.title ?? (pluginId || "Plugin"); + const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; + const invoke = useCallback( + async (method: string, input: unknown) => { + if (!client) throw new Error("Plugin host is offline"); + return client.invokePluginRpc(pluginId, method, input); + }, + [client, pluginId], + ); + const close = useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace(`/h/${encodeURIComponent(serverId)}`); + }, [serverId]); + const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); + const headerLeft = useMemo( + () => ( + <> + {Icon ? ( + + + + ) : null} + {title} + + ), + [Icon, title], + ); + const headerRight = useMemo( + () => ( + <> + + + + + + ), + [close, contributionId, contributionServerIds, pluginId, serverId], + ); + + return ( + + + + {plugin && surface ? ( + + + + ) : ( + This plugin surface is unavailable. + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + screen: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + body: { + flex: 1, + }, + errorText: { + color: theme.colors.statusDanger, + padding: theme.spacing[4], + }, + hostSwitcher: { + maxWidth: 180, + minHeight: 32, + paddingHorizontal: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + }, + hostSwitcherText: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts new file mode 100644 index 000000000..931b98a0c --- /dev/null +++ b/packages/app/src/plugins/types.ts @@ -0,0 +1,26 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { + PluginAttachmentSourceContribution, + PluginSidebarContribution, + PluginSurfaceContribution, +} from "@paseo/plugin"; + +export interface EvaluatedPlugin { + id: string; + cleanup: () => void; + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; + attachmentSources: PluginAttachmentSourceContribution[]; +} + +export interface InstalledPlugin extends EvaluatedPlugin { + serverId: string; + clientBundle: string; + queryClient: QueryClient; +} + +export type { + PluginAttachmentSourceContribution, + PluginSidebarContribution, + PluginSurfaceContribution, +}; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c7e6df8e5..3fca4f5c9 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Code2, Smartphone, Sparkles, + Blocks, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -108,6 +109,7 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; @@ -191,6 +193,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, + { id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks }, ]; function renderHostSettingsContent( @@ -216,6 +219,8 @@ function renderHostSettingsContent( return ; case "terminals": return ; + case "plugins": + return ; case "host": return ; } diff --git a/packages/app/src/screens/settings/plugins-page-state.test.ts b/packages/app/src/screens/settings/plugins-page-state.test.ts new file mode 100644 index 000000000..192436029 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page-state.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolvePluginPageState } from "./plugins-page-state"; + +describe("resolvePluginPageState", () => { + it.each([ + ["offline", false, false, false, false, 0], + ["unsupported", true, false, false, false, 0], + ["loading", true, true, true, false, 0], + ["error", true, true, false, true, 0], + ["empty", true, true, false, false, 0], + ["ready", true, true, false, false, 1], + ] as const)("resolves %s", (expected, connected, supported, loading, error, pluginCount) => { + expect(resolvePluginPageState({ connected, supported, loading, error, pluginCount })).toBe( + expected, + ); + }); +}); diff --git a/packages/app/src/screens/settings/plugins-page-state.ts b/packages/app/src/screens/settings/plugins-page-state.ts new file mode 100644 index 000000000..0623dca04 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page-state.ts @@ -0,0 +1,15 @@ +export type PluginPageState = "offline" | "unsupported" | "loading" | "error" | "empty" | "ready"; + +export function resolvePluginPageState(input: { + connected: boolean; + supported: boolean; + loading: boolean; + error: boolean; + pluginCount: number; +}): PluginPageState { + if (!input.connected) return "offline"; + if (!input.supported) return "unsupported"; + if (input.loading) return "loading"; + if (input.error) return "error"; + return input.pluginCount === 0 ? "empty" : "ready"; +} diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx new file mode 100644 index 000000000..89dc49388 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { i18n as testI18n } from "@/i18n/i18next"; +import React, { type ReactElement } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostPluginsPage } from "./plugins-page"; + +void testI18n; + +const runtime = vi.hoisted(() => ({ + connected: true, + supported: true, + client: null as DaemonClient | null, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => runtime.client, + useHostRuntimeIsConnected: () => runtime.connected, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: () => runtime.supported, +})); + +vi.mock("react-native-reanimated", () => ({ + default: { View: "div" }, + Easing: { ease: "ease", inOut: (value: unknown) => value }, + interpolateColor: (value: number, _input: number[], output: string[]) => + value >= 1 ? output[1] : output[0], + useAnimatedStyle: (factory: () => unknown) => factory(), + useDerivedValue: (factory: () => unknown) => ({ value: factory() }), + withTiming: (value: unknown) => value, +})); + +vi.mock("@gorhom/bottom-sheet", async () => { + const ReactModule = await vi.importActual("react"); + const { ScrollView, TextInput } = + await vi.importActual("react-native"); + return { + BottomSheetBackdrop: () => null, + BottomSheetModal: ReactModule.forwardRef(() => null), + BottomSheetScrollView: ScrollView, + BottomSheetTextInput: TextInput, + useBottomSheetInternal: () => null, + }; +}); + +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +function never(): Promise { + return new Promise(() => undefined); +} + +function plugin(enabled = true): PluginListItem { + return { + id: "example", + path: "/plugins/example", + enabled, + status: enabled ? "running" : "disabled", + }; +} + +function createClient() { + return { + on: vi.fn(() => () => undefined), + getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), + patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), + listPlugins: vi.fn(async (): Promise => []), + inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })), + installDirectoryPlugin: vi.fn(async () => plugin()), + reloadPlugin: vi.fn(async () => plugin()), + enablePlugin: vi.fn(async () => plugin()), + disablePlugin: vi.fn(async () => plugin(false)), + removePlugin: vi.fn(async () => undefined), + }; +} + +type PluginClient = ReturnType; + +function renderPage(client: PluginClient | null): void { + runtime.client = client as unknown as DaemonClient | null; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const element: ReactElement = ( + + + + ); + render(element); +} + +describe("HostPluginsPage", () => { + beforeEach(() => { + vi.stubGlobal("React", React); + runtime.connected = true; + runtime.supported = true; + runtime.client = null; + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the offline state", () => { + runtime.connected = false; + renderPage(null); + + expect(screen.getByRole("alert").textContent).toContain("Plugin host is offline"); + }); + + it("renders a catalog error and retries through the real query", async () => { + const client = createClient(); + client.listPlugins.mockRejectedValueOnce(new Error("catalog exploded")); + renderPage(client); + + expect(await screen.findByText("Unable to load plugins")).toBeDefined(); + expect(screen.getByText("catalog exploded")).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => expect(client.listPlugins).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("No plugins configured")).toBeDefined(); + }); + + it.each([ + ["reloadPlugin", "Reload", "Reloading…", true], + ["disablePlugin", "Disable", "Disabling…", true], + ["enablePlugin", "Enable", "Enabling…", false], + ["removePlugin", "Remove", "Removing…", true], + ] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => { + const client = createClient(); + client.listPlugins.mockResolvedValue([plugin(enabled)]); + client[method].mockImplementation(() => never()); + renderPage(client); + + fireEvent.click(await screen.findByRole("button", { name: trigger })); + + const pendingControl = await screen.findByRole("button", { name: pending }); + expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); + expect(client[method]).toHaveBeenCalledTimes(1); + }); + + it("renders install pending through the real form and mutation", async () => { + const client = createClient(); + client.installDirectoryPlugin.mockImplementation(() => never()); + renderPage(client); + + fireEvent.change(screen.getByLabelText("Plugin directory"), { + target: { value: "/plugins/example" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Install directory" })); + + const pendingControl = await screen.findByRole("button", { name: "Installing…" }); + expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); + expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined); + }); +}); diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx new file mode 100644 index 000000000..129328e47 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useMutation } from "@tanstack/react-query"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { Switch } from "@/components/ui/switch"; +import { useFetchQuery } from "@/data/query"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; +import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; +type PluginRowAction = "reload" | "enable" | "disable" | "remove"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function pluginRowAction(action: string | undefined): PluginRowAction | undefined { + if (action === "reload" || action === "enable" || action === "disable" || action === "remove") { + return action; + } + return undefined; +} + +function PluginRow({ + plugin, + clientError, + pending, + pendingAction, + onAction, +}: { + plugin: PluginListItem; + clientError?: string; + pending: boolean; + pendingAction?: PluginRowAction; + onAction(action: PluginRowAction, plugin: PluginListItem): void; +}) { + const { t } = useTranslation(); + const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); + const toggle = useCallback( + () => onAction(plugin.enabled ? "disable" : "enable", plugin), + [onAction, plugin], + ); + const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); + const status = clientError ? "failed" : plugin.status; + let badgeVariant: "success" | "error" | "muted" = "muted"; + if (status === "running") badgeVariant = "success"; + else if (status === "failed") badgeVariant = "error"; + const statusLabel = t(`settings.plugins.status.${status}`); + let toggleLabel = t( + plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + ); + if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); + else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); + return ( + + + + {plugin.id} + + + {plugin.path} + {clientError || plugin.error ? ( + {clientError ?? plugin.error} + ) : null} + + + + + + + + ); +} + +export function HostPluginsPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginManagement"); + const { config, patchConfig } = useDaemonConfig(serverId); + const refreshQueue = useRef(Promise.resolve()); + useInstalledPlugins(); + const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); + const [directory, setDirectory] = useState(""); + const [pluginId, setPluginId] = useState(""); + const [directoryResetKey, setDirectoryResetKey] = useState(0); + const [pluginIdResetKey, setPluginIdResetKey] = useState(0); + const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( + null, + ); + const plugins = useFetchQuery({ + queryKey, + queryFn: async () => { + if (!client) throw new Error("Plugin host is offline"); + return client.listPlugins(); + }, + enabled: Boolean(client && connected && supported), + dataShape: "list", + staleTimeMs: 1_000, + }); + const refetchPlugins = plugins.refetch; + const refresh = useCallback(async () => { + if (!client) return; + const refetch = async () => { + await refetchPlugins(); + }; + refreshQueue.current = refreshQueue.current.then(refetch, refetch); + await refreshQueue.current; + }, [client, refetchPlugins]); + useEffect(() => { + if (!client) return; + return client.on("status", (message) => { + if (message.payload.status === "plugin_catalog_changed") void refresh(); + }); + }, [client, refresh]); + const mutation = useMutation({ + mutationFn: async (operation: { + action: string; + pluginId?: string; + run: () => Promise; + }) => operation.run(), + onSuccess: async (message) => { + setFeedback({ kind: "success", message }); + await refresh(); + }, + onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }), + }); + const install = useCallback(() => { + const path = directory.trim(); + if (!client || !path) return; + setFeedback(null); + mutation.mutate({ + action: "install", + run: async () => { + const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined); + setDirectory(""); + setPluginId(""); + setDirectoryResetKey((key) => key + 1); + setPluginIdResetKey((key) => key + 1); + return t("settings.plugins.feedback.installed", { id: installed.id }); + }, + }); + }, [client, directory, mutation, pluginId, t]); + const prefillManifestId = useCallback(() => { + const path = directory.trim(); + if (!client || !path || pluginId.trim()) return; + void client + .inspectDirectoryPlugin(path) + .then(({ id }) => { + setPluginId(id); + setPluginIdResetKey((key) => key + 1); + return undefined; + }) + .catch(() => undefined); + }, [client, directory, pluginId]); + const action = useCallback( + (name: PluginRowAction, plugin: PluginListItem) => { + if (!client) return; + const run = async () => { + if (name === "remove") { + const confirmed = await confirmDialog({ + title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }), + message: t("settings.plugins.removeConfirmMessage"), + confirmLabel: t("settings.plugins.actions.remove"), + destructive: true, + }); + if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id }); + await client.removePlugin(plugin.id); + } else { + await client[`${name}Plugin`](plugin.id); + } + return t(`settings.plugins.feedback.${name}`, { id: plugin.id }); + }; + setFeedback(null); + mutation.mutate({ action: name, pluginId: plugin.id, run }); + }, + [client, mutation, t], + ); + const toggleGlobal = useCallback( + (enabled: boolean) => { + setFeedback(null); + mutation.mutate({ + action: enabled ? "global-enable" : "global-disable", + run: async () => { + await patchConfig({ pluginsEnabled: enabled }); + return enabled + ? t("settings.plugins.feedback.globalEnabled") + : t("settings.plugins.feedback.globalDisabled"); + }, + }); + }, + [mutation, patchConfig, t], + ); + const pageState = resolvePluginPageState({ + connected, + supported, + loading: plugins.isLoading, + error: plugins.isError, + pluginCount: plugins.data?.length ?? 0, + }); + const retry = useCallback(() => { + void refetchPlugins(); + }, [refetchPlugins]); + + if (pageState === "offline") { + return ( + + ); + } + if (pageState === "unsupported") { + return ; + } + + let catalogContent = ; + if (pageState === "error") { + catalogContent = ( + + + + ); + } else if (pageState !== "loading") { + catalogContent = ( + + {plugins.data?.length ? ( + plugins.data.map((plugin) => { + const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id); + const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id; + return ( + + ); + }) + ) : ( + + {t("settings.plugins.states.empty")} + + )} + + ); + } + + return ( + + + + + + + {t("settings.plugins.globalTitle")} + {t("settings.plugins.globalHint")} + + + + + + + + + + + + + + {feedback ? ( + + ) : null} + {catalogContent} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + install: { padding: theme.spacing[4], gap: theme.spacing[3] }, + pluginRow: { + padding: theme.spacing[4], + gap: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, + error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.xs }, + empty: { padding: theme.spacing[4], alignItems: "center" }, +})); diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index 56807d085..a71ea9666 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => { }); describe("draft-store normalization", () => { + it("preserves plugin resources when hydrating a draft", () => { + const attachment = { + kind: "plugin_resource" as const, + pluginId: "linear", + sourceId: "issues", + sourceTitle: "Linear issue", + sourceIcon: "CircleDot", + item: { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + subtitle: "In progress", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + text: "Linear issue ENG-123: Plugin attachments", + resourceType: "issue", + }, + }; + + expect( + toDraftInputIfReady({ + input: { text: "Implement this", attachments: [attachment] }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }), + ).toEqual({ text: "Implement this", attachments: [attachment] }); + }); + it("preserves uploaded file attachments when hydrating a draft", () => { const attachment = { kind: "file" as const, diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index ee53825ed..5d9fecd52 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,6 +3,7 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; +import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments"; import { z } from "zod"; export const DRAFT_STORE_VERSION = 5; @@ -95,6 +96,7 @@ export const UserComposerAttachmentSchema: z.ZodType = z z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), + PluginResourceComposerAttachmentSchema, z.strictObject({ kind: z.literal("github_pr"), item: ChangeRequestItemSchema, diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 87e151788..612d0f002 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [ "providers", "usage", "terminals", + "plugins", "host", ] as const; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 4ec936feb..d63acf56b 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -7,14 +7,28 @@ const testTheme = { statusDanger: "#b91c1c", statusWarning: "#d97706", statusMerged: "#7c3aed", + accent: "#2563eb", + accentForeground: "#ffffff", + destructive: "#dc2626", + destructiveForeground: "#ffffff", surface1: "#fafafa", surface2: "#f4f4f5", + surface3: "#e4e4e7", border: "#e4e4e7", + borderAccent: "#a1a1aa", + palette: { + amber: { 500: "#f59e0b" }, + blue: { 300: "#93c5fd" }, + red: { 300: "#fca5a5" }, + white: "#ffffff", + }, }, + borderWidth: { 1: 1 }, spacing: [0, 4, 8, 12, 16, 20, 24, 28, 32], fontSize: { xs: 12, sm: 14, + base: 16, }, fontWeight: { normal: "400", @@ -23,7 +37,12 @@ const testTheme = { borderRadius: { base: 4, md: 6, + lg: 8, + xl: 12, + full: 9999, }, + iconSize: { sm: 16 }, + opacity: { 50: 0.5 }, }; type StyleFactory = (theme: typeof testTheme) => T; diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index c480fe824..489dbc257 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -55,4 +55,24 @@ describe("canonical CLI surface", () => { expect(open?.helpInformation()).toContain(""); expect(open?.helpInformation()).toContain("--server "); }); + + it("offers the complete local plugin lifecycle", () => { + const plugin = createCli().commands.find((command) => command.name() === "plugin"); + + expect(plugin?.commands.map((command) => command.name())).toEqual([ + "init", + "ls", + "install", + "reload", + "enable", + "disable", + "remove", + ]); + expect( + plugin?.commands.find((command) => command.name() === "init")?.helpInformation(), + ).toContain("--id "); + expect( + plugin?.commands.find((command) => command.name() === "install")?.helpInformation(), + ).toContain("--id "); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cb93b810a..cf12a7426 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; +import { createPluginCommand } from "./commands/plugin/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createScriptCommand } from "./commands/script/index.js"; @@ -182,6 +183,7 @@ export function createCli(): Command { // Provider commands program.addCommand(createProviderCommand()); + program.addCommand(createPluginCommand()); // Speech model commands program.addCommand(createSpeechCommand()); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts new file mode 100644 index 000000000..14b8b5234 --- /dev/null +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const listPlugins = vi.fn(async () => []); +const close = vi.fn(async () => undefined); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + getLastServerInfoMessage: () => ({ features: { pluginManagement: false } }), + listPlugins, + close, + })), +})); + +import { runPluginListCommand } from "./index.js"; + +describe("plugin management commands", () => { + it("requires host support before attempting a management RPC", async () => { + await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use plugin management.", + }); + expect(listPlugins).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts new file mode 100644 index 000000000..8c41cd111 --- /dev/null +++ b/packages/cli/src/commands/plugin/index.ts @@ -0,0 +1,121 @@ +import { Command } from "commander"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; +import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js"; +import { withPluginManagementClient } from "./shared.js"; + +interface PluginOptions extends CommandOptions { + host?: string; + id?: string; +} + +const pluginSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "PLUGIN", field: "id", width: 20 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, + { header: "DIRECTORY", field: "path", width: 40 }, + { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, + ], +}; + +const scaffoldSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "PLUGIN", field: "id", width: 20 }, + { header: "DIRECTORY", field: "directory", width: 60 }, + ], +}; + +export async function runPluginInitCommand( + directory: string, + options: PluginOptions, + _command: Command, +): Promise> { + return { + type: "single", + data: await scaffoldPluginDirectory(directory, options.id), + schema: scaffoldSchema, + }; +} + +export async function runPluginListCommand( + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + return { type: "list", data, schema: pluginSchema }; +} + +async function install( + directory: string, + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => + client.installDirectoryPlugin(directory, options.id), + ); + return { type: "single", data, schema: pluginSchema }; +} + +async function act( + action: "reload" | "enable" | "disable", + pluginId: string, + options: PluginOptions, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => + client[`${action}Plugin`](pluginId), + ); + return { type: "single", data, schema: pluginSchema }; +} + +async function remove( + pluginId: string, + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, async (client) => { + const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); + if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); + await client.removePlugin(pluginId); + return { ...current, enabled: false, status: "disabled" as const }; + }); + return { type: "single", data, schema: pluginSchema }; +} + +export function createPluginCommand(): Command { + const plugin = new Command("plugin").description("Manage trusted local plugins"); + addJsonOption( + plugin + .command("init") + .description("Create a typecheckable local plugin") + .argument("") + .option("--id ", "Manifest plugin ID (defaults to the directory name)"), + ).action(withOutput(runPluginInitCommand)); + addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action( + withOutput(runPluginListCommand), + ); + addJsonAndDaemonHostOptions( + plugin + .command("install") + .description("Install a local plugin directory") + .argument("", "Host filesystem directory") + .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)"), + ).action(withOutput(install)); + for (const action of ["reload", "enable", "disable"] as const) { + addJsonAndDaemonHostOptions( + plugin.command(action).description(`${action} a local plugin`).argument(""), + ).action( + withOutput((id: string, options: PluginOptions, _command: Command) => + act(action, id, options), + ), + ); + } + addJsonAndDaemonHostOptions( + plugin.command("remove").description("Remove plugin configuration").argument(""), + ).action(withOutput(remove)); + return plugin; +} diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts new file mode 100644 index 000000000..02bb3d156 --- /dev/null +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import ts from "typescript"; +import { afterEach, describe, expect, it } from "vitest"; +import { scaffoldPluginDirectory } from "./scaffold.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("plugin scaffold", () => { + it("creates a standalone strict TSX project that typechecks", async () => { + const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + + await scaffoldPluginDirectory(directory); + + const configPath = path.join(directory, "tsconfig.json"); + const loaded = ts.readConfigFile(configPath, ts.sys.readFile); + expect(loaded.error).toBeUndefined(); + const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory); + const diagnostics = ts.getPreEmitDiagnostics( + ts.createProgram(parsed.fileNames, parsed.options), + ); + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); + expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({ + id: "hello-plugin", + }); + }); + + it("refuses to write into a non-empty directory", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(directory); + await writeFile(path.join(directory, "notes.txt"), "keep me"); + + await expect(scaffoldPluginDirectory(directory, "hello-plugin")).rejects.toThrow( + "Plugin directory must be empty", + ); + expect(await readFile(path.join(directory, "notes.txt"), "utf8")).toBe("keep me"); + }); +}); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts new file mode 100644 index 000000000..dc107dd6c --- /dev/null +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -0,0 +1,167 @@ +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { PluginIdSchema } from "@getpaseo/protocol/messages"; + +const SDK_DECLARATIONS = `declare module "@paseo/plugin" { + import type { ComponentType } from "react"; + import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + + export interface PluginSurfaceProps { + theme: Record; + host: { id: string; label: string }; + layout: { compact: boolean; platform: "ios" | "android" | "web" }; + } + + export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, + > { + name: string; + input: InputSchema; + output: OutputSchema; + } + + export interface PluginAttachmentItem { + id: string; + identifier: string; + title: string; + subtitle?: string; + url: string; + text: string; + resourceType: string; + } + + export interface PluginAttachmentSearchPayload { + items: PluginAttachmentItem[]; + } + + export interface PluginAttachmentSourceContribution { + id: string; + title: string; + icon: string; + pickerTitle: string; + searchPlaceholder: string; + search: PluginRpcContract; + } + + export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; + } + + export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; + } + + export interface PluginContext { + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + ) => ZodInput | Promise>, + ): void; + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; + } + + export type PluginCleanup = () => void; + export type PluginContribution = (plugin: PluginContext) => PluginCleanup; + + export function defineRpc(definition: { + name: string; + input: InputSchema; + output: OutputSchema; + }): PluginRpcContract; + + export function useRpc( + contract: PluginRpcContract, + ): (input: ZodInput) => Promise>; + + export function defineAttachmentSource( + definition: Definition, + ): Definition; + + export const PluginAttachmentItemSchema: import("zod").ZodType; + export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType; +} +`; + +const TSCONFIG = { + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["ES2023", "DOM"], + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + include: ["index.tsx", "paseo-plugin.d.ts"], +}; + +const ENTRY = `import type { PluginContext } from "@paseo/plugin"; +import React from "react"; +import { Text } from "react-native"; + +function MainSurface() { + return Hello from my plugin; +} + +export default function contribute(plugin: PluginContext) { + plugin.addSurface("main", MainSurface); + return () => undefined; +} +`; + +export interface PluginScaffold { + id: string; + directory: string; +} + +export async function scaffoldPluginDirectory( + targetDirectory: string, + requestedId?: string, +): Promise { + const directory = path.resolve(targetDirectory); + const id = PluginIdSchema.parse(requestedId ?? path.basename(directory)); + await mkdir(directory, { recursive: true }); + const existing = await readdir(directory); + if (existing.length > 0) { + throw new Error(`Plugin directory must be empty: ${directory}`); + } + + const packageJson = { + name: id, + private: true, + version: "0.0.0", + scripts: { typecheck: "tsc --noEmit" }, + devDependencies: { + "@tanstack/react-query": "^5.90.11", + "@types/react": "~19.2.0", + react: "19.1.0", + "react-native": "0.81.5", + typescript: "^5.9.3", + zod: "^4.4.3", + }, + }; + const files = new Map([ + ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], + ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], + ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], + ["paseo-plugin.d.ts", SDK_DECLARATIONS], + ["index.tsx", ENTRY], + ]); + await Promise.all( + [...files].map(([filename, contents]) => + writeFile(path.join(directory, filename), contents, { flag: "wx" }), + ), + ); + return { id, directory }; +} diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts new file mode 100644 index 000000000..e6e9f92be --- /dev/null +++ b/packages/cli/src/commands/plugin/shared.ts @@ -0,0 +1,23 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { CommandError } from "../../output/index.js"; +import { connectToDaemon } from "../../utils/client.js"; + +export async function withPluginManagementClient( + host: string | undefined, + run: (client: DaemonClient) => Promise, +): Promise { + const client = await connectToDaemon({ host }); + // COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14. + if (client.getLastServerInfoMessage()?.features?.pluginManagement !== true) { + await client.close().catch(() => undefined); + throw { + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use plugin management.", + } satisfies CommandError; + } + try { + return await run(client); + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts new file mode 100644 index 000000000..51ecfcf94 --- /dev/null +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { connectToDaemon } from "../../src/utils/client.ts"; +import { createE2ETestContext } from "../helpers/test-daemon.ts"; + +const pluginSource = `export default function contribute(plugin: unknown) { + void plugin; + return () => undefined; +}`; + +async function main(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); + const context = await createE2ETestContext({ timeout: 45_000 }); + try { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" })); + await writeFile(path.join(directory, "index.tsx"), pluginSource); + + const install = await context.paseo(["plugin", "install", directory, "--json"]); + assert.equal(install.exitCode, 0, install.stderr); + assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); + + const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.close(); + + const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]); + assert.equal(reload.exitCode, 0, reload.stderr); + assert.equal(JSON.parse(reload.stdout).status, "running"); + + const disable = await context.paseo(["plugin", "disable", "cli-e2e", "--json"]); + assert.equal(disable.exitCode, 0, disable.stderr); + assert.equal(JSON.parse(disable.stdout).status, "disabled"); + + const enable = await context.paseo(["plugin", "enable", "cli-e2e", "--json"]); + assert.equal(enable.exitCode, 0, enable.stderr); + assert.equal(JSON.parse(enable.stdout).status, "running"); + + const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]); + assert.equal(remove.exitCode, 0, remove.stderr); + const list = await context.paseo(["plugin", "ls", "--json"]); + assert.equal(list.exitCode, 0, list.stderr); + assert.deepEqual(JSON.parse(list.stdout), []); + } finally { + await context.stop(); + await rm(directory, { recursive: true, force: true }); + } +} + +await main(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index cf1053d12..a92e535ae 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -101,6 +101,7 @@ import type { PaseoConfigRevision, WorkspaceCreateRequest, WorkspaceRecoveryState, + PluginListItem, } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest, @@ -4722,6 +4723,95 @@ export class DaemonClient { }); } + async getPluginCatalog(): Promise> { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.catalog.get.request", requestId }, + responseType: "plugin.catalog.get.response", + }); + return payload.plugins; + } + + async listPlugins(): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.list.request", requestId }, + responseType: "plugin.list.response", + }); + return payload.plugins; + } + + async installDirectoryPlugin(path: string, id?: string): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.directory.install.request", requestId, path, ...(id ? { id } : {}) }, + responseType: "plugin.directory.install.response", + }); + return payload.plugin; + } + + async inspectDirectoryPlugin(path: string): Promise<{ id: string }> { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.directory.inspect.request", requestId, path }, + responseType: "plugin.directory.inspect.response", + }); + } + + async reloadPlugin(pluginId: string): Promise { + return this.managePlugin("reload", pluginId); + } + + async enablePlugin(pluginId: string): Promise { + return this.managePlugin("enable", pluginId); + } + + async disablePlugin(pluginId: string): Promise { + return this.managePlugin("disable", pluginId); + } + + async removePlugin(pluginId: string): Promise { + const requestId = this.createRequestId(); + await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.remove.request", requestId, pluginId }, + responseType: "plugin.remove.response", + }); + } + + private async managePlugin( + action: "reload" | "enable" | "disable", + pluginId: string, + ): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: `plugin.${action}.request`, requestId, pluginId }, + responseType: `plugin.${action}.response`, + }); + return payload.plugin; + } + + async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "plugin.rpc.invoke.request", + requestId, + pluginId, + method, + input, + }, + responseType: "plugin.rpc.invoke.response", + }); + return payload.output; + } + async respondToPermissionAndWait( agentId: string, requestId: string, diff --git a/packages/plugin/package.json b/packages/plugin/package.json new file mode 100644 index 000000000..586658f08 --- /dev/null +++ b/packages/plugin/package.json @@ -0,0 +1,42 @@ +{ + "name": "@paseo/plugin", + "version": "0.3.0", + "private": true, + "files": [ + "dist", + "!dist/**/*.map" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./host": { + "types": "./dist/host.d.ts", + "source": "./src/host.ts", + "default": "./dist/host.js" + } + }, + "scripts": { + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", + "typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "@types/react": "~19.2.0", + "react": "19.1.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6", + "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "19.1.0", + "zod": "^4.4.3" + } +} diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts new file mode 100644 index 000000000..ee7c2b088 --- /dev/null +++ b/packages/plugin/src/attachments.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const PluginAttachmentItemSchema = z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.url(), + text: z.string(), + resourceType: z.string(), +}); + +export const PluginAttachmentSearchPayloadSchema = z.object({ + items: z.array(PluginAttachmentItemSchema), +}); + +export type PluginAttachmentItem = z.infer; +export type PluginAttachmentSearchPayload = z.infer; diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts new file mode 100644 index 000000000..b05fa3e03 --- /dev/null +++ b/packages/plugin/src/contracts.ts @@ -0,0 +1,52 @@ +import type { ComponentType } from "react"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "./rpc.js"; + +export interface PluginSurfaceProps { + theme: Record; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export interface PluginAttachmentSourceContribution { + id: string; + title: string; + icon: string; + pickerTitle: string; + searchPlaceholder: string; + search: PluginRpcContract; +} + +export interface PluginContext { + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + ) => ZodInput | Promise>, + ): void; + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; +} + +export type PluginCleanup = () => void; + +export type PluginContribution = (plugin: PluginContext) => PluginCleanup; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/host.test.ts new file mode 100644 index 000000000..bc1eac2d0 --- /dev/null +++ b/packages/plugin/src/host.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { defineAttachmentSource, defineRpc } from "./index.js"; +import { callPluginRpc, searchPluginAttachments } from "./host.js"; + +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 }); + }); +}); + +describe("plugin attachment sources", () => { + const search = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string(), + text: z.string(), + resourceType: z.string(), + }), + ), + }), + }); + + it("searches through the declared RPC and validates the standard result", async () => { + const source = defineAttachmentSource({ + id: "issues", + title: "Issue", + icon: "CircleDot", + pickerTitle: "Attach issue", + searchPlaceholder: "Search", + search, + }); + + const payload = await searchPluginAttachments( + source, + async (method, input) => ({ + items: [ + { + id: "issue-1", + identifier: `${method}:${String(Reflect.get(input as object, "query"))}`, + title: "Plugin attachments", + url: "https://example.com/issues/1", + text: "Issue snapshot", + resourceType: "issue", + }, + ], + }), + "ENG-123", + ); + + expect(payload.items[0]?.identifier).toBe("issues.search:ENG-123"); + }); + + it("rejects a malformed standard search result", async () => { + const source = defineAttachmentSource({ + id: "issues", + title: "Issue", + icon: "CircleDot", + pickerTitle: "Attach issue", + searchPlaceholder: "Search", + search, + }); + + await expect( + searchPluginAttachments(source, async () => ({ items: [{ id: "issue-1" }] }), "issue"), + ).rejects.toBeInstanceOf(z.ZodError); + }); +}); diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts new file mode 100644 index 000000000..dd1454d7d --- /dev/null +++ b/packages/plugin/src/host.ts @@ -0,0 +1,50 @@ +import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +import type { + PluginAttachmentSourceContribution, + PluginContext, + PluginSidebarContribution, + PluginSurfaceContribution, + PluginSurfaceProps, +} from "./contracts.js"; +import { PluginRpcProvider } from "./rpc-context.js"; +import { callPluginRpc } from "./rpc.js"; +import type { ComponentType } from "react"; + +interface PluginCollector { + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; +} + +export interface PluginRegistrationCollector { + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; + attachmentSources: PluginAttachmentSourceContribution[]; +} + +export function createPluginContext( + collector: PluginCollector, +): Pick { + return { + addSurface(id, Component) { + collector.addSurface(id, Component); + }, + addSidebarItem(contribution) { + collector.addSidebarItem(contribution); + }, + addAttachmentSource(contribution) { + collector.addAttachmentSource(contribution); + }, + }; +} + +export async function searchPluginAttachments( + source: PluginAttachmentSourceContribution, + invoke: (method: string, input: unknown) => Promise, + query: string, +) { + const output = await callPluginRpc(source.search, invoke, { query }); + return PluginAttachmentSearchPayloadSchema.parseAsync(output); +} + +export { callPluginRpc, PluginRpcProvider }; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts new file mode 100644 index 000000000..7591d3446 --- /dev/null +++ b/packages/plugin/src/index.ts @@ -0,0 +1,24 @@ +export { + PluginAttachmentItemSchema, + PluginAttachmentSearchPayloadSchema, + type PluginAttachmentItem, + type PluginAttachmentSearchPayload, +} from "./attachments.js"; +export type { + PluginAttachmentSourceContribution, + PluginCleanup, + PluginContribution, + PluginContext, + PluginSidebarContribution, + PluginSurfaceContribution, + PluginSurfaceProps, +} from "./contracts.js"; +import type { PluginAttachmentSourceContribution } from "./contracts.js"; +export { useRpc } from "./rpc-context.js"; +export { defineRpc, type PluginRpcContract } from "./rpc.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/rpc-context.tsx new file mode 100644 index 000000000..104fac853 --- /dev/null +++ b/packages/plugin/src/rpc-context.tsx @@ -0,0 +1,32 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import { callPluginRpc, type PluginRpcContract } from "./rpc.js"; + +interface PluginRpcContextValue { + invoke(method: string, input: unknown): Promise; +} + +const PluginRpcContext = createContext(null); + +export function PluginRpcProvider({ + children, + invoke, +}: { + children: ReactNode; + invoke(method: string, input: unknown): Promise; +}) { + const value = useMemo(() => ({ invoke }), [invoke]); + return {children}; +} + +export function useRpc( + contract: PluginRpcContract, +): (input: ZodInput) => Promise> { + const context = useContext(PluginRpcContext); + if (!context) throw new Error("useRpc must run inside a contributed plugin surface"); + + return useCallback( + (input: ZodInput) => callPluginRpc(contract, context.invoke, input), + [context, contract], + ); +} diff --git a/packages/plugin/src/rpc.ts b/packages/plugin/src/rpc.ts new file mode 100644 index 000000000..fab69276d --- /dev/null +++ b/packages/plugin/src/rpc.ts @@ -0,0 +1,36 @@ +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + +export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, +> { + name: string; + input: InputSchema; + output: OutputSchema; +} + +interface RpcDefinition { + name: string; + input: InputSchema; + output: OutputSchema; +} + +const RPC_NAME = /^[a-z][a-z0-9._-]*$/; + +export function defineRpc( + definition: RpcDefinition, +): PluginRpcContract { + const name = definition.name.trim(); + if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`); + return { ...definition, name }; +} + +export async function callPluginRpc( + contract: PluginRpcContract, + invoke: (method: string, input: unknown) => Promise, + input: ZodInput, +): Promise> { + const parsedInput = await contract.input.parseAsync(input); + const output = await invoke(contract.name, parsedInput); + return contract.output.parseAsync(output); +} diff --git a/packages/plugin/tsconfig.examples.json b/packages/plugin/tsconfig.examples.json new file mode 100644 index 000000000..7aa081b89 --- /dev/null +++ b/packages/plugin/tsconfig.examples.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"] +} diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json new file mode 100644 index 000000000..b6c46d8b3 --- /dev/null +++ b/packages/plugin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 2a037d913..7d544e3e6 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -310,6 +310,50 @@ describe("shared messages attachments", () => { ]); }); + it("preserves neutral external-resource presentation on text attachments", () => { + const parsed = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "req-external-resource", + agentId: "agent-1", + text: "Implement this", + attachments: [ + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + ], + }); + + expect(parsed.attachments).toEqual([ + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + ]); + }); + it("keeps known firstAgentContext attachments and drops unknown ones", () => { const parsed = CreatePaseoWorktreeRequestSchema.parse({ type: "create_paseo_worktree_request", diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts new file mode 100644 index 000000000..9a13780c8 --- /dev/null +++ b/packages/protocol/src/messages.plugins.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + MutableDaemonConfigSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, + StatusMessageSchema, +} from "./messages.js"; + +describe("plugin protocol compatibility", () => { + it("keeps old directory plugin config valid when enabled is absent", () => { + const config = MutableDaemonConfigSchema.parse({ + mcp: { injectIntoAgents: true }, + plugins: { example: { source: "directory", path: "/plugins/example" } }, + }); + + expect(config.plugins?.example?.enabled).toBeUndefined(); + }); + + it("uses namespaced management request and response pairs", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "plugin.directory.install.request", + requestId: "request-1", + path: "/plugins/example", + id: "example-work", + }).type, + ).toBe("plugin.directory.install.request"); + expect( + SessionInboundMessageSchema.parse({ + type: "plugin.directory.inspect.request", + requestId: "request-0", + path: "/plugins/example", + }).type, + ).toBe("plugin.directory.inspect.request"); + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.directory.install.response", + payload: { + requestId: "request-1", + plugin: { + id: "example-work", + path: "/plugins/example", + enabled: true, + status: "running", + }, + }, + }).type, + ).toBe("plugin.directory.install.response"); + }); + + it("keeps the catalog change notification safe for older clients", () => { + expect( + StatusMessageSchema.parse({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }), + ).toEqual({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }); + }); + + it("requires plugin action payloads and keeps remove empty", () => { + expect(() => + SessionOutboundMessageSchema.parse({ + type: "plugin.reload.response", + payload: { requestId: "request-1" }, + }), + ).toThrow(); + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.remove.response", + payload: { requestId: "request-2" }, + }), + ).toEqual({ type: "plugin.remove.response", payload: { requestId: "request-2" } }); + expect(() => + SessionOutboundMessageSchema.parse({ + type: "plugin.remove.response", + payload: { requestId: "request-2", plugin: { id: "extra" } }, + }), + ).toThrow(); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 70f554431..c3c63f3ad 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -173,6 +173,21 @@ 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), + enabled: z.boolean().optional(), + }) + .strict(); + +export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]); + +export type PluginSource = z.infer; + export const MutableDaemonConfigSchema = z .object({ // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. @@ -190,6 +205,8 @@ export const MutableDaemonConfigSchema = z appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .passthrough(); @@ -208,6 +225,8 @@ export const MutableDaemonConfigPatchSchema = z appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .partial() .passthrough(); @@ -1003,6 +1022,16 @@ export const ForgeIssueAttachmentSchema = z.object({ projectPath: z.string().optional(), }); +export const ExternalResourceAttachmentMetadataSchema = z.object({ + provider: z.string(), + providerLabel: z.string(), + resourceType: z.string(), + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), +}); + export const TextAttachmentSchema = z .object({ type: z.literal("text"), @@ -1010,6 +1039,7 @@ export const TextAttachmentSchema = z contextKind: z.string().optional(), title: z.string().nullable().optional(), text: z.string(), + externalResource: ExternalResourceAttachmentMetadataSchema.optional(), }) .transform(({ contextKind, ...attachment }) => ({ ...attachment, @@ -1291,6 +1321,46 @@ 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 PluginListRequestSchema = z.object({ + type: z.literal("plugin.list.request"), + requestId: z.string(), +}); + +export const PluginDirectoryInstallRequestSchema = z.object({ + type: z.literal("plugin.directory.install.request"), + requestId: z.string(), + path: z.string().min(1), + id: PluginIdSchema.optional(), +}); + +export const PluginDirectoryInspectRequestSchema = z.object({ + type: z.literal("plugin.directory.inspect.request"), + requestId: z.string(), + path: z.string().min(1), +}); + +function pluginIdRequest(type: Type) { + return z.object({ type: z.literal(type), requestId: z.string(), pluginId: PluginIdSchema }); +} + +export const PluginReloadRequestSchema = pluginIdRequest("plugin.reload.request"); +export const PluginEnableRequestSchema = pluginIdRequest("plugin.enable.request"); +export const PluginDisableRequestSchema = pluginIdRequest("plugin.disable.request"); +export const PluginRemoveRequestSchema = pluginIdRequest("plugin.remove.request"); + +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(), @@ -2760,6 +2830,15 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonDisconnectRequestSchema, DiagnosticsRequestSchema, + PluginCatalogGetRequestSchema, + PluginListRequestSchema, + PluginDirectoryInstallRequestSchema, + PluginDirectoryInspectRequestSchema, + PluginReloadRequestSchema, + PluginEnableRequestSchema, + PluginDisableRequestSchema, + PluginRemoveRequestSchema, + PluginRpcInvokeRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -3082,6 +3161,10 @@ export const ServerInfoStatusPayloadSchema = z relayConfig: z.boolean().optional(), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. pushTokenRevocation: z.boolean().optional(), + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: z.boolean().optional(), + // COMPAT(pluginManagement): added in v0.4.0, remove gate after 2027-08-14. + pluginManagement: 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. @@ -3268,6 +3351,11 @@ export const DaemonConfigChangedStatusPayloadSchema = z }) .passthrough(); +export const PluginCatalogChangedStatusPayloadSchema = z.object({ + status: z.literal("plugin_catalog_changed"), + pluginId: PluginIdSchema, +}); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -3276,6 +3364,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ ShutdownRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, + PluginCatalogChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer; @@ -5707,6 +5796,69 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu export type DaemonUpdateProgressMessage = z.infer; +export const PluginCatalogGetResponseSchema = z.object({ + type: z.literal("plugin.catalog.get.response"), + payload: z.object({ + requestId: z.string(), + plugins: z.array( + z.object({ + id: PluginIdSchema, + clientBundle: z.string(), + }), + ), + }), +}); + +export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]); +export type PluginStatus = z.infer; + +export const PluginListItemSchema = z.object({ + id: PluginIdSchema, + path: z.string(), + enabled: z.boolean(), + status: PluginStatusSchema, + error: z.string().optional(), +}); +export type PluginListItem = z.infer; + +export const PluginListResponseSchema = z.object({ + type: z.literal("plugin.list.response"), + payload: z.object({ requestId: z.string(), plugins: z.array(PluginListItemSchema) }), +}); + +export const PluginDirectoryInstallResponseSchema = z.object({ + type: z.literal("plugin.directory.install.response"), + payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }), +}); + +export const PluginDirectoryInspectResponseSchema = z.object({ + type: z.literal("plugin.directory.inspect.response"), + payload: z.object({ requestId: z.string(), id: PluginIdSchema }), +}); + +function pluginActionResponse(type: Type) { + return z.object({ + type: z.literal(type), + payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }), + }); +} + +export const PluginReloadResponseSchema = pluginActionResponse("plugin.reload.response"); +export const PluginEnableResponseSchema = pluginActionResponse("plugin.enable.response"); +export const PluginDisableResponseSchema = pluginActionResponse("plugin.disable.response"); +export const PluginRemoveResponseSchema = z.object({ + type: z.literal("plugin.remove.response"), + payload: z.object({ requestId: z.string() }).strict(), +}); + +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, HubExecutionAgentValidateResponseSchema, @@ -5714,6 +5866,15 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentUpdateSchema, HubExecutionAgentStreamSchema, BrowserAutomationExecuteRequestSchema, + PluginCatalogGetResponseSchema, + PluginListResponseSchema, + PluginDirectoryInstallResponseSchema, + PluginDirectoryInspectResponseSchema, + PluginReloadResponseSchema, + PluginEnableResponseSchema, + PluginDisableResponseSchema, + PluginRemoveResponseSchema, + PluginRpcInvokeResponseSchema, ActivityLogMessageSchema, AssistantChunkMessageSchema, AudioOutputMessageSchema, diff --git a/packages/server/package.json b/packages/server/package.json index 3fc98085c..429056e14 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -71,6 +71,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.4.0", "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", @@ -78,11 +79,13 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@paseo/plugin": "*", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 350945332..bb1d3f74b 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,7 +1,7 @@ import os from "node:os"; import http from "node:http"; import path from "node:path"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; @@ -42,6 +42,16 @@ type WebSocketProbeResult = | { status: "connected" } | { status: "rejected"; statusCode: number | null }; +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + describe("paseo daemon bootstrap", () => { afterEach(() => { vi.restoreAllMocks(); @@ -398,7 +408,24 @@ describe("paseo daemon bootstrap", () => { const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const pluginDirectory = path.join(paseoHomeRoot, "plugin"); + const pluginPidPath = path.join(pluginDirectory, "plugin.pid"); await mkdir(paseoHome, { recursive: true }); + if (!isPlatform("win32")) { + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "startup-rollback" }), + ); + await writeFile( + path.join(pluginDirectory, "index.tsx"), + `import { writeFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + writeFileSync(${JSON.stringify(pluginPidPath)}, String(process.pid)); +}`, + ); + } const config: PaseoDaemonConfig = { listen: `127.0.0.1:${mainPort}`, paseoHome, @@ -414,12 +441,22 @@ describe("paseo daemon bootstrap", () => { openai: undefined, speech: undefined, serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` }, + pluginsEnabled: !isPlatform("win32"), + plugins: isPlatform("win32") + ? {} + : { "startup-rollback": { source: "directory", path: pluginDirectory } }, }; const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); try { await expect(daemon.start()).rejects.toThrow(); await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow(); + if (!isPlatform("win32")) { + const pluginPid = Number(await readFile(pluginPidPath, "utf8")); + await vi.waitFor(() => { + expect(processExists(pluginPid)).toBe(false); + }); + } } finally { await daemon.stop().catch(() => undefined); await new Promise((resolve) => occupiedMain.close(() => resolve())); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e27021674..b0449544d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -168,7 +168,12 @@ import type { PushNotificationSender } from "./push/index.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 { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; +import type { + AgentProfile, + FirstAgentContext, + PluginSource, + TerminalProfile, +} from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -216,6 +221,7 @@ import { type HubRelationshipRemote, } from "./hub/relationship-remote.js"; import { DaemonExecutions } from "./hub/daemon-executions.js"; +import { PluginService } from "./plugins/index.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -398,6 +404,8 @@ export interface PaseoDaemonConfig { appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; agentProfiles?: AgentProfile[]; + pluginsEnabled?: boolean; + plugins?: Record; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -532,6 +540,8 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", + pluginsEnabled: config.pluginsEnabled ?? false, + plugins: config.plugins ?? {}, }; if (config.terminalProfiles !== undefined) { @@ -563,6 +573,7 @@ export async function createPaseoDaemon( ); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); + const pluginRuntime = new PluginService(logger, daemonConfigStore); const serverId = getOrCreateServerId(config.paseoHome, { logger }); const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); @@ -1431,6 +1442,7 @@ export async function createPaseoDaemon( const start = async () => { let mainStarted = false; try { + await pluginRuntime.start(); if (serviceProxyListenTarget) { const boundServiceProxyTarget = await serviceProxy.startStandalone({ listenTarget: serviceProxyListenTarget, @@ -1560,6 +1572,7 @@ export async function createPaseoDaemon( browserToolsBroker, hubRelationships, workspaceSetupRuntime, + pluginRuntime, ); relayRuntime = createRelayRuntime({ config: { @@ -1603,6 +1616,7 @@ export async function createPaseoDaemon( speechService.start(); scriptHealthMonitor.start(); } catch (error) { + await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); if (mainStarted) { httpServer.closeAllConnections(); @@ -1613,6 +1627,7 @@ export async function createPaseoDaemon( }; const stop = async () => { + await pluginRuntime.stopAllPlugins(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); diff --git a/packages/server/src/server/config-plugins.test.ts b/packages/server/src/server/config-plugins.test.ts new file mode 100644 index 000000000..0e0232a2a --- /dev/null +++ b/packages/server/src/server/config-plugins.test.ts @@ -0,0 +1,34 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { loadConfig } from "./config.js"; + +const roots: string[] = []; + +async function createPaseoHome(config: unknown): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-plugins-")); + roots.push(root); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); + return paseoHome; +} + +describe("daemon plugin config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("defaults plugins off when config is absent", async () => { + const home = await createPaseoHome({ version: 1 }); + + expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(false); + }); + + test("loads the explicit plugin opt-in", async () => { + const home = await createPaseoHome({ version: 1, pluginsEnabled: true }); + + expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(true); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 9419b5b15..6fbe8d020 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -534,6 +534,8 @@ export function loadConfig( appendSystemPrompt, terminalProfiles, agentProfiles, + pluginsEnabled: persisted.pluginsEnabled ?? false, + plugins: persisted.plugins, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 9ed029e08..47e706116 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -198,6 +198,7 @@ export class DaemonConfigStore { const { removeProviders = [], ...configPatch } = parsedPatch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins; const next = MutableDaemonConfigSchema.parse( omitMetadataGenerationProvidersFromConfig( omitProvidersFromConfig(merged, removedProviders), @@ -335,6 +336,8 @@ function mergeMutableConfigIntoPersistedConfig(params: { return { ...persisted, + pluginsEnabled: mutable.pluginsEnabled, + plugins: mutable.plugins, daemon: { ...persisted.daemon, ...(persistRelayEnabled diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index 5fb22afda..a96dc8cfd 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -187,7 +187,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution" type: "hub.execution.agent.create.response", payload: { success: true, executionId: "replayed-execution" }, }); - const providerCreations = hub.providerCreations(); + const executionProviderCreations = hub.executionProviderCreations(); hub.beginOwnedCreate("replay-create", "replayed-execution", { mcpServers: { @@ -204,7 +204,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution" agentId: original.payload.agentId, }, }); - expect(hub.providerCreations()).toBe(providerCreations); + expect(hub.executionProviderCreations()).toBe(executionProviderCreations); expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]); }); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 9f83a5a6a..b4ecf9984 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -475,7 +475,7 @@ describe("Hub relationship", () => { }); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); expect(durableAgentIds).toEqual([created.payload.agentId]); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(relationship.providerResumes()).toBe(0); expect(relationship.providerPromptTexts()).toEqual([prompt]); expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); @@ -640,7 +640,7 @@ describe("Hub relationship", () => { agentId: durableAgentIds[0], }, }); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(durableAgentIds).toHaveLength(1); }); @@ -665,7 +665,7 @@ describe("Hub relationship", () => { agent: { id: expect.any(String) }, }, }); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(await relationship.durableOwnedAgentIds()).toHaveLength(1); }); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index ac3408670..9589ffb75 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -862,6 +862,10 @@ export class HubRelationshipHarness { return this.codex.creations; } + executionProviderCreations(): number { + return this.codex.createdConfigs.filter((config) => config.internal !== true).length; + } + providerResumes(): number { return this.codex.resumes; } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e83b02603..2012ba093 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,7 +9,12 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { + AgentProfileSchema, + 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"]); @@ -304,6 +309,8 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), agents: z .object({ diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts new file mode 100644 index 000000000..1726f10bf --- /dev/null +++ b/packages/server/src/server/plugins/compiler.ts @@ -0,0 +1,206 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "@babel/parser"; +import { build, type Plugin } from "esbuild"; + +type PluginBuildTarget = "client" | "server"; + +const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { + client: new Set(["handle"]), + server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]), +}; + +function registrationName(statement: unknown, contextName: string): string | null { + if (!statement || typeof statement !== "object") return null; + const expression = Reflect.get(statement, "expression"); + if ( + !expression || + typeof expression !== "object" || + Reflect.get(expression, "type") !== "CallExpression" + ) { + return null; + } + const callee = Reflect.get(expression, "callee"); + if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") { + return null; + } + const object = Reflect.get(callee, "object"); + const property = Reflect.get(callee, "property"); + if ( + !object || + typeof object !== "object" || + Reflect.get(object, "type") !== "Identifier" || + Reflect.get(object, "name") !== contextName || + !property || + typeof property !== "object" || + Reflect.get(property, "type") !== "Identifier" + ) { + return null; + } + return String(Reflect.get(property, "name")); +} + +function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } { + const defaultExports = programBody.filter( + (statement) => + statement !== null && + typeof statement === "object" && + Reflect.get(statement, "type") === "ExportDefaultDeclaration", + ); + if (defaultExports.length !== 1) { + throw new Error("Plugin index.tsx must have exactly one default export function"); + } + const declaration = Reflect.get(defaultExports[0] as object, "declaration"); + const declarationType = + declaration !== null && typeof declaration === "object" + ? Reflect.get(declaration, "type") + : null; + if ( + declarationType !== "FunctionDeclaration" && + declarationType !== "FunctionExpression" && + declarationType !== "ArrowFunctionExpression" + ) { + throw new Error("Plugin default export must be a function receiving its context"); + } + const parameters = Reflect.get(declaration, "params"); + const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null; + if ( + parameter === null || + typeof parameter !== "object" || + Reflect.get(parameter, "type") !== "Identifier" + ) { + throw new Error("Plugin default export must receive one named context parameter"); + } + const body = Reflect.get(declaration, "body"); + if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") { + throw new Error("Plugin default export must have a block body"); + } + return { body, contextName: String(Reflect.get(parameter, "name")) }; +} + +function collectRemovedRegistrationRanges( + node: unknown, + contextName: string, + removedNames: ReadonlySet, + ranges: Array<{ start: number; end: number }>, +): void { + if (node === null || typeof node !== "object") return; + if (Reflect.get(node, "type") === "ExpressionStatement") { + const name = registrationName(node, contextName); + if (name && removedNames.has(name)) { + const start = Reflect.get(node, "start"); + const end = Reflect.get(node, "end"); + if (typeof start !== "number" || typeof end !== "number") { + throw new Error(`Could not locate plugin context ${name} registration in index.tsx`); + } + ranges.push({ start, end }); + return; + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) + collectRemovedRegistrationRanges(child, contextName, removedNames, ranges); + } else if (value !== null && typeof value === "object") { + collectRemovedRegistrationRanges(value, contextName, removedNames, ranges); + } + } +} + +function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string { + const ast = parse(source, { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + const pluginFunction = defaultPluginFunction(ast.program.body); + const ranges: Array<{ start: number; end: number }> = []; + collectRemovedRegistrationRanges( + pluginFunction.body, + pluginFunction.contextName, + REGISTRATIONS_REMOVED_BY_TARGET[target], + ranges, + ); + + let output = source; + for (const range of ranges.toReversed()) { + output = `${output.slice(0, range.start)}${output.slice(range.end)}`; + } + return output; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { + const filter = + target === "server" + ? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/ + : /^node:/; + return { + name: `paseo-plugin-${target}-unused-platform-modules`, + setup(buildContext) { + buildContext.onResolve({ filter }, (args) => ({ + path: args.path, + namespace: "paseo-unused-platform-module", + sideEffects: false, + })); + buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({ + contents: "module.exports = {};", + loader: "js", + })); + }, + }; +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const source = await readFile(entryPath, "utf8"); + const filteredSource = removeOppositeTargetRegistrations(source, target); + const result = await build({ + stdin: { + contents: filteredSource, + loader: "tsx", + resolveDir: path.dirname(entryPath), + sourcefile: entryPath, + }, + bundle: true, + format: "cjs", + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + external: + target === "client" + ? [ + "@paseo/plugin", + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : ["@paseo/plugin", "zod"], + plugins: [createUnusedPlatformModulePlugin(target)], + treeShaking: true, + write: false, + }); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + return wrapCommonJsBundle(makeHermesInteropEager(output)); +} + +export async function compilePlugin(entryPath: string): Promise<{ + clientBundle: string; + serverBundle: string; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + compileTarget(entryPath, "client"), + compileTarget(entryPath, "server"), + ]); + return { clientBundle, serverBundle }; +} diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts new file mode 100644 index 000000000..1a3ec585c --- /dev/null +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -0,0 +1,370 @@ +import { mkdtemp, readFile, rm, stat, 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 { DaemonConfigStore } from "../daemon-config-store.js"; +import { PluginService } from "./index.js"; + +const roots: string[] = []; + +async function createPlugin(id: string, source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); + roots.push(directory); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id })); + await writeFile(path.join(directory, "index.tsx"), source); + return directory; +} + +function createStore( + home: string, + plugins: Record = {}, +): DaemonConfigStore { + return new DaemonConfigStore(home, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins, + }); +} + +function createService( + home: string, + plugins: Record = {}, + runtime?: ConstructorParameters[2], +): PluginService { + return new PluginService(pino({ level: "silent" }), createStore(home, plugins), runtime); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function createPausedRuntime() { + let releaseStart = () => undefined; + let markStarted = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const running = new Set(); + const runtime: NonNullable[2]> = { + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + markStarted(); + await startGate; + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + }; + return { runtime, started, releaseStart }; +} + +function createPluginSelectivePausedRuntime(pausedPluginId: string) { + let releaseStart = () => undefined; + let markStarted = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const starts: string[] = []; + const running = new Set(); + const runtime: NonNullable[2]> = { + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + starts.push(pluginId); + if (pluginId === pausedPluginId) { + markStarted(); + await startGate; + } + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + }; + return { runtime, started, releaseStart, starts }; +} + +describe("PluginService", () => { + it("uses an explicit config key, exposes reload failure, and retries from disk", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "manifest-default", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + + await expect( + service.installDirectory({ path: directory, id: "work-plugin" }), + ).resolves.toMatchObject({ + id: "work-plugin", + status: "running", + }); + await expect(service.installDirectory({ path: directory, id: "work-plugin" })).rejects.toThrow( + "choose another ID with --id", + ); + + await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); + expect(service.catalog()).toEqual([]); + expect(service.listPlugins()).toEqual([ + expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }), + ]); + + await writeFile( + path.join(directory, "index.tsx"), + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" }); + await service.stopAllPlugins(); + }); + + it("disables and removes a plugin without touching its source directory", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const cleanupFile = path.join(home, "cleanup.txt"); + const directory = await createPlugin( + "cleanup-plugin", + `import { writeFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + return () => writeFileSync(${JSON.stringify(cleanupFile)}, "cleaned"); +}`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + await service.installDirectory({ path: directory }); + + await expect(service.disablePlugin("cleanup-plugin")).resolves.toMatchObject({ + status: "disabled", + }); + expect(await readFile(cleanupFile, "utf8")).toBe("cleaned"); + await service.removePlugin("cleanup-plugin"); + + expect(service.listPlugins()).toEqual([]); + await expect(stat(directory)).resolves.toMatchObject({}); + await service.stopAllPlugins(); + }); + + it("detaches every plugin synchronously when the global switch turns off and recovers", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const first = await createPlugin( + "first", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const second = await createPlugin( + "second", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + await service.installDirectory({ path: first }); + await service.installDirectory({ path: second }); + + store.patch({ pluginsEnabled: false }); + + expect(service.catalog()).toEqual([]); + await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + store.patch({ pluginsEnabled: true }); + await service.reloadPlugin("first"); + expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([ + { id: "first", status: "running" }, + { id: "second", status: "running" }, + ]); + await service.stopAllPlugins(); + }); + + it("does not publish an in-flight start after a later global disable", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: true }, + }); + store.patch({ pluginsEnabled: false }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + store.patch({ pluginsEnabled: true }); + await paused.started; + store.patch({ pluginsEnabled: false }); + paused.releaseStart(); + await service.stopAllPlugins(); + + expect(service.catalog()).toEqual([]); + expect(service.listPlugins()).toEqual([ + { id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" }, + ]); + }); + + it("does not publish an in-flight enable after a later plugin disable", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + const enabling = expect(service.enablePlugin("slow")).rejects.toThrow( + "Plugin start cancelled: slow", + ); + await paused.started; + const disabling = service.disablePlugin("slow"); + paused.releaseStart(); + + await enabling; + await expect(disabling).resolves.toMatchObject({ status: "disabled" }); + expect(service.catalog()).toEqual([]); + }); + + it("keeps a later disable authoritative over an enable waiting behind another plugin", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + occupier: { source: "directory", path: "/plugins/occupier", enabled: false }, + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPluginSelectivePausedRuntime("occupier"); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + const occupying = service.enablePlugin("occupier"); + await paused.started; + const enabling = service.enablePlugin("slow"); + const disabling = service.disablePlugin("slow"); + paused.releaseStart(); + + await occupying; + await expect(enabling).resolves.toMatchObject({ + id: "slow", + enabled: false, + status: "disabled", + }); + await expect(disabling).resolves.toMatchObject({ + id: "slow", + enabled: false, + status: "disabled", + }); + expect(paused.starts).toEqual(["occupier"]); + expect(service.catalog()).toEqual([{ id: "occupier", clientBundle: "bundle" }]); + }); + + it("notifies exactly once after successful and failed configured installs", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const successful = await createPlugin( + "successful-install", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const failed = await createPlugin( + "failed-install", + `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, + ); + const service = createService(home); + const events: string[] = []; + service.subscribe((pluginId) => events.push(pluginId)); + await service.start(); + + await service.installDirectory({ path: successful }); + expect(events).toEqual(["successful-install"]); + + events.length = 0; + await expect(service.installDirectory({ path: failed })).rejects.toThrow("startup exploded"); + expect(events).toEqual(["failed-install"]); + await service.stopAllPlugins(); + }); + + it("reports invalid manifests, missing entries, and startup failures", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};"); + await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({})); + const missingEntry = await createPlugin("missing-entry", "export default () => () => {};"); + await rm(path.join(missingEntry, "index.tsx")); + const startupFailure = await createPlugin( + "startup-failure", + `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, + ); + const service = createService(home); + await service.start(); + + await expect(service.installDirectory({ path: invalid })).rejects.toThrow(); + await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow( + "Plugin entry point is missing", + ); + await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( + "startup exploded", + ); + expect(service.listPlugins()).toEqual([ + expect.objectContaining({ + id: "missing-entry", + status: "failed", + error: expect.stringContaining("Plugin entry point is missing"), + }), + expect.objectContaining({ + id: "startup-failure", + status: "failed", + error: "startup exploded", + }), + ]); + await service.stopAllPlugins(); + }); + + it("contains cleanup errors and invokes server cleanup once per stopped installation", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const cleanupFile = path.join(home, "cleanups.txt"); + const directory = await createPlugin( + "cleanup-count", + `import { appendFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + return () => { + appendFileSync(${JSON.stringify(cleanupFile)}, "cleanup\\n"); + throw new Error("cleanup exploded"); + }; +}`, + ); + const service = createService(home); + await service.start(); + await service.installDirectory({ path: directory }); + await service.reloadPlugin("cleanup-count"); + await service.disablePlugin("cleanup-count"); + await service.enablePlugin("cleanup-count"); + await service.removePlugin("cleanup-count"); + await service.installDirectory({ path: directory }); + await service.stopAllPlugins(); + + expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4); + }); +}); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts new file mode 100644 index 000000000..d933bd3e5 --- /dev/null +++ b/packages/server/src/server/plugins/index.ts @@ -0,0 +1,278 @@ +import path from "node:path"; +import type pino from "pino"; +import { + PluginIdSchema, + type PluginListItem, + type PluginSource, +} from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../daemon-config-store.js"; +import { readPluginManifest } from "./manifest.js"; +import { PluginRuntime } from "./runtime.js"; + +interface PluginRuntimePort { + catalog(): Array<{ id: string; clientBundle: string }>; + invoke(pluginId: string, method: string, input: unknown): Promise; + startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; + stopPluginById(pluginId: string): Promise; + stopAll(): Promise; + subscribe(listener: (pluginId: string, error?: string) => void): () => void; +} + +export class PluginService { + private readonly runtime: PluginRuntimePort; + private readonly logger: pino.Logger; + private readonly errors = new Map(); + private readonly listeners = new Set<(pluginId: string) => void>(); + private lifecycle = Promise.resolve(); + private globalStartsBlocked = true; + private started = false; + + constructor( + logger: pino.Logger, + private readonly configStore: DaemonConfigStore, + runtime: PluginRuntimePort = new PluginRuntime(logger), + ) { + this.logger = logger.child({ module: "plugin-service" }); + this.runtime = runtime; + this.runtime.subscribe((pluginId, error) => { + if (error) this.errors.set(pluginId, error); + this.notify(pluginId); + }); + } + + subscribe(listener: (pluginId: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async start(): Promise { + if (this.started) return; + this.started = true; + const config = this.configStore.get(); + this.globalStartsBlocked = config.pluginsEnabled !== true; + if (config.pluginsEnabled === true) { + for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { + if (source.enabled === false) continue; + await this.startConfigured(pluginId); + } + } + this.configStore.onFieldChange("pluginsEnabled", (value) => { + this.handleGlobalSwitch(value === true); + }); + } + + listPlugins(): PluginListItem[] { + const config = this.configStore.get(); + const running = new Set(this.runtime.catalog().map((plugin) => plugin.id)); + return Object.entries(config.plugins ?? {}) + .map(([id, source]) => { + const enabled = source.enabled !== false; + if (!enabled || config.pluginsEnabled !== true) { + return { id, path: source.path, enabled, status: "disabled" as const }; + } + const error = this.errors.get(id); + const item: PluginListItem = { + id, + path: source.path, + enabled, + status: running.has(id) ? ("running" as const) : ("failed" as const), + }; + if (error) item.error = error; + return item; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + } + + catalog(): Array<{ id: string; clientBundle: string }> { + return this.runtime.catalog(); + } + + async installDirectory(input: { path: string; id?: string }): Promise { + return this.enqueue(async () => { + const directory = path.resolve(input.path); + const manifest = await readPluginManifest(directory); + const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); + if (this.configStore.get().plugins?.[pluginId]) { + throw new Error( + `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, + ); + } + const sources = { + ...this.configStore.get().plugins, + [pluginId]: { source: "directory" as const, path: directory, enabled: true }, + }; + this.configStore.patch({ plugins: sources }); + if (this.canPublish(pluginId)) await this.startConfigured(pluginId); + this.notify(pluginId); + const installed = this.requireItem(pluginId); + if (installed.status === "failed") { + throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); + } + return installed; + }); + } + + async inspectDirectory(configuredPath: string): Promise<{ id: string }> { + return readPluginManifest(path.resolve(configuredPath)); + } + + async reloadPlugin(pluginId: string): Promise { + return this.enqueue(async () => { + const source = this.requireEnabledSource(pluginId); + if (this.configStore.get().pluginsEnabled !== true) { + throw new Error("Plugins are globally disabled"); + } + this.errors.delete(pluginId); + await this.runtime.stopPluginById(pluginId); + await this.startExplicit(pluginId, source.path); + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async enablePlugin(pluginId: string): Promise { + const source = this.requireSource(pluginId); + this.patchSource(pluginId, { ...source, enabled: true }); + this.errors.delete(pluginId); + return this.enqueue(async () => { + const current = this.requireSource(pluginId); + if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) { + await this.startExplicit(pluginId, current.path); + } + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async disablePlugin(pluginId: string): Promise { + const source = this.requireSource(pluginId); + this.patchSource(pluginId, { ...source, enabled: false }); + const stopping = this.runtime.stopPluginById(pluginId); + return this.enqueue(async () => { + await stopping; + this.errors.delete(pluginId); + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async removePlugin(pluginId: string): Promise { + this.requireSource(pluginId); + const stopping = this.runtime.stopPluginById(pluginId); + const sources = { ...this.configStore.get().plugins }; + delete sources[pluginId]; + this.configStore.patch({ plugins: sources }); + await this.enqueue(async () => { + await stopping; + this.errors.delete(pluginId); + this.notify(pluginId); + }); + } + + invokePluginRpc(pluginId: string, method: string, input: unknown): Promise { + return this.runtime.invoke(pluginId, method, input); + } + + async stopAllPlugins(): Promise { + this.globalStartsBlocked = true; + const stopping = this.runtime.stopAll(); + await this.enqueue(async () => { + await stopping; + await this.runtime.stopAll(); + }); + } + + private handleGlobalSwitch(enabled: boolean): void { + if (!enabled) { + this.globalStartsBlocked = true; + const stopping = this.runtime.stopAll(); + for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); + void this.enqueue(async () => { + await stopping; + }).catch((error) => this.logger.error({ err: error }, "Failed to disable plugins")); + return; + } + void this.enqueue(async () => { + this.globalStartsBlocked = false; + for (const [pluginId, source] of Object.entries(this.configStore.get().plugins ?? {})) { + if (source.enabled !== false) await this.startConfigured(pluginId); + this.notify(pluginId); + } + }).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins")); + } + + private async startConfigured(pluginId: string): Promise { + const source = this.configStore.get().plugins?.[pluginId]; + if (!source || source.enabled === false || !this.canPublish(pluginId)) return; + this.errors.delete(pluginId); + try { + await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId)); + } catch (error) { + if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); + } + } + + private async startExplicit(pluginId: string, sourcePath: string): Promise { + try { + await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + } catch (error) { + if (this.canPublish(pluginId)) { + this.recordFailure(pluginId, error); + this.notify(pluginId); + } + throw error; + } + } + + private canPublish(pluginId: string): boolean { + const config = this.configStore.get(); + return ( + !this.globalStartsBlocked && + config.pluginsEnabled === true && + config.plugins?.[pluginId]?.enabled !== false && + config.plugins?.[pluginId] !== undefined + ); + } + + private enqueue(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private recordFailure(pluginId: string, error: unknown): void { + this.errors.set(pluginId, error instanceof Error ? error.message : String(error)); + } + + private requireSource(pluginId: string): PluginSource { + PluginIdSchema.parse(pluginId); + const source = this.configStore.get().plugins?.[pluginId]; + if (!source) throw new Error(`Plugin is not configured: ${pluginId}`); + return source; + } + + private requireEnabledSource(pluginId: string): PluginSource { + const source = this.requireSource(pluginId); + if (source.enabled === false) throw new Error(`Plugin is disabled: ${pluginId}`); + return source; + } + + private patchSource(pluginId: string, source: PluginSource): void { + this.configStore.patch({ + plugins: { ...this.configStore.get().plugins, [pluginId]: source }, + }); + } + + private requireItem(pluginId: string): PluginListItem { + const item = this.listPlugins().find((plugin) => plugin.id === pluginId); + if (!item) throw new Error(`Plugin is not configured: ${pluginId}`); + return item; + } + + private notify(pluginId: string): void { + for (const listener of this.listeners) listener(pluginId); + } +} diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts new file mode 100644 index 000000000..540f3540e --- /dev/null +++ b/packages/server/src/server/plugins/manifest.ts @@ -0,0 +1,16 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { PluginIdSchema } from "@getpaseo/protocol/messages"; + +const MANIFEST_FILENAME = "paseo-plugin.json"; +const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict(); + +export type PluginManifest = z.infer; + +export async function readPluginManifest(directory: string): Promise { + const manifestPath = path.join(directory, MANIFEST_FILENAME); + const info = await stat(manifestPath).catch(() => null); + if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`); + return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts new file mode 100644 index 000000000..fc2b4eee9 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -0,0 +1,10 @@ +export type PluginProcessRequest = + | { type: "initialize"; bundle: string } + | { type: "invoke"; requestId: string; method: string; input: unknown } + | { type: "shutdown" }; + +export type PluginProcessMessage = + | { type: "ready"; methods: string[] } + | { type: "result"; requestId: string; output: unknown } + | { type: "error"; requestId: string; error: string } + | { type: "fatal"; error: string }; diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts new file mode 100644 index 000000000..8c54183aa --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -0,0 +1,105 @@ +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import { createRequire } from "node:module"; +import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@paseo/plugin"; + +type RpcHandler = (input: unknown) => unknown | Promise; + +interface RegisteredRpc { + contract: PluginRpcContract; + handler: RpcHandler; +} + +const handlers = new Map(); +let cleanup: (() => void) | null = null; +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 register(contract: PluginRpcContract, 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 { defineAttachmentSource, 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"); + } + const contributedCleanup = setup({ handle: register }); + if (typeof contributedCleanup !== "function") { + throw new Error("Plugin contribution must return a cleanup function"); + } + cleanup = contributedCleanup; +} + +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") { + const currentCleanup = cleanup; + cleanup = null; + if (currentCleanup) { + try { + currentCleanup(); + } catch (error) { + console.error("Plugin cleanup failed", error); + } + } + process.disconnect(); + return; + } + const registered = handlers.get(message.method); + if (!registered) { + send({ + type: "error", + requestId: message.requestId, + error: `Unknown RPC method: ${message.method}`, + }); + return; + } + void registered.contract.input + .parseAsync(message.input) + .then(registered.handler) + .then((output) => registered.contract.output.parseAsync(output)) + .then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts new file mode 100644 index 000000000..8a1c07bf5 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -0,0 +1,319 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { afterEach, describe, expect, it } from "vitest"; +import { PluginRuntime } from "./runtime.js"; + +const temporaryDirectories: string[] = []; + +async function createPlugin(id: string, source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8"); + await writeFile(path.join(directory, "index.tsx"), source, "utf8"); + return directory; +} + +function createReloadChild(name: string, events: string[], methods: string[] = []) { + const listeners = new Map void>>(); + const emit = (event: string, message: unknown) => { + for (const listener of listeners.get(event) ?? []) listener(message as never); + }; + return { + connected: true, + killed: false, + send(message: { type: string }, callback?: (error: Error | null) => void) { + callback?.(null); + if (message.type === "initialize") { + events.push(`start:${name}`); + queueMicrotask(() => emit("message", { type: "ready", methods })); + } + if (message.type === "shutdown") { + events.push(`shutdown:${name}`); + this.connected = false; + queueMicrotask(() => { + events.push(`exit:${name}`); + emit("close", null); + }); + } + return true; + }, + kill() { + this.killed = true; + this.connected = false; + queueMicrotask(() => emit("close", null)); + return true; + }, + disconnect() { + this.connected = false; + }, + on(event: string, listener: (message: never) => void) { + const registered = listeners.get(event) ?? []; + registered.push(listener); + listeners.set(event, registered); + return this; + }, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("PluginRuntime", () => { + it("waits for the old subprocess to exit before starting its replacement", async () => { + const directory = await createPlugin( + "reloadable", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const events: string[] = []; + const children = [createReloadChild("old", events), createReloadChild("new", events)]; + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => { + const child = children.shift(); + if (!child) throw new Error("Unexpected extra child"); + return child; + }, + }); + await runtime.startPlugin("configured-id", directory); + + await runtime.stopPluginById("configured-id"); + await runtime.startPlugin("configured-id", directory); + + expect(events).toEqual(["start:old", "shutdown:old", "exit:old", "start:new"]); + await runtime.stopAll(); + }); + + it("rejects pending RPCs when the plugin stops", async () => { + const directory = await createPlugin( + "pending", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild("pending", [], ["wait"]); + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => child, + }); + await runtime.startPlugin("pending", directory); + + const rejection = expect(runtime.invoke("pending", "wait", {})).rejects.toThrow( + "Plugin stopped: pending", + ); + await runtime.stopPluginById("pending"); + + await rejection; + }); + + it("kills a plugin child that fails initialization", async () => { + const directory = await createPlugin( + "broken", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const listeners = new Map void>>(); + const child = { + connected: true, + killed: false, + send(message: { type: string }, callback?: (error: Error | null) => void) { + callback?.(null); + if (message.type === "initialize") { + queueMicrotask(() => { + for (const listener of listeners.get("message") ?? []) { + listener({ type: "fatal", error: "broken plugin" } as never); + } + }); + } + return true; + }, + kill() { + this.killed = true; + this.connected = false; + return true; + }, + disconnect() { + this.connected = false; + }, + on(event: string, listener: (message: never) => void) { + const registered = listeners.get(event) ?? []; + registered.push(listener); + listeners.set(event, registered); + return this; + }, + }; + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => child, + }); + + await expect(runtime.startPlugin("broken", directory)).rejects.toThrow("broken plugin"); + + expect(child.killed).toBe(true); + await runtime.stopAll(); + }); + + it("rejects a server contribution without cleanup", async () => { + const directory = await createPlugin( + "missing-cleanup", + `export default function contribute(plugin: unknown) { void plugin; }`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await expect(runtime.startPlugin("missing-cleanup", directory)).rejects.toThrow( + "must return a cleanup function", + ); + expect(runtime.catalog()).toEqual([]); + await runtime.stopAll(); + }); + + it("loads the official Linear attachment extension", async () => { + const directory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../plugin-examples/linear", + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("linear", directory); + + expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["linear"]); + expect(runtime.catalog()[0]?.clientBundle).toContain("Attach Linear issue"); + expect(runtime.catalog()[0]?.clientBundle).not.toContain("LINEAR_API_KEY"); + expect(runtime.catalog()[0]?.clientBundle).not.toContain("api.linear.app"); + await runtime.stopAll(); + }); + + 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 { defineAttachmentSource, defineRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string(), platform: z.string() }), +}); + +const attachments = defineAttachmentSource({ + id: "issues", + title: "Example issue", + icon: "CircleDot", + pickerTitle: "Attach example issue", + searchPlaceholder: "Search issues", + search: greetRpc, +}); + +function HelloSurface() { + return Hello from native UI; +} + +export default function contribute(plugin: any) { + plugin.handle(greetRpc, async (input: { name: string }) => ({ + message: "Hello, " + input.name, + platform: platform(), + })); + plugin.addSurface("main", HelloSurface); + plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); + plugin.addAttachmentSource(attachments); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("hello", 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).toContain("Attach example issue"); + 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.stopAll(); + }); + + 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" })); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("invalid-output", directory); + + await expect(runtime.invoke("invalid-output", "broken", {})).rejects.toThrow(); + await runtime.stopAll(); + }); + + it("uses the config key as runtime identity without comparing the manifest id", async () => { + const directory = await createPlugin( + "actual", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("configured", directory); + + expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["configured"]); + await runtime.stopAll(); + }); + + it("does not publish a plugin when lifecycle intent changes while it starts", async () => { + const directory = await createPlugin( + "blocked", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const events: string[] = []; + const child = createReloadChild("blocked", events); + const runtime = new PluginRuntime(pino({ level: "silent" }), { spawnChild: () => child }); + + await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( + "Plugin start cancelled: blocked", + ); + + expect(runtime.catalog()).toEqual([]); + expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); + }); + + it("reports an unexpected subprocess crash and removes its catalog entry", async () => { + const directory = await createPlugin( + "crashing", + `export default function contribute(plugin: unknown) { + void plugin; + setTimeout(() => process.exit(17), 20); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + const crashed = new Promise((resolve) => { + runtime.subscribe((pluginId, error) => { + if (pluginId === "crashing" && error) resolve(error); + }); + }); + await runtime.startPlugin("crashing", directory); + + await expect(crashed).resolves.toBe("Plugin process exited: crashing"); + expect(runtime.catalog()).toEqual([]); + await expect(runtime.invoke("crashing", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + }); +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts new file mode 100644 index 000000000..c7deab8f1 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.ts @@ -0,0 +1,269 @@ +import { fork } from "node:child_process"; +import { 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 { compilePlugin } from "./compiler.js"; +import { readPluginManifest } from "./manifest.js"; +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; + +const ENTRY_FILENAME = "index.tsx"; +const REQUEST_TIMEOUT_MS = 30_000; + +interface PluginChild { + connected: boolean; + killed: boolean; + send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; + kill(): boolean; + disconnect(): void; + on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +} + +interface PendingInvocation { + resolve: (output: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +interface LoadedPlugin { + id: string; + clientBundle: string; + methods: ReadonlySet; + child: PluginChild; + pending: Map; +} + +interface PluginRuntimeDependencies { + spawnChild?: () => PluginChild; +} + +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 terminatePluginChild(child: PluginChild): void { + if (child.connected) child.disconnect(); + if (!child.killed) child.kill(); +} + +function send(child: PluginChild, message: PluginProcessRequest): Promise { + return new Promise((resolve, reject) => { + child.send(message, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function requireRegularFile(filePath: string, label: string): Promise { + const info = await stat(filePath).catch(() => null); + if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`); +} + +export class PluginRuntime { + private readonly plugins = new Map(); + private readonly logger: pino.Logger; + private readonly spawnChild: () => PluginChild; + private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); + + constructor(logger: pino.Logger, dependencies: PluginRuntimeDependencies = {}) { + this.logger = logger.child({ module: "plugins" }); + this.spawnChild = dependencies.spawnChild ?? spawnPluginChild; + } + + subscribe(listener: (pluginId: string, error?: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async startPlugin( + pluginId: string, + configuredPath: string, + canPublish: () => boolean = () => true, + ): Promise { + if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath); + if (!canPublish()) { + await this.stopPlugin(loaded); + throw new Error(`Plugin start cancelled: ${pluginId}`); + } + this.plugins.set(pluginId, loaded); + } + + async stopPluginById(pluginId: string): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) return false; + this.plugins.delete(pluginId); + this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); + await this.stopPlugin(loaded); + return true; + } + + catalog(): Array<{ id: string; clientBundle: string }> { + return [...this.plugins.values()] + .map(({ id, clientBundle }) => ({ id, clientBundle })) + .sort((left, right) => left.id.localeCompare(right.id)); + } + + async invoke(pluginId: string, method: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.methods.has(method)) + throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + loaded.pending.delete(requestId); + reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + }, REQUEST_TIMEOUT_MS); + loaded.pending.set(requestId, { resolve, reject, timeout }); + void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => { + clearTimeout(timeout); + loaded.pending.delete(requestId); + reject(error); + }); + }); + } + + async stopAll(): Promise { + const loaded = [...this.plugins.values()]; + this.plugins.clear(); + for (const plugin of loaded) { + this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); + } + await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); + } + + private async loadDirectoryPlugin( + pluginId: string, + configuredPath: string, + ): Promise { + const directory = path.resolve(configuredPath); + await readPluginManifest(directory); + const entryPath = path.join(directory, ENTRY_FILENAME); + await requireRegularFile(entryPath, "Plugin entry point"); + const bundles = await compilePlugin(entryPath); + const child = this.spawnChild(); + const pending = new Map(); + let methods: string[]; + try { + methods = await new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout( + () => fail(new Error(`Plugin ${pluginId} did not initialize`)), + REQUEST_TIMEOUT_MS, + ); + const fail = (error: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }; + child.on("message", (message) => { + if (message.type === "ready") { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(message.methods); + } else if (message.type === "fatal") { + fail(new Error(message.error)); + } + }); + child.on("close", () => fail(new Error(`Plugin ${pluginId} exited during initialization`))); + void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(fail); + }); + } catch (error) { + terminatePluginChild(child); + throw error; + } + 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.logger.info({ pluginId, methods }, "Loaded plugin"); + return loaded; + } + + 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); + this.notify(loaded.id, `Plugin process exited: ${loaded.id}`); + } + this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); + } + + private async stopPlugin(loaded: LoadedPlugin): Promise { + if (loaded.child.killed) return; + let didClose = false; + const closed = new Promise((resolve) => + loaded.child.on("close", () => { + didClose = true; + resolve(); + }), + ); + if (loaded.child.connected) { + await send(loaded.child, { type: "shutdown" }).catch(() => undefined); + } + await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 1_000))]); + if (!didClose) terminatePluginChild(loaded.child); + await closed; + } + + private rejectPending(loaded: LoadedPlugin, message: string): void { + for (const invocation of loaded.pending.values()) { + clearTimeout(invocation.timeout); + invocation.reject(new Error(message)); + } + loaded.pending.clear(); + } + + private notify(pluginId: string, error?: string): void { + for (const listener of this.listeners) listener(pluginId, error); + } +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 663e147ef..13f0511d3 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -320,6 +320,7 @@ interface SessionForTestOptions { messages?: unknown[]; targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>; binaryMessages?: Uint8Array[]; + pluginRuntime?: SessionOptions["pluginRuntime"]; } function createSessionForTest(options: SessionForTestOptions = {}): Session { @@ -405,6 +406,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { })), onChange: vi.fn(() => () => {}), }), + pluginRuntime: options.pluginRuntime, stt: options.stt ?? null, tts: null, terminalManager: options.terminalManager ?? null, @@ -424,6 +426,71 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { return new Session(sessionOptions); } +test("routes plugin management requests and catalog notifications", async () => { + const messages: SessionOutboundMessage[] = []; + const listeners = new Set<(pluginId: string) => void>(); + const plugin = { + id: "example", + path: "/plugins/example", + enabled: true, + status: "running" as const, + }; + const pluginRuntime: NonNullable = { + listPlugins: () => [plugin], + installDirectory: async () => plugin, + inspectDirectory: async () => ({ id: "example" }), + reloadPlugin: async () => plugin, + enablePlugin: async () => plugin, + disablePlugin: async () => ({ ...plugin, enabled: false, status: "disabled" }), + removePlugin: async () => undefined, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + catalog: () => [{ id: "example", clientBundle: "bundle" }], + invokePluginRpc: async () => ({ ok: true }), + }; + const session = createSessionForTest({ messages, pluginRuntime }); + + await session.handleMessage({ type: "plugin.list.request", requestId: "list" }); + await session.handleMessage({ + type: "plugin.directory.install.request", + requestId: "install", + path: "/plugins/example", + }); + await session.handleMessage({ + type: "plugin.reload.request", + requestId: "reload", + pluginId: "example", + }); + await session.handleMessage({ + type: "plugin.disable.request", + requestId: "disable", + pluginId: "example", + }); + await session.handleMessage({ + type: "plugin.remove.request", + requestId: "remove", + pluginId: "example", + }); + for (const listener of listeners) listener("example"); + + expect(messages.map((message) => message.type)).toEqual([ + "plugin.list.response", + "plugin.directory.install.response", + "plugin.reload.response", + "plugin.disable.response", + "plugin.remove.response", + "status", + ]); + expect(messages.at(-1)).toEqual({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }); + await session.cleanup(); + expect(listeners.size).toBe(0); +}); + describe("session authorization scopes", () => { test("routes named-agent validation through the session source", async () => { const messages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1710a7072..a8c2daa32 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -460,6 +460,21 @@ export interface SessionOptions { workspaceGitService: WorkspaceGitService; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; + pluginRuntime?: { + listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; + installDirectory(input: { + path: string; + id?: string; + }): Promise; + inspectDirectory(path: string): Promise<{ id: string }>; + reloadPlugin(pluginId: string): Promise; + enablePlugin(pluginId: string): Promise; + disablePlugin(pluginId: string): Promise; + removePlugin(pluginId: string): Promise; + subscribe(listener: (pluginId: string) => void): () => void; + catalog(): Array<{ id: string; clientBundle: string }>; + invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; + }; mcpBaseUrl?: string | null; stt: Resolvable; sttLanguage?: string; @@ -637,8 +652,10 @@ export class Session { private readonly workspaceRecovery: WorkspaceRecoveryService; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null; + private unsubscribePluginChanges: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; private registryMutationQueue: Promise = Promise.resolve(); private projectUpdateQueue: Promise = Promise.resolve(); @@ -719,6 +736,7 @@ export class Session { workspaceGitService, workspaceAutoName, daemonConfigStore, + pluginRuntime, stt, sttLanguage, tts, @@ -758,6 +776,8 @@ export class Session { this.paseoHome = paseoHome; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; + this.pluginRuntime = pluginRuntime; + this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -1861,12 +1881,117 @@ export class Session { this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? + this.dispatchPluginDirectoryMessage(msg) ?? + this.dispatchPluginMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchScheduleMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } + private dispatchPluginMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "plugin.list.request") { + this.emit({ + type: "plugin.list.response", + payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] }, + }); + return undefined; + } + if (msg.type === "plugin.reload.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.reloadPlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.reload.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.enable.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.enablePlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.enable.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.disable.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.disablePlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.disable.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.remove.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.removePlugin(msg.pluginId).then(() => { + this.emit({ type: "plugin.remove.response", payload: { requestId: msg.requestId } }); + return 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 service is unavailable"); + return this.pluginRuntime + .invokePluginRpc(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 dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "plugin.directory.install.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.installDirectory({ path: msg.path, id: msg.id }).then((plugin) => { + this.emit({ + type: "plugin.directory.install.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.directory.inspect.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.inspectDirectory(msg.path).then(({ id }) => { + this.emit({ + type: "plugin.directory.inspect.response", + payload: { requestId: msg.requestId, id }, + }); + return undefined; + }); + } + return undefined; + } + + private subscribeToPluginChanges( + pluginRuntime: SessionOptions["pluginRuntime"], + ): (() => void) | null { + if (!pluginRuntime) return null; + return pluginRuntime.subscribe((pluginId) => { + this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } }); + }); + } + private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": @@ -7025,6 +7150,8 @@ export class Session { } this.unsubscribeProjectMutations?.(); this.unsubscribeProjectMutations = null; + this.unsubscribePluginChanges?.(); + this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; this.agentUpdates.dispose(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index bc2c347a8..7b89e6501 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -30,7 +30,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"; @@ -579,6 +584,7 @@ export class VoiceAssistantWebSocketServer { private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; private readonly directorySync = new DirectorySyncService(); + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; constructor( server: HTTPServer, @@ -623,6 +629,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; @@ -636,6 +643,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(); @@ -1357,6 +1365,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", @@ -1558,6 +1567,9 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. pushTokenRevocation: true, + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: true, + pluginManagement: 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. diff --git a/plugin-examples/linear/README.md b/plugin-examples/linear/README.md new file mode 100644 index 000000000..a6480f50c --- /dev/null +++ b/plugin-examples/linear/README.md @@ -0,0 +1,33 @@ +# Linear plugin example + +This example adds `Attach Linear issue` to the composer. Search by issue identifier +or title, then send a stable issue snapshot to the agent. + +Set a personal API key in the daemon environment: + +```bash +export LINEAR_API_KEY="lin_api_..." +``` + +Register the extension in `$PASEO_HOME/config.json`: + +```json +{ + "pluginsEnabled": true, + "plugins": { + "linear": { + "source": "directory", + "path": "/absolute/path/to/paseo/plugin-examples/linear" + } + } +} +``` + +Restart the development daemon after changing its environment or plugin configuration. + +The entry point demonstrates the complete attachment-source pattern: + +- define one validated search RPC; +- handle it in the daemon subprocess; +- register a declarative attachment source for the client; +- keep credentials and vendor API calls out of the client bundle. diff --git a/plugin-examples/linear/index.tsx b/plugin-examples/linear/index.tsx new file mode 100644 index 000000000..25b8ae335 --- /dev/null +++ b/plugin-examples/linear/index.tsx @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { + defineAttachmentSource, + defineRpc, + type PluginAttachmentSearchPayload, + type PluginContext, +} from "@paseo/plugin"; +import { createLinearIssueSearch } from "./linear"; + +const SearchPayloadSchema = z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + text: z.string(), + resourceType: z.string(), + }), + ), +}); + +const searchIssues = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: SearchPayloadSchema, +}); + +const issueAttachments = defineAttachmentSource({ + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: searchIssues, +}); + +export default function contribute(plugin: PluginContext) { + plugin.handle(searchIssues, async ({ query }): Promise => { + const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); + return linear.search(query); + }); + plugin.addAttachmentSource(issueAttachments); + return () => undefined; +} diff --git a/plugin-examples/linear/linear.test.ts b/plugin-examples/linear/linear.test.ts new file mode 100644 index 000000000..01fa00269 --- /dev/null +++ b/plugin-examples/linear/linear.test.ts @@ -0,0 +1,135 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { createLinearIssueSearch } from "./linear"; + +const issue = { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + description: "Let extensions attach external context.", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + priorityLabel: "High", + state: { name: "In Progress" }, + assignee: { name: "Mohamed" }, + project: { name: "Paseo" }, + labels: { nodes: [{ name: "Feature" }] }, +}; + +interface CapturedRequest { + authorization: string | undefined; + body: { query: string; variables: Record }; +} + +async function readRequest(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return { + authorization: request.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"], + }; +} + +async function withLinearServer( + respond: (request: CapturedRequest, response: ServerResponse) => void, + run: (endpoint: string) => Promise, +): Promise { + const server = createServer((request, response) => { + void readRequest(request).then((captured) => respond(captured, response)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + try { + return await run(`http://127.0.0.1:${address.port}/graphql`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +function sendJson(response: ServerResponse, body: unknown): void { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +describe("Linear issue search", () => { + it("resolves an exact issue identifier and formats its agent snapshot", async () => { + const result = await withLinearServer( + (request, response) => { + expect(request.authorization).toBe("lin_api_test"); + expect(request.body.variables).toEqual({ id: "ENG-123" }); + sendJson(response, { data: { issue } }); + }, + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search("eng-123"); + }, + ); + + expect(result).toEqual({ + items: [ + { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + subtitle: "In Progress · Mohamed", + url: issue.url, + resourceType: "issue", + text: [ + "Linear issue ENG-123: Plugin attachments", + `URL: ${issue.url}`, + "Status: In Progress", + "Priority: High", + "Assignee: Mohamed", + "Project: Paseo", + "Labels: Feature", + "", + "Let extensions attach external context.", + ].join("\n"), + }, + ], + }); + }); + + it("searches issue titles through a case-insensitive filter", async () => { + const result = await withLinearServer( + (request, response) => { + expect(request.body.variables).toEqual({ + filter: { title: { containsIgnoreCase: "plugin" } }, + }); + sendJson(response, { data: { issues: { nodes: [issue] } } }); + }, + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search(" plugin "); + }, + ); + + expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]); + }); + + it("surfaces GraphQL errors even when HTTP succeeds", async () => { + await expect( + withLinearServer( + (_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }), + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search("ENG-123"); + }, + ), + ).rejects.toThrow("Token expired"); + }); + + it("requires a daemon-side API key", () => { + expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow( + "Set LINEAR_API_KEY in the daemon environment", + ); + }); +}); diff --git a/plugin-examples/linear/linear.ts b/plugin-examples/linear/linear.ts new file mode 100644 index 000000000..ede9e1c5f --- /dev/null +++ b/plugin-examples/linear/linear.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; + +const LinearIssueSchema = z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + description: z.string().nullable(), + url: z.string(), + priorityLabel: z.string(), + state: z.object({ name: z.string() }), + assignee: z.object({ name: z.string() }).nullable(), + project: z.object({ name: z.string() }).nullable(), + labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }), +}); + +const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough(); +const ExactIssueResponseSchema = z.object({ + data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(), + errors: z.array(LinearGraphqlErrorSchema).optional(), +}); +const IssueSearchResponseSchema = z.object({ + data: z + .object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) }) + .nullable() + .optional(), + errors: z.array(LinearGraphqlErrorSchema).optional(), +}); + +const ISSUE_FIELDS = ` + id + identifier + title + description + url + priorityLabel + state { name } + assignee { name } + project { name } + labels { nodes { name } } +`; + +const EXACT_ISSUE_QUERY = ` + query PaseoLinearIssue($id: String!) { + issue(id: $id) { ${ISSUE_FIELDS} } + } +`; + +const SEARCH_ISSUES_QUERY = ` + query PaseoLinearIssues($filter: IssueFilter) { + issues(first: 20, filter: $filter, orderBy: updatedAt) { + nodes { ${ISSUE_FIELDS} } + } + } +`; + +const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i; + +export interface LinearAttachmentItem { + id: string; + identifier: string; + title: string; + subtitle?: string; + url: string; + text: string; + resourceType: string; +} + +export interface LinearIssueSearch { + search(query: string): Promise<{ items: LinearAttachmentItem[] }>; +} + +interface LinearIssueSearchOptions { + apiKey: string; + endpoint?: string; + request?: typeof fetch; +} + +interface GraphqlRequest { + query: string; + variables: Record; +} + +class LinearApiError extends Error { + constructor(message: string) { + super(message); + this.name = "LinearApiError"; + } +} + +function issueSubtitle(issue: z.infer): string | undefined { + const parts = [issue.state.name, issue.assignee?.name].filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +function issueText(issue: z.infer): string { + const labels = issue.labels.nodes.map((label) => label.name).join(", "); + const lines = [ + `Linear issue ${issue.identifier}: ${issue.title}`, + `URL: ${issue.url}`, + `Status: ${issue.state.name}`, + `Priority: ${issue.priorityLabel}`, + ]; + if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`); + if (issue.project) lines.push(`Project: ${issue.project.name}`); + if (labels) lines.push(`Labels: ${labels}`); + lines.push("", issue.description ?? "No description."); + return lines.join("\n"); +} + +function toAttachmentItem(issue: z.infer): LinearAttachmentItem { + const subtitle = issueSubtitle(issue); + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + ...(subtitle ? { subtitle } : {}), + url: issue.url, + text: issueText(issue), + resourceType: "issue", + }; +} + +function describeHttpFailure(status: number): string { + if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY"; + if (status === 429) return "Linear rate limit reached. Try again shortly"; + return `Linear API request failed with HTTP ${status}`; +} + +function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void { + if (!errors || errors.length === 0) return; + throw new LinearApiError(errors.map((error) => error.message).join("; ")); +} + +export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch { + const apiKey = options.apiKey.trim(); + if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment"); + const endpoint = options.endpoint ?? "https://api.linear.app/graphql"; + const request = options.request ?? fetch; + + async function graphql(body: GraphqlRequest): Promise { + const response = await request(endpoint, { + method: "POST", + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status)); + return response.json(); + } + + async function findExact(identifier: string): Promise { + const response = ExactIssueResponseSchema.parse( + await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }), + ); + throwGraphqlErrors(response.errors); + return response.data?.issue ? [toAttachmentItem(response.data.issue)] : []; + } + + async function searchTitles(query: string): Promise { + const filter = query ? { title: { containsIgnoreCase: query } } : null; + const response = IssueSearchResponseSchema.parse( + await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }), + ); + throwGraphqlErrors(response.errors); + if (!response.data) throw new LinearApiError("Linear returned no issue data"); + return response.data.issues.nodes.map(toAttachmentItem); + } + + return { + async search(query: string) { + const normalized = query.trim(); + if (LINEAR_IDENTIFIER.test(normalized)) { + return { items: await findExact(normalized.toUpperCase()) }; + } + const items = await searchTitles(normalized); + return { items }; + }, + }; +} diff --git a/plugin-examples/linear/paseo-plugin.json b/plugin-examples/linear/paseo-plugin.json new file mode 100644 index 000000000..c8fa978d9 --- /dev/null +++ b/plugin-examples/linear/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "linear" +} diff --git a/plugin-examples/local-plugin/index.tsx b/plugin-examples/local-plugin/index.tsx new file mode 100644 index 000000000..5f6ca6a48 --- /dev/null +++ b/plugin-examples/local-plugin/index.tsx @@ -0,0 +1,76 @@ +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, type PluginSurfaceProps, useRpc } from "@paseo/plugin"; + +const incrementRpc = defineRpc({ + name: "increment", + input: z.object({ value: z.number() }), + output: z.object({ value: z.number(), handledBy: z.string() }), +}); + +const ExampleThemeSchema = z.object({ + colors: z.object({ + surface0: z.string(), + foreground: z.string(), + foregroundMuted: z.string(), + accent: z.string(), + accentForeground: z.string(), + statusDanger: z.string(), + }), +}); + +function ExampleSurface({ theme }: PluginSurfaceProps) { + const { colors } = ExampleThemeSchema.parse(theme); + const callIncrement = useRpc(incrementRpc); + const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement }); + const value = data?.value ?? 0; + const styles = useMemo( + () => ({ + screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 }, + title: { color: colors.foreground, fontSize: 24 }, + detail: { color: colors.foregroundMuted }, + button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent }, + buttonText: { color: colors.accentForeground, textAlign: "center" as const }, + error: { color: colors.statusDanger }, + }), + [colors], + ); + const handleIncrement = useCallback(() => { + mutate({ value }); + }, [mutate, value]); + + return ( + + Native plugin surface + {data?.handledBy ?? "The RPC has not run yet."} + + + {isPending ? "Calling daemon…" : `RPC counter: ${value}`} + + + {error ? {error.message} : null} + + ); +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(incrementRpc, async (input) => ({ + value: input.value + 1, + handledBy: "plugin subprocess", + })); + plugin.addSurface("main", ExampleSurface); + plugin.addSidebarItem({ + id: "main", + title: "Local plugin", + icon: "Blocks", + surface: "main", + }); + return () => undefined; +} diff --git a/plugin-examples/local-plugin/paseo-plugin.json b/plugin-examples/local-plugin/paseo-plugin.json new file mode 100644 index 000000000..d1a1b3743 --- /dev/null +++ b/plugin-examples/local-plugin/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "local-example" +} diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md new file mode 100644 index 000000000..c1c77ff65 --- /dev/null +++ b/skills/paseo-plugin/SKILL.md @@ -0,0 +1,56 @@ +--- +name: paseo-plugin +description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, or remove a Paseo plugin. +user-invocable: true +--- + +# Paseo plugins + +Start by scaffolding a strict, typecheckable TSX project: + +```bash +paseo plugin init /absolute/path/to/my-plugin +cd /absolute/path/to/my-plugin +npm install +``` + +The generated directory contains `paseo-plugin.json`, `index.tsx`, `paseo-plugin.d.ts`, +`package.json`, and `tsconfig.json`. The manifest contains one default ID: + +```json +{ "id": "my-plugin" } +``` + +Default-export one contribution function from `index.tsx`. Use `@paseo/plugin` and the host-provided React, React Native, TanStack Query, and Zod dependencies. The function must return cleanup for resources created by plugin code, even when it has nothing to clean up: + +```tsx +import type { PluginContext } from "@paseo/plugin"; +import React from "react"; +import { Text } from "react-native"; + +function MainSurface() { + return Hello from my plugin; +} + +export default function contribute(plugin: PluginContext) { + plugin.addSurface("main", MainSurface); + return () => undefined; +} +``` + +Manage the plugin through the daemon CLI: + +```bash +paseo plugin install /absolute/path/to/plugin +paseo plugin install /absolute/path/to/plugin --id another-runtime-id +paseo plugin reload my-plugin +paseo plugin ls +paseo plugin disable my-plugin +paseo plugin enable my-plugin +paseo plugin remove my-plugin +``` + +After source edits, reload explicitly and use the reported load error to correct the plugin. Use `enable`, `disable`, and `remove` for recovery. Never edit `$PASEO_HOME/config.json` directly. Plugins are trusted code and run without sandboxing. + +Run `npm run typecheck` before every install or reload. The installed packages and local SDK +declarations exist only for static checking; Paseo provides the runtime modules.