From 23853a1515d374b873f9d5749b532a81f022b1cd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 17:12:49 +0200 Subject: [PATCH] Add managed local plugin lifecycle (#3222) * feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. * feat(plugins): add Linear attachment source * fix(nix): refresh npm dependency hash * test(server): exclude internal sessions from Hub counts * test(server): distinguish Hub execution sessions * feat(plugins): make local plugins explicitly opt-in Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory. * refactor(plugins): establish host boundaries * fix(nix): refresh plugin dependency hash * fix(plugins): complete packaging and startup rollback * feat(plugins): add managed local lifecycle Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership. * fix(nix): refresh plugin dependency hash --- .github/workflows/docker.yml | 1 + .github/workflows/nix-update-hash.yml | 1 + .github/workflows/nix.yml | 1 + CLAUDE.md | 1 + docker/base/Dockerfile | 1 + docs/plugins.md | 173 ++++++ nix/npm-deps.hash | 2 +- package-lock.json | 528 ++++++++++++++++++ package.json | 11 +- .../app/e2e/browser/plugin-management.spec.ts | 209 +++++++ .../app/e2e/support/helpers/new-workspace.ts | 2 + packages/app/e2e/support/helpers/settings.ts | 1 + packages/app/package.json | 1 + packages/app/src/app/_layout.tsx | 2 + packages/app/src/app/h/[serverId]/_layout.tsx | 1 + .../plugin/[pluginId]/[surfaceId].tsx | 5 + .../attachment-pill-content.test.tsx | 29 + .../attachments/attachment-pill-content.tsx | 8 + packages/app/src/attachments/types.ts | 2 + packages/app/src/components/left-sidebar.tsx | 3 + packages/app/src/composer/actions.test.ts | 29 + .../app/src/composer/attachments/submit.ts | 6 + packages/app/src/composer/index.tsx | 34 +- packages/app/src/data/query.ts | 8 +- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 48 ++ packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + .../app/src/i18n/resources/plugin-settings.ts | 380 +++++++++++++ packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/plugins/attachments/index.ts | 5 + .../app/src/plugins/attachments/model.test.ts | 69 +++ packages/app/src/plugins/attachments/model.ts | 70 +++ .../app/src/plugins/attachments/picker.tsx | 179 ++++++ packages/app/src/plugins/attachments/pill.tsx | 65 +++ .../app/src/plugins/catalog-sync.test.tsx | 91 +++ packages/app/src/plugins/catalog-sync.tsx | 62 ++ packages/app/src/plugins/evaluate.test.ts | 111 ++++ packages/app/src/plugins/evaluate.ts | 135 +++++ packages/app/src/plugins/icons.ts | 11 + packages/app/src/plugins/index.ts | 5 + packages/app/src/plugins/registry.test.ts | 140 +++++ packages/app/src/plugins/registry.ts | 139 +++++ packages/app/src/plugins/routes.ts | 17 + .../app/src/plugins/sidebar-groups.test.ts | 45 ++ packages/app/src/plugins/sidebar-groups.ts | 48 ++ packages/app/src/plugins/sidebar-items.tsx | 80 +++ .../plugins/surface-error-boundary.test.tsx | 79 +++ .../src/plugins/surface-error-boundary.tsx | 51 ++ packages/app/src/plugins/surface-screen.tsx | 270 +++++++++ packages/app/src/plugins/types.ts | 26 + packages/app/src/screens/settings-screen.tsx | 5 + .../settings/plugins-page-state.test.ts | 17 + .../screens/settings/plugins-page-state.ts | 15 + .../screens/settings/plugins-page.test.tsx | 173 ++++++ .../app/src/screens/settings/plugins-page.tsx | 359 ++++++++++++ .../app/src/stores/draft-store/state.test.ts | 28 + packages/app/src/stores/draft-store/state.ts | 2 + packages/app/src/utils/host-routes.ts | 1 + .../app/test-stubs/react-native-unistyles.ts | 19 + packages/cli/src/cli-surface.test.ts | 20 + packages/cli/src/cli.ts | 2 + .../cli/src/commands/plugin/index.test.ts | 25 + packages/cli/src/commands/plugin/index.ts | 121 ++++ .../cli/src/commands/plugin/scaffold.test.ts | 49 ++ packages/cli/src/commands/plugin/scaffold.ts | 167 ++++++ packages/cli/src/commands/plugin/shared.ts | 23 + .../cli/tests/e2e/plugin-lifecycle.test.ts | 53 ++ packages/client/src/daemon-client.ts | 90 +++ packages/plugin/package.json | 42 ++ packages/plugin/src/attachments.ts | 18 + packages/plugin/src/contracts.ts | 52 ++ packages/plugin/src/host.test.ts | 104 ++++ packages/plugin/src/host.ts | 50 ++ packages/plugin/src/index.ts | 24 + packages/plugin/src/rpc-context.tsx | 32 ++ packages/plugin/src/rpc.ts | 36 ++ packages/plugin/tsconfig.examples.json | 16 + packages/plugin/tsconfig.json | 21 + .../protocol/src/messages.attachments.test.ts | 44 ++ .../protocol/src/messages.plugins.test.ts | 83 +++ packages/protocol/src/messages.ts | 161 ++++++ packages/server/package.json | 3 + .../server/src/server/bootstrap.smoke.test.ts | 39 +- packages/server/src/server/bootstrap.ts | 17 +- .../server/src/server/config-plugins.test.ts | 34 ++ packages/server/src/server/config.ts | 2 + .../server/src/server/daemon-config-store.ts | 3 + .../src/server/hub/daemon-executions.test.ts | 4 +- .../hub/relationship-controller.test.ts | 6 +- .../hub/test-utils/relationship-harness.ts | 4 + .../server/src/server/persisted-config.ts | 9 +- .../server/src/server/plugins/compiler.ts | 206 +++++++ .../src/server/plugins/index.posix.test.ts | 370 ++++++++++++ packages/server/src/server/plugins/index.ts | 278 +++++++++ .../server/src/server/plugins/manifest.ts | 16 + .../server/plugins/plugin-process-protocol.ts | 10 + .../src/server/plugins/plugin-process.ts | 105 ++++ .../src/server/plugins/runtime.posix.test.ts | 319 +++++++++++ packages/server/src/server/plugins/runtime.ts | 269 +++++++++ packages/server/src/server/session.test.ts | 67 +++ packages/server/src/server/session.ts | 127 +++++ .../server/src/server/websocket-server.ts | 14 +- plugin-examples/linear/README.md | 33 ++ plugin-examples/linear/index.tsx | 46 ++ plugin-examples/linear/linear.test.ts | 135 +++++ plugin-examples/linear/linear.ts | 183 ++++++ plugin-examples/linear/paseo-plugin.json | 3 + plugin-examples/local-plugin/index.tsx | 76 +++ .../local-plugin/paseo-plugin.json | 3 + skills/paseo-plugin/SKILL.md | 56 ++ 115 files changed, 7481 insertions(+), 17 deletions(-) create mode 100644 docs/plugins.md create mode 100644 packages/app/e2e/browser/plugin-management.spec.ts create mode 100644 packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx create mode 100644 packages/app/src/attachments/attachment-pill-content.test.tsx create mode 100644 packages/app/src/i18n/resources/plugin-settings.ts create mode 100644 packages/app/src/plugins/attachments/index.ts create mode 100644 packages/app/src/plugins/attachments/model.test.ts create mode 100644 packages/app/src/plugins/attachments/model.ts create mode 100644 packages/app/src/plugins/attachments/picker.tsx create mode 100644 packages/app/src/plugins/attachments/pill.tsx create mode 100644 packages/app/src/plugins/catalog-sync.test.tsx create mode 100644 packages/app/src/plugins/catalog-sync.tsx create mode 100644 packages/app/src/plugins/evaluate.test.ts create mode 100644 packages/app/src/plugins/evaluate.ts create mode 100644 packages/app/src/plugins/icons.ts create mode 100644 packages/app/src/plugins/index.ts create mode 100644 packages/app/src/plugins/registry.test.ts create mode 100644 packages/app/src/plugins/registry.ts create mode 100644 packages/app/src/plugins/routes.ts create mode 100644 packages/app/src/plugins/sidebar-groups.test.ts create mode 100644 packages/app/src/plugins/sidebar-groups.ts create mode 100644 packages/app/src/plugins/sidebar-items.tsx create mode 100644 packages/app/src/plugins/surface-error-boundary.test.tsx create mode 100644 packages/app/src/plugins/surface-error-boundary.tsx create mode 100644 packages/app/src/plugins/surface-screen.tsx create mode 100644 packages/app/src/plugins/types.ts create mode 100644 packages/app/src/screens/settings/plugins-page-state.test.ts create mode 100644 packages/app/src/screens/settings/plugins-page-state.ts create mode 100644 packages/app/src/screens/settings/plugins-page.test.tsx create mode 100644 packages/app/src/screens/settings/plugins-page.tsx create mode 100644 packages/cli/src/commands/plugin/index.test.ts create mode 100644 packages/cli/src/commands/plugin/index.ts create mode 100644 packages/cli/src/commands/plugin/scaffold.test.ts create mode 100644 packages/cli/src/commands/plugin/scaffold.ts create mode 100644 packages/cli/src/commands/plugin/shared.ts create mode 100644 packages/cli/tests/e2e/plugin-lifecycle.test.ts create mode 100644 packages/plugin/package.json create mode 100644 packages/plugin/src/attachments.ts create mode 100644 packages/plugin/src/contracts.ts create mode 100644 packages/plugin/src/host.test.ts create mode 100644 packages/plugin/src/host.ts create mode 100644 packages/plugin/src/index.ts create mode 100644 packages/plugin/src/rpc-context.tsx create mode 100644 packages/plugin/src/rpc.ts create mode 100644 packages/plugin/tsconfig.examples.json create mode 100644 packages/plugin/tsconfig.json create mode 100644 packages/protocol/src/messages.plugins.test.ts create mode 100644 packages/server/src/server/config-plugins.test.ts create mode 100644 packages/server/src/server/plugins/compiler.ts create mode 100644 packages/server/src/server/plugins/index.posix.test.ts create mode 100644 packages/server/src/server/plugins/index.ts create mode 100644 packages/server/src/server/plugins/manifest.ts create mode 100644 packages/server/src/server/plugins/plugin-process-protocol.ts create mode 100644 packages/server/src/server/plugins/plugin-process.ts create mode 100644 packages/server/src/server/plugins/runtime.posix.test.ts create mode 100644 packages/server/src/server/plugins/runtime.ts create mode 100644 plugin-examples/linear/README.md create mode 100644 plugin-examples/linear/index.tsx create mode 100644 plugin-examples/linear/linear.test.ts create mode 100644 plugin-examples/linear/linear.ts create mode 100644 plugin-examples/linear/paseo-plugin.json create mode 100644 plugin-examples/local-plugin/index.tsx create mode 100644 plugin-examples/local-plugin/paseo-plugin.json create mode 100644 skills/paseo-plugin/SKILL.md 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.