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
This commit is contained in:
Mohamed Boudra
2026-08-14 17:12:49 +02:00
committed by GitHub
parent f0bd2c8483
commit 23853a1515
115 changed files with 7481 additions and 17 deletions
+1
View File
@@ -18,6 +18,7 @@ on:
- "packages/client/**" - "packages/client/**"
- "packages/expo-two-way-audio/**" - "packages/expo-two-way-audio/**"
- "packages/highlight/**" - "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**" - "packages/protocol/**"
- "packages/relay/**" - "packages/relay/**"
- "packages/server/**" - "packages/server/**"
+1
View File
@@ -10,6 +10,7 @@ on:
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
- "packages/highlight/**" - "packages/highlight/**"
- "packages/plugin/**"
- "packages/server/**" - "packages/server/**"
- "packages/relay/**" - "packages/relay/**"
- "packages/cli/**" - "packages/cli/**"
+1
View File
@@ -12,6 +12,7 @@ on:
- "packages/app/**" - "packages/app/**"
- "packages/expo-two-way-audio/**" - "packages/expo-two-way-audio/**"
- "packages/highlight/**" - "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**" - "packages/protocol/**"
- "packages/client/**" - "packages/client/**"
- "packages/server/**" - "packages/server/**"
+1
View File
@@ -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/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/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/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/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/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 | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+1
View File
@@ -23,6 +23,7 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --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=@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/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+173
View File
@@ -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 <id>` 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 <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
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.
+1 -1
View File
@@ -1 +1 @@
sha256-qPIKchcbGkJHFSqgeM/BUSmu7TiVfKy3IEgUcMNNJYc= sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
+528
View File
@@ -12,6 +12,7 @@
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
"packages/highlight", "packages/highlight",
"packages/plugin",
"packages/protocol", "packages/protocol",
"packages/client", "packages/client",
"packages/server", "packages/server",
@@ -9568,6 +9569,10 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -36119,6 +36124,7 @@
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1", "@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5", "@react-native/normalize-colors": "^0.81.5",
@@ -38582,6 +38588,22 @@
"vitest": "^4.1.6" "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": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.4.0", "version": "0.4.0",
@@ -38820,6 +38842,7 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0", "@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0", "@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0", "@getpaseo/protocol": "0.4.0",
@@ -38827,11 +38850,13 @@
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2", "express": "^4.18.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
@@ -38995,6 +39020,468 @@
"win32" "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": { "packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", "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" "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": { "packages/server/node_modules/isexe": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+7 -4
View File
@@ -23,6 +23,7 @@
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
"packages/highlight", "packages/highlight",
"packages/plugin",
"packages/protocol", "packages/protocol",
"packages/client", "packages/client",
"packages/server", "packages/server",
@@ -46,19 +47,21 @@
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --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": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay", "build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol", "build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client", "build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --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": "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:relay:clean && npm run build:client:clean", "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": "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: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: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": "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:client:clean && 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:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present",
@@ -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 <Text>${title} cleanup {cleanups}</Text>;
}
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
${
renderError
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
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<void> {
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<void> {
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<void> {
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<void> {
await page.getByRole("button", { name: title, exact: true }).click();
await expectContributionBody(page, expectedBody);
}
async function leavePluginSettings(page: Page): Promise<void> {
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<void> {
await leavePluginSettings(page);
await openContribution(page, title, expectedBody);
}
async function reloadActiveContribution(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
expectedBody: string,
): Promise<void> {
await client.reloadPlugin("e2e-plugin");
await expectContributionBody(page, expectedBody);
}
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
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 });
}
});
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
| "on" | "on"
| "patchDaemonConfig" | "patchDaemonConfig"
| "removeProject" | "removeProject"
| "removePlugin"
| "reloadPlugin"
>; >;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>; type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -41,6 +41,7 @@ type HostSection =
| "providers" | "providers"
| "usage" | "usage"
| "terminals" | "terminals"
| "plugins"
| "host"; | "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> { export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
+1
View File
@@ -49,6 +49,7 @@
"@gorhom/bottom-sheet": "^5.2.14", "@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1", "@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5", "@react-native/normalize-colors": "^0.81.5",
+2
View File
@@ -127,6 +127,7 @@ import {
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import { import {
ensureOsNotificationPermission, ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT, WEB_NOTIFICATION_CLICK_EVENT,
@@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return ( return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}> <SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} /> <LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider> </SessionProvider>
); );
} }
@@ -41,6 +41,7 @@ function KnownHostRoute() {
<Stack.Screen name="sessions" /> <Stack.Screen name="sessions" />
<Stack.Screen name="open-project" /> <Stack.Screen name="open-project" />
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
</ThemedStack> </ThemedStack>
); );
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -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");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import React from "react";
import { import {
CircleDot, CircleDot,
FileText, FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`, subtitle: `Issue #${attachment.number}`,
}; };
case "text": case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return { return {
icon: attachmentFileIcon, icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"), title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem, ForgeSearchItem,
UploadedFileAttachment, UploadedFileAttachment,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file"; export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata } | { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment } | { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment | WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem } | { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; 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 // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"]; type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -681,6 +682,7 @@ function MobileSidebar({
testID="sidebar-schedules" testID="sidebar-schedules"
variant="compact" variant="compact"
/> />
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View> </View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}> <WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable <Pressable
@@ -899,6 +901,7 @@ function DesktopSidebar({
testID="sidebar-schedules" testID="sidebar-schedules"
variant="compact" variant="compact"
/> />
<PluginSidebarItems />
</View> </View>
</View> </View>
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
}); });
expect(externalUrlCalls).toEqual([issueItem.url]); 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", () => { describe("toggleGithubAttachment", () => {
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem, buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments"; } from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file"; import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github"; export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue; continue;
} }
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) { if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) { if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot); images.push(attachment.attachment.screenshot);
+33 -1
View File
@@ -126,6 +126,7 @@ import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image"; import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image"; import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import { import {
appendWorkspaceFileAttachment, appendWorkspaceFileAttachment,
@@ -441,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove, onRemove,
}); });
} }
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return ( return (
<GithubAttachmentPill <GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`} key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -1162,6 +1177,14 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null); const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null); const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null); const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading); const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef( const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -1961,6 +1984,7 @@ export function Composer({
setIsGithubPickerOpen(true); setIsGithubPickerOpen(true);
}, },
}, },
...pluginAttachments.menuItems,
{ {
id: "file", id: "file",
label: t("composer.attachments.addFile"), label: t("composer.attachments.addFile"),
@@ -1971,7 +1995,14 @@ export function Composer({
}, },
); );
return items; return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]); }, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback( const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => { (item: ForgeSearchItem) => {
@@ -2231,6 +2262,7 @@ export function Composer({
emptyText={githubEmptyText} emptyText={githubEmptyText}
renderOption={renderGithubPickerOption} renderOption={renderGithubPickerOption}
/> />
{pluginAttachments.picker}
</View> </View>
</View> </View>
</View> </View>
+6 -2
View File
@@ -4,6 +4,7 @@ import {
useQueries, useQueries,
useQuery, useQuery,
type QueryKey, type QueryKey,
type QueryClient,
type UseQueryOptions, type UseQueryOptions,
type UseQueryResult, type UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
@@ -47,8 +48,11 @@ export function useFetchQuery<
TError = Error, TError = Error,
TData = TQueryFnData, TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey, TQueryKey extends QueryKey = QueryKey,
>(input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> { >(
return useQuery(fetchQueryOptions(input)); input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(fetchQueryOptions(input), queryClient);
} }
export function useFetchQueries<TData>( export function useFetchQueries<TData>(
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = { export const ar: TranslationResources = {
common: { common: {
@@ -1785,8 +1786,10 @@ export const ar: TranslationResources = {
providers: "مقدمي الخدمات", providers: "مقدمي الخدمات",
usage: "الاستخدام", usage: "الاستخدام",
terminals: "Terminals", terminals: "Terminals",
plugins: "Plugins",
host: "نظرة عامة", host: "نظرة عامة",
}, },
plugins: pluginSettings.ar,
metadataGeneration: { metadataGeneration: {
title: "إنشاء البيانات الوصفية", title: "إنشاء البيانات الوصفية",
description: description:
+48
View File
@@ -1796,8 +1796,56 @@ export const en = {
providers: "Providers", providers: "Providers",
usage: "Usage", usage: "Usage",
terminals: "Terminals", terminals: "Terminals",
plugins: "Plugins",
host: "Overview", 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: { metadataGeneration: {
title: "Metadata generation", title: "Metadata generation",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = { export const es: TranslationResources = {
common: { common: {
@@ -1828,8 +1829,10 @@ export const es: TranslationResources = {
providers: "Proveedores", providers: "Proveedores",
usage: "Uso", usage: "Uso",
terminals: "Terminals", terminals: "Terminals",
plugins: "Plugins",
host: "Resumen", host: "Resumen",
}, },
plugins: pluginSettings.es,
metadataGeneration: { metadataGeneration: {
title: "Generación de metadatos", title: "Generación de metadatos",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = { export const fr: TranslationResources = {
common: { common: {
@@ -1832,8 +1833,10 @@ export const fr: TranslationResources = {
providers: "Fournisseurs", providers: "Fournisseurs",
usage: "Utilisation", usage: "Utilisation",
terminals: "Terminals", terminals: "Terminals",
plugins: "Plugins",
host: "Aperçu", host: "Aperçu",
}, },
plugins: pluginSettings.fr,
metadataGeneration: { metadataGeneration: {
title: "Génération de métadonnées", title: "Génération de métadonnées",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = { export const ja: TranslationResources = {
common: { common: {
@@ -1801,8 +1802,10 @@ export const ja: TranslationResources = {
providers: "プロバイダー", providers: "プロバイダー",
usage: "使用状況", usage: "使用状況",
terminals: "ターミナル", terminals: "ターミナル",
plugins: "プラグイン",
host: "概要", host: "概要",
}, },
plugins: pluginSettings.ja,
metadataGeneration: { metadataGeneration: {
title: "メタデータ生成", title: "メタデータ生成",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = { export const ko: TranslationResources = {
common: { common: {
@@ -1795,8 +1796,10 @@ export const ko: TranslationResources = {
providers: "프로바이더", providers: "프로바이더",
usage: "사용량", usage: "사용량",
terminals: "터미널", terminals: "터미널",
plugins: "플러그인",
host: "개요", host: "개요",
}, },
plugins: pluginSettings.ko,
metadataGeneration: { metadataGeneration: {
title: "메타데이터 생성", title: "메타데이터 생성",
description: description:
@@ -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;
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = { export const ptBR: TranslationResources = {
common: { common: {
@@ -1814,8 +1815,10 @@ export const ptBR: TranslationResources = {
providers: "Provedores", providers: "Provedores",
usage: "Uso", usage: "Uso",
terminals: "Terminais", terminals: "Terminais",
plugins: "Plugins",
host: "Visão geral", host: "Visão geral",
}, },
plugins: pluginSettings["pt-BR"],
metadataGeneration: { metadataGeneration: {
title: "Geração de metadados", title: "Geração de metadados",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = { export const ru: TranslationResources = {
common: { common: {
@@ -1819,8 +1820,10 @@ export const ru: TranslationResources = {
providers: "Провайдеры", providers: "Провайдеры",
usage: "Использование", usage: "Использование",
terminals: "Terminals", terminals: "Terminals",
plugins: "Plugins",
host: "Обзор", host: "Обзор",
}, },
plugins: pluginSettings.ru,
metadataGeneration: { metadataGeneration: {
title: "Создание метаданных", title: "Создание метаданных",
description: description:
+3
View File
@@ -1,4 +1,5 @@
import type { TranslationResources } from "./en"; import type { TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = { export const zhCN: TranslationResources = {
common: { common: {
@@ -1765,8 +1766,10 @@ export const zhCN: TranslationResources = {
providers: "Providers", providers: "Providers",
usage: "使用情况", usage: "使用情况",
terminals: "Terminals", terminals: "Terminals",
plugins: "插件",
host: "概览", host: "概览",
}, },
plugins: pluginSettings["zh-CN"],
metadataGeneration: { metadataGeneration: {
title: "元数据生成", title: "元数据生成",
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型", description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
@@ -0,0 +1,5 @@
export {
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
type PluginResourceComposerAttachment,
} from "./model";
@@ -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],
});
});
});
@@ -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,
},
};
}
@@ -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 <Icon size={ICON_SIZE.md} color={color} />;
}
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<View | null>;
}
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<string | null>(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: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
[sources],
);
if (!active) return { menuItems, picker: null };
return {
menuItems,
picker: (
<Combobox
options={options}
value=""
onSelect={handleSelect}
searchable
searchPlaceholder={active.source.searchPlaceholder}
title={active.source.pickerTitle}
open
onOpenChange={handleOpenChange}
onSearchQueryChange={setQuery}
desktopPlacement="top-start"
anchorRef={input.anchorRef}
emptyText={searchEmptyText(search.error, search.isFetching)}
/>
),
};
}
@@ -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<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
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(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
);
return (
<AttachmentPill
testID="composer-plugin-resource-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
disabled={disabled}
>
<AttachmentLabel
icon={icon}
title={attachment.item.title}
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
/>
</AttachmentPill>
);
}
@@ -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<void> {
await act(async () => {
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
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);
});
});
+62
View File
@@ -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;
}
+111
View File
@@ -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");
});
});
+135
View File
@@ -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<string>();
const sidebarItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
if (typeof Component !== "function")
throw new Error(`Surface ${normalizedId} is not a component`);
surfaceIds.add(normalizedId);
collector.surfaces.push({ id: normalizedId, Component });
},
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (sidebarItemIds.has(normalizedId))
throw new Error(`Duplicate sidebar item: ${normalizedId}`);
if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`);
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
sidebarItemIds.add(normalizedId);
collector.sidebarItems.push({
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
});
},
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,
};
}
+11
View File
@@ -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;
}
+5
View File
@@ -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";
+140
View File
@@ -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<void>((resolve) => {
observeAbort = resolve;
});
void plugin.queryClient
.fetchQuery({
queryKey: ["plugin-attachment-search", "example"],
queryFn: ({ signal }) =>
new Promise<never>((_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([]);
});
});
+139
View File
@@ -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<string, InstalledPlugin[]>();
private readonly listeners = new Set<() => void>();
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
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);
}
+17
View File
@@ -0,0 +1,17 @@
export function buildPluginSurfaceRoute(
serverId: string,
pluginId: string,
contributionId: string,
): `/h/${string}/plugin/${string}/${string}` {
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`;
}
export function hostIdFromPathname(pathname: string): string | null {
const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1];
if (!encoded) return null;
try {
return decodeURIComponent(encoded);
} catch {
return null;
}
}
@@ -0,0 +1,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"]);
});
});
@@ -0,0 +1,48 @@
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
const preferredHosts = new Map<string, string>();
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
item: PluginSidebarContribution;
}
export interface PluginSidebarGroup {
key: string;
pluginId: string;
contributionId: string;
title: string;
icon: string;
targets: PluginSidebarTarget[];
}
export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] {
const groups = new Map<string, PluginSidebarGroup>();
for (const plugin of plugins) {
for (const item of plugin.sidebarItems) {
const key = `${plugin.id}/${item.id}`;
const existing = groups.get(key);
if (existing) {
existing.targets.push({ plugin, item });
} else {
groups.set(key, {
key,
pluginId: plugin.id,
contributionId: item.id,
title: item.title,
icon: item.icon,
targets: [{ plugin, item }],
});
}
}
}
return [...groups.values()];
}
export function getPreferredPluginContributionHost(groupKey: string): string | null {
return preferredHosts.get(groupKey) ?? null;
}
export function rememberPluginContributionHost(groupKey: string, serverId: string): void {
preferredHosts.set(groupKey, serverId);
}
@@ -0,0 +1,80 @@
import { router, usePathname } from "expo-router";
import { useCallback, useMemo } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugins } from "./registry";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
import {
groupPluginSidebarContributions,
getPreferredPluginContributionHost,
rememberPluginContributionHost,
type PluginSidebarGroup,
type PluginSidebarTarget,
} from "./sidebar-groups";
function selectTarget(
group: PluginSidebarGroup,
currentHostId: string | null,
): PluginSidebarTarget {
const current = group.targets.find((target) => target.plugin.serverId === currentHostId);
if (current) return current;
const rememberedHostId = getPreferredPluginContributionHost(group.key);
const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId);
return remembered ?? group.targets[0];
}
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const plugins = useInstalledPlugins();
const pathname = usePathname();
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
const currentHostId = hostIdFromPathname(pathname);
return groups.map((group) => (
<PluginSidebarItemRow
key={group.key}
group={group}
currentHostId={currentHostId}
pathname={pathname}
onBeforeNavigate={onBeforeNavigate}
/>
));
}
function PluginSidebarItemRow({
group,
currentHostId,
pathname,
onBeforeNavigate,
}: {
group: PluginSidebarGroup;
currentHostId: string | null;
pathname: string;
onBeforeNavigate?: () => void;
}) {
const target = selectTarget(group, currentHostId);
const route = buildPluginSurfaceRoute(
target.plugin.serverId,
group.pluginId,
group.contributionId,
);
const isActive = group.targets.some(
(candidate) =>
pathname ===
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId),
);
const navigate = useCallback(() => {
rememberPluginContributionHost(group.key, target.plugin.serverId);
onBeforeNavigate?.();
router.push(route);
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={resolvePluginIcon(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
variant="compact"
/>
);
}
@@ -0,0 +1,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 <Text>Old surface mounted</Text>;
}
function CorrectedSurface() {
return <Text>Corrected surface</Text>;
}
const oldInstallation = {};
const correctedInstallation = {};
describe("SurfaceErrorBoundary", () => {
const roots: Array<ReturnType<typeof createRoot>> = [];
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(
<SurfaceErrorBoundary
key="host/plugin/surface"
installation={oldInstallation}
Surface={FailingSurface}
>
<FailingSurface />
</SurfaceErrorBoundary>,
);
});
expect(container.textContent).toBe("Plugin failed: old render exploded");
expect(oldSurfaceCleanups).toBe(1);
await act(async () => {
root.render(
<SurfaceErrorBoundary
key="host/plugin/surface"
installation={correctedInstallation}
Surface={CorrectedSurface}
>
<CorrectedSurface />
</SurfaceErrorBoundary>,
);
});
expect(container.textContent).toBe("Corrected surface");
expect(oldSurfaceCleanups).toBe(1);
});
});
@@ -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 <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
}
return this.props.children;
}
}
const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
}));
+270
View File
@@ -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<string, unknown> = {};
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
});
const ThemedX = withUnistyles(X);
const ThemedChevronDown = withUnistyles(ChevronDown);
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
function SurfaceRenderer({
Surface,
invoke,
plugin,
layout,
host,
themeDto = EMPTY_THEME_DTO,
}: {
Surface: ComponentType<PluginSurfaceProps>;
invoke(method: string, input: unknown): Promise<unknown>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>;
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PluginRpcProvider invoke={invoke}>
<Surface theme={themeDto} host={host} layout={layout} />
</PluginRpcProvider>
</QueryClientProvider>
);
}
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginHostSwitcher({
serverId,
pluginId,
contributionId,
serverIds,
}: {
serverId: string;
pluginId: string;
contributionId: string;
serverIds: string[];
}) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const [open, setOpen] = useState(false);
const anchorRef = useRef<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const selectHost = useCallback(
(nextServerId: string) => {
rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId));
},
[contributionId, pluginId],
);
const openPicker = useCallback(() => setOpen(true), []);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostPicker
hosts={hosts}
value={serverId}
onSelect={selectHost}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title="Choose plugin host"
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Plugin host: ${selectedLabel}`}
testID="plugin-host-switcher"
onPress={openPicker}
style={styles.hostSwitcher}
>
<Text numberOfLines={1} style={styles.hostSwitcherText}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
export function PluginSurfaceScreen() {
const params = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
surfaceId?: string | string[];
}>();
const serverId = routeParam(params.serverId);
const pluginId = routeParam(params.pluginId);
const contributionId = routeParam(params.surfaceId);
const plugin = useInstalledPlugin(serverId, pluginId);
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const compact = useIsCompactFormFactor();
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo(
() =>
installations
.filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId))
.map((entry) => entry.serverId),
[contributionId, installations],
);
const title = sidebarItem?.title ?? (pluginId || "Plugin");
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const invoke = useCallback(
async (method: string, input: unknown) => {
if (!client) throw new Error("Plugin host is offline");
return client.invokePluginRpc(pluginId, method, input);
},
[client, pluginId],
);
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
}, [serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const headerLeft = useMemo(
() => (
<>
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle>{title}</ScreenTitle>
</>
),
[Icon, title],
);
const headerRight = useMemo(
() => (
<>
<PluginHostSwitcher
serverId={serverId}
pluginId={pluginId}
contributionId={contributionId}
serverIds={contributionServerIds}
/>
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
testID="plugin-surface-close"
tooltipKeys={EMPTY_SHORTCUT_KEYS}
tooltipLabel="Close"
tooltipSide="bottom"
>
<ThemedX size={18} uniProps={mutedColorMapping} />
</HeaderToggleButton>
</>
),
[close, contributionId, contributionServerIds, pluginId, serverId],
);
return (
<View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface ? (
<SurfaceErrorBoundary
key={`${serverId}/${pluginId}/${contributionId}`}
installation={plugin}
Surface={surface.Component}
>
<ThemedSurfaceRenderer
Surface={surface.Component}
invoke={invoke}
plugin={plugin}
host={host}
layout={layout}
uniProps={pluginThemeMapping}
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
screen: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
hostSwitcher: {
maxWidth: 180,
minHeight: 32,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
hostSwitcherText: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
+26
View File
@@ -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,
};
@@ -37,6 +37,7 @@ import {
Code2, Code2,
Smartphone, Smartphone,
Sparkles, Sparkles,
Blocks,
} from "lucide-react-native"; } from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -108,6 +109,7 @@ import {
HostWorkspacesPage, HostWorkspacesPage,
HostTerminalsPage, HostTerminalsPage,
} from "@/screens/settings/host-page"; } from "@/screens/settings/host-page";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-screen"; import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-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: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks },
]; ];
function renderHostSettingsContent( function renderHostSettingsContent(
@@ -216,6 +219,8 @@ function renderHostSettingsContent(
return <HostUsagePage serverId={view.serverId} />; return <HostUsagePage serverId={view.serverId} />;
case "terminals": case "terminals":
return <HostTerminalsPage serverId={view.serverId} />; return <HostTerminalsPage serverId={view.serverId} />;
case "plugins":
return <HostPluginsPage serverId={view.serverId} />;
case "host": case "host":
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />; return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
} }
@@ -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,
);
});
});
@@ -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";
}
@@ -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<typeof import("react")>("react");
const { ScrollView, TextInput } =
await vi.importActual<typeof import("react-native")>("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<T>(): Promise<T> {
return new Promise<T>(() => 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<PluginListItem[]> => []),
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<typeof createClient>;
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 = (
<QueryClientProvider client={queryClient}>
<HostPluginsPage serverId="host-a" />
</QueryClientProvider>
);
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<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<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);
});
});
@@ -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 (
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<View style={settingsStyles.rowContent}>
<View style={styles.pluginTitle}>
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
<StatusBadge label={statusLabel} variant={badgeVariant} />
</View>
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
{clientError || plugin.error ? (
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
) : null}
</View>
<View style={styles.actions}>
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
: t("settings.plugins.actions.reload")}
</Button>
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
{toggleLabel}
</Button>
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
{pendingAction === "remove"
? t("settings.plugins.actions.removing")
: t("settings.plugins.actions.remove")}
</Button>
</View>
</View>
);
}
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<string>;
}) => 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 (
<Alert
variant="warning"
title={t("settings.plugins.states.offlineTitle")}
description={t("settings.plugins.states.offlineDescription")}
/>
);
}
if (pageState === "unsupported") {
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
}
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
if (pageState === "error") {
catalogContent = (
<Alert
variant="error"
title={t("settings.plugins.states.errorTitle")}
description={errorMessage(plugins.error)}
>
<Button variant="outline" size="sm" onPress={retry}>
{t("settings.plugins.states.retry")}
</Button>
</Alert>
);
} else if (pageState !== "loading") {
catalogContent = (
<View style={settingsStyles.card}>
{plugins.data?.length ? (
plugins.data.map((plugin) => {
const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id);
const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id;
return (
<PluginRow
key={plugin.id}
plugin={plugin}
clientError={clientError}
pending={mutation.isPending}
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
onAction={action}
/>
);
})
) : (
<View style={styles.empty}>
<Text style={settingsStyles.rowHint}>{t("settings.plugins.states.empty")}</Text>
</View>
)}
</View>
);
}
return (
<View>
<SettingsSection title={t("settings.plugins.title")}>
<Alert
variant="warning"
title={t("settings.plugins.trustedTitle")}
description={t("settings.plugins.trustedDescription")}
/>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.plugins.globalTitle")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.plugins.globalHint")}</Text>
</View>
<Switch
value={config?.pluginsEnabled === true}
onValueChange={toggleGlobal}
disabled={mutation.isPending}
accessibilityLabel={t("settings.plugins.globalTitle")}
/>
</View>
</View>
<View style={[settingsStyles.card, styles.install]}>
<Field label={t("settings.plugins.directoryLabel")}>
<FormTextInput
initialValue=""
resetKey={directoryResetKey}
onChangeText={setDirectory}
onBlur={prefillManifestId}
placeholder={t("settings.plugins.directoryPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!mutation.isPending}
accessibilityLabel={t("settings.plugins.directoryLabel")}
/>
</Field>
<Field label={t("settings.plugins.idLabel")} hint={t("settings.plugins.idHint")}>
<FormTextInput
initialValue={pluginId}
resetKey={pluginIdResetKey}
onChangeText={setPluginId}
placeholder={t("settings.plugins.idPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!mutation.isPending}
accessibilityLabel={t("settings.plugins.idLabel")}
/>
</Field>
<Button onPress={install} disabled={!directory.trim() || mutation.isPending}>
{mutation.isPending && mutation.variables?.action === "install"
? t("settings.plugins.installing")
: t("settings.plugins.install")}
</Button>
</View>
{feedback ? (
<Alert
variant={feedback.kind}
title={feedback.message}
testID="plugin-management-feedback"
/>
) : null}
{catalogContent}
</SettingsSection>
</View>
);
}
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" },
}));
@@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => {
}); });
describe("draft-store normalization", () => { 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", () => { it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = { const attachment = {
kind: "file" as const, kind: "file" as const,
@@ -3,6 +3,7 @@ import {
type AttachmentMetadata, type AttachmentMetadata,
type UserComposerAttachment, type UserComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments";
import { z } from "zod"; import { z } from "zod";
export const DRAFT_STORE_VERSION = 5; export const DRAFT_STORE_VERSION = 5;
@@ -95,6 +96,7 @@ export const UserComposerAttachmentSchema: z.ZodType<UserComposerAttachment> = z
z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }),
z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }),
z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }),
PluginResourceComposerAttachmentSchema,
z.strictObject({ z.strictObject({
kind: z.literal("github_pr"), kind: z.literal("github_pr"),
item: ChangeRequestItemSchema, item: ChangeRequestItemSchema,
+1
View File
@@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [
"providers", "providers",
"usage", "usage",
"terminals", "terminals",
"plugins",
"host", "host",
] as const; ] as const;
@@ -7,14 +7,28 @@ const testTheme = {
statusDanger: "#b91c1c", statusDanger: "#b91c1c",
statusWarning: "#d97706", statusWarning: "#d97706",
statusMerged: "#7c3aed", statusMerged: "#7c3aed",
accent: "#2563eb",
accentForeground: "#ffffff",
destructive: "#dc2626",
destructiveForeground: "#ffffff",
surface1: "#fafafa", surface1: "#fafafa",
surface2: "#f4f4f5", surface2: "#f4f4f5",
surface3: "#e4e4e7",
border: "#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], spacing: [0, 4, 8, 12, 16, 20, 24, 28, 32],
fontSize: { fontSize: {
xs: 12, xs: 12,
sm: 14, sm: 14,
base: 16,
}, },
fontWeight: { fontWeight: {
normal: "400", normal: "400",
@@ -23,7 +37,12 @@ const testTheme = {
borderRadius: { borderRadius: {
base: 4, base: 4,
md: 6, md: 6,
lg: 8,
xl: 12,
full: 9999,
}, },
iconSize: { sm: 16 },
opacity: { 50: 0.5 },
}; };
type StyleFactory<T> = (theme: typeof testTheme) => T; type StyleFactory<T> = (theme: typeof testTheme) => T;
+20
View File
@@ -55,4 +55,24 @@ describe("canonical CLI surface", () => {
expect(open?.helpInformation()).toContain("<agent-id>"); expect(open?.helpInformation()).toContain("<agent-id>");
expect(open?.helpInformation()).toContain("--server <server-id>"); expect(open?.helpInformation()).toContain("--server <server-id>");
}); });
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 <id>");
expect(
plugin?.commands.find((command) => command.name() === "install")?.helpInformation(),
).toContain("--id <id>");
});
}); });
+2
View File
@@ -3,6 +3,7 @@ import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js"; import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js"; import { createProviderCommand } from "./commands/provider/index.js";
import { createPluginCommand } from "./commands/plugin/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js"; import { createSpeechCommand } from "./commands/speech/index.js";
import { createScriptCommand } from "./commands/script/index.js"; import { createScriptCommand } from "./commands/script/index.js";
@@ -182,6 +183,7 @@ export function createCli(): Command {
// Provider commands // Provider commands
program.addCommand(createProviderCommand()); program.addCommand(createProviderCommand());
program.addCommand(createPluginCommand());
// Speech model commands // Speech model commands
program.addCommand(createSpeechCommand()); program.addCommand(createSpeechCommand());
@@ -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);
});
});
+121
View File
@@ -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<PluginListItem> = {
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<PluginScaffold> = {
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<SingleResult<PluginScaffold>> {
return {
type: "single",
data: await scaffoldPluginDirectory(directory, options.id),
schema: scaffoldSchema,
};
}
export async function runPluginListCommand(
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginListItem>> {
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<SingleResult<PluginListItem>> {
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<SingleResult<PluginListItem>> {
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<SingleResult<PluginListItem>> {
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("<directory>")
.option("--id <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("<directory>", "Host filesystem directory")
.option("--id <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("<id>"),
).action(
withOutput((id: string, options: PluginOptions, _command: Command) =>
act(action, id, options),
),
);
}
addJsonAndDaemonHostOptions(
plugin.command("remove").description("Remove plugin configuration").argument("<id>"),
).action(withOutput(remove));
return plugin;
}
@@ -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");
});
});
@@ -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<string, unknown>;
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<PluginSurfaceProps>;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export type PluginCleanup = () => void;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
`;
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 <Text>Hello from my plugin</Text>;
}
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<PluginScaffold> {
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<string, string>([
["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 };
}
@@ -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<T>(
host: string | undefined,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
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);
}
}
@@ -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<void> {
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();
+90
View File
@@ -101,6 +101,7 @@ import type {
PaseoConfigRevision, PaseoConfigRevision,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceRecoveryState, WorkspaceRecoveryState,
PluginListItem,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -4722,6 +4723,95 @@ export class DaemonClient {
}); });
} }
async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.catalog.get.request", requestId },
responseType: "plugin.catalog.get.response",
});
return payload.plugins;
}
async listPlugins(): Promise<PluginListItem[]> {
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<PluginListItem> {
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<PluginListItem> {
return this.managePlugin("reload", pluginId);
}
async enablePlugin(pluginId: string): Promise<PluginListItem> {
return this.managePlugin("enable", pluginId);
}
async disablePlugin(pluginId: string): Promise<PluginListItem> {
return this.managePlugin("disable", pluginId);
}
async removePlugin(pluginId: string): Promise<void> {
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<PluginListItem> {
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<unknown> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
});
return payload.output;
}
async respondToPermissionAndWait( async respondToPermissionAndWait(
agentId: string, agentId: string,
requestId: string, requestId: string,
+42
View File
@@ -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"
}
}
+18
View File
@@ -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<typeof PluginAttachmentItemSchema>;
export type PluginAttachmentSearchPayload = z.infer<typeof PluginAttachmentSearchPayloadSchema>;
+52
View File
@@ -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<string, unknown>;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export type PluginCleanup = () => void;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
+104
View File
@@ -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);
});
});
+50
View File
@@ -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<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export interface PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export function createPluginContext(
collector: PluginCollector,
): Pick<PluginContext, "addSurface" | "addSidebarItem" | "addAttachmentSource"> {
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<unknown>,
query: string,
) {
const output = await callPluginRpc(source.search, invoke, { query });
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export { callPluginRpc, PluginRpcProvider };
+24
View File
@@ -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 extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
+32
View File
@@ -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<unknown>;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
export function PluginRpcProvider({
children,
invoke,
}: {
children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>;
}) {
const value = useMemo(() => ({ invoke }), [invoke]);
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
const context = useContext(PluginRpcContext);
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
[context, contract],
);
}
+36
View File
@@ -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<InputSchema extends ZodType, OutputSchema extends ZodType> {
name: string;
input: InputSchema;
output: OutputSchema;
}
const RPC_NAME = /^[a-z][a-z0-9._-]*$/;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
definition: RpcDefinition<InputSchema, OutputSchema>,
): PluginRpcContract<InputSchema, OutputSchema> {
const name = definition.name.trim();
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`);
return { ...definition, name };
}
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
invoke: (method: string, input: unknown) => Promise<unknown>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>> {
const parsedInput = await contract.input.parseAsync(input);
const output = await invoke(contract.name, parsedInput);
return contract.output.parseAsync(output);
}
+16
View File
@@ -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"]
}
+21
View File
@@ -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"]
}
@@ -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", () => { it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({ const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
@@ -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();
});
});
+161
View File
@@ -173,6 +173,21 @@ const MutableRelayConfigSchema = z
enabled: z.boolean(), enabled: z.boolean(),
}) })
.passthrough(); .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<typeof PluginSourceSchema>;
export const MutableDaemonConfigSchema = z export const MutableDaemonConfigSchema = z
.object({ .object({
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. // 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(""), appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
}) })
.passthrough(); .passthrough();
@@ -208,6 +225,8 @@ export const MutableDaemonConfigPatchSchema = z
appendSystemPrompt: z.string().optional(), appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
}) })
.partial() .partial()
.passthrough(); .passthrough();
@@ -1003,6 +1022,16 @@ export const ForgeIssueAttachmentSchema = z.object({
projectPath: z.string().optional(), 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 export const TextAttachmentSchema = z
.object({ .object({
type: z.literal("text"), type: z.literal("text"),
@@ -1010,6 +1039,7 @@ export const TextAttachmentSchema = z
contextKind: z.string().optional(), contextKind: z.string().optional(),
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
text: z.string(), text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
}) })
.transform(({ contextKind, ...attachment }) => ({ .transform(({ contextKind, ...attachment }) => ({
...attachment, ...attachment,
@@ -1291,6 +1321,46 @@ export const DiagnosticsRequestSchema = z.object({
requestId: z.string(), 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<const Type extends string>(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({ export const GetDaemonConfigRequestMessageSchema = z.object({
type: z.literal("get_daemon_config_request"), type: z.literal("get_daemon_config_request"),
requestId: z.string(), requestId: z.string(),
@@ -2760,6 +2830,15 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema, HubManagementDaemonDisconnectRequestSchema,
DiagnosticsRequestSchema, DiagnosticsRequestSchema,
PluginCatalogGetRequestSchema,
PluginListRequestSchema,
PluginDirectoryInstallRequestSchema,
PluginDirectoryInspectRequestSchema,
PluginReloadRequestSchema,
PluginEnableRequestSchema,
PluginDisableRequestSchema,
PluginRemoveRequestSchema,
PluginRpcInvokeRequestSchema,
GetDaemonConfigRequestMessageSchema, GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema,
@@ -3082,6 +3161,10 @@ export const ServerInfoStatusPayloadSchema = z
relayConfig: z.boolean().optional(), relayConfig: z.boolean().optional(),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
pushTokenRevocation: z.boolean().optional(), 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. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(), "terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -3268,6 +3351,11 @@ export const DaemonConfigChangedStatusPayloadSchema = z
}) })
.passthrough(); .passthrough();
export const PluginCatalogChangedStatusPayloadSchema = z.object({
status: z.literal("plugin_catalog_changed"),
pluginId: PluginIdSchema,
});
export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
AgentCreatedStatusPayloadSchema, AgentCreatedStatusPayloadSchema,
AgentCreateFailedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema,
@@ -3276,6 +3364,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
ShutdownRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema,
RestartRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema,
DaemonConfigChangedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema,
PluginCatalogChangedStatusPayloadSchema,
]); ]);
export type KnownStatusPayload = z.infer<typeof KnownStatusPayloadSchema>; export type KnownStatusPayload = z.infer<typeof KnownStatusPayloadSchema>;
@@ -5707,6 +5796,69 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>; export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
export const PluginCatalogGetResponseSchema = z.object({
type: z.literal("plugin.catalog.get.response"),
payload: z.object({
requestId: z.string(),
plugins: z.array(
z.object({
id: PluginIdSchema,
clientBundle: z.string(),
}),
),
}),
});
export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]);
export type PluginStatus = z.infer<typeof PluginStatusSchema>;
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
path: z.string(),
enabled: z.boolean(),
status: PluginStatusSchema,
error: z.string().optional(),
});
export type PluginListItem = z.infer<typeof PluginListItemSchema>;
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<const Type extends string>(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", [ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema, HubExecutionAgentCreateResponseSchema,
HubExecutionAgentValidateResponseSchema, HubExecutionAgentValidateResponseSchema,
@@ -5714,6 +5866,15 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentUpdateSchema, HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema, HubExecutionAgentStreamSchema,
BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteRequestSchema,
PluginCatalogGetResponseSchema,
PluginListResponseSchema,
PluginDirectoryInstallResponseSchema,
PluginDirectoryInspectResponseSchema,
PluginReloadResponseSchema,
PluginEnableResponseSchema,
PluginDisableResponseSchema,
PluginRemoveResponseSchema,
PluginRpcInvokeResponseSchema,
ActivityLogMessageSchema, ActivityLogMessageSchema,
AssistantChunkMessageSchema, AssistantChunkMessageSchema,
AudioOutputMessageSchema, AudioOutputMessageSchema,
+3
View File
@@ -71,6 +71,7 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0", "@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0", "@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0", "@getpaseo/protocol": "0.4.0",
@@ -78,11 +79,13 @@
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0", "@xterm/headless": "^6.0.0",
"ai": "5.0.78", "ai": "5.0.78",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2", "express": "^4.18.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
@@ -1,7 +1,7 @@
import os from "node:os"; import os from "node:os";
import http from "node:http"; import http from "node:http";
import path from "node:path"; 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 pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
@@ -42,6 +42,16 @@ type WebSocketProbeResult =
| { status: "connected" } | { status: "connected" }
| { status: "rejected"; statusCode: number | null }; | { 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", () => { describe("paseo daemon bootstrap", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -398,7 +408,24 @@ describe("paseo daemon bootstrap", () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo"); const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); 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 }); 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 = { const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${mainPort}`, listen: `127.0.0.1:${mainPort}`,
paseoHome, paseoHome,
@@ -414,12 +441,22 @@ describe("paseo daemon bootstrap", () => {
openai: undefined, openai: undefined,
speech: undefined, speech: undefined,
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` }, 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" })); const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try { try {
await expect(daemon.start()).rejects.toThrow(); await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).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 { } finally {
await daemon.stop().catch(() => undefined); await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedMain.close(() => resolve())); await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
+16 -1
View File
@@ -168,7 +168,12 @@ import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js"; import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js"; import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.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 { import type {
AgentProviderRuntimeSettingsMap, AgentProviderRuntimeSettingsMap,
ProviderOverride, ProviderOverride,
@@ -216,6 +221,7 @@ import {
type HubRelationshipRemote, type HubRelationshipRemote,
} from "./hub/relationship-remote.js"; } from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js"; import { DaemonExecutions } from "./hub/daemon-executions.js";
import { PluginService } from "./plugins/index.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]"; const REDACTED_LOG_VALUE = "[redacted]";
@@ -398,6 +404,8 @@ export interface PaseoDaemonConfig {
appendSystemPrompt?: string; appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[]; terminalProfiles?: TerminalProfile[];
agentProfiles?: AgentProfile[]; agentProfiles?: AgentProfile[];
pluginsEnabled?: boolean;
plugins?: Record<string, PluginSource>;
staticDir: string; staticDir: string;
mcpDebug: boolean; mcpDebug: boolean;
isDev?: boolean; isDev?: boolean;
@@ -532,6 +540,8 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "", appendSystemPrompt: config.appendSystemPrompt ?? "",
pluginsEnabled: config.pluginsEnabled ?? false,
plugins: config.plugins ?? {},
}; };
if (config.terminalProfiles !== undefined) { if (config.terminalProfiles !== undefined) {
@@ -563,6 +573,7 @@ export async function createPaseoDaemon(
); );
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({}); const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore);
const serverId = getOrCreateServerId(config.paseoHome, { logger }); const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -1431,6 +1442,7 @@ export async function createPaseoDaemon(
const start = async () => { const start = async () => {
let mainStarted = false; let mainStarted = false;
try { try {
await pluginRuntime.start();
if (serviceProxyListenTarget) { if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({ const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget, listenTarget: serviceProxyListenTarget,
@@ -1560,6 +1572,7 @@ export async function createPaseoDaemon(
browserToolsBroker, browserToolsBroker,
hubRelationships, hubRelationships,
workspaceSetupRuntime, workspaceSetupRuntime,
pluginRuntime,
); );
relayRuntime = createRelayRuntime({ relayRuntime = createRelayRuntime({
config: { config: {
@@ -1603,6 +1616,7 @@ export async function createPaseoDaemon(
speechService.start(); speechService.start();
scriptHealthMonitor.start(); scriptHealthMonitor.start();
} catch (error) { } catch (error) {
await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined);
if (mainStarted) { if (mainStarted) {
httpServer.closeAllConnections(); httpServer.closeAllConnections();
@@ -1613,6 +1627,7 @@ export async function createPaseoDaemon(
}; };
const stop = async () => { const stop = async () => {
await pluginRuntime.stopAllPlugins();
await hubRelationships.stop(); await hubRelationships.stop();
workspaceReconciliation.dispose(); workspaceReconciliation.dispose();
scriptHealthMonitor.stop(); scriptHealthMonitor.stop();
@@ -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<string> {
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);
});
});
+2
View File
@@ -534,6 +534,8 @@ export function loadConfig(
appendSystemPrompt, appendSystemPrompt,
terminalProfiles, terminalProfiles,
agentProfiles, agentProfiles,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
mcpDebug: env.MCP_DEBUG === "1", mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development", isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"), agentStoragePath: path.join(paseoHome, "agents"),
@@ -198,6 +198,7 @@ export class DaemonConfigStore {
const { removeProviders = [], ...configPatch } = parsedPatch; const { removeProviders = [], ...configPatch } = parsedPatch;
const removedProviders = Array.from(new Set(removeProviders)); const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch); const merged = deepMerge(this.current, configPatch);
if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins;
const next = MutableDaemonConfigSchema.parse( const next = MutableDaemonConfigSchema.parse(
omitMetadataGenerationProvidersFromConfig( omitMetadataGenerationProvidersFromConfig(
omitProvidersFromConfig(merged, removedProviders), omitProvidersFromConfig(merged, removedProviders),
@@ -335,6 +336,8 @@ function mergeMutableConfigIntoPersistedConfig(params: {
return { return {
...persisted, ...persisted,
pluginsEnabled: mutable.pluginsEnabled,
plugins: mutable.plugins,
daemon: { daemon: {
...persisted.daemon, ...persisted.daemon,
...(persistRelayEnabled ...(persistRelayEnabled
@@ -187,7 +187,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
type: "hub.execution.agent.create.response", type: "hub.execution.agent.create.response",
payload: { success: true, executionId: "replayed-execution" }, payload: { success: true, executionId: "replayed-execution" },
}); });
const providerCreations = hub.providerCreations(); const executionProviderCreations = hub.executionProviderCreations();
hub.beginOwnedCreate("replay-create", "replayed-execution", { hub.beginOwnedCreate("replay-create", "replayed-execution", {
mcpServers: { mcpServers: {
@@ -204,7 +204,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
agentId: original.payload.agentId, agentId: original.payload.agentId,
}, },
}); });
expect(hub.providerCreations()).toBe(providerCreations); expect(hub.executionProviderCreations()).toBe(executionProviderCreations);
expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]); expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]);
}); });
@@ -475,7 +475,7 @@ describe("Hub relationship", () => {
}); });
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(durableAgentIds).toEqual([created.payload.agentId]); expect(durableAgentIds).toEqual([created.payload.agentId]);
expect(relationship.providerCreations()).toBe(1); expect(relationship.executionProviderCreations()).toBe(1);
expect(relationship.providerResumes()).toBe(0); expect(relationship.providerResumes()).toBe(0);
expect(relationship.providerPromptTexts()).toEqual([prompt]); expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
@@ -640,7 +640,7 @@ describe("Hub relationship", () => {
agentId: durableAgentIds[0], agentId: durableAgentIds[0],
}, },
}); });
expect(relationship.providerCreations()).toBe(1); expect(relationship.executionProviderCreations()).toBe(1);
expect(durableAgentIds).toHaveLength(1); expect(durableAgentIds).toHaveLength(1);
}); });
@@ -665,7 +665,7 @@ describe("Hub relationship", () => {
agent: { id: expect.any(String) }, agent: { id: expect.any(String) },
}, },
}); });
expect(relationship.providerCreations()).toBe(1); expect(relationship.executionProviderCreations()).toBe(1);
expect(await relationship.durableOwnedAgentIds()).toHaveLength(1); expect(await relationship.durableOwnedAgentIds()).toHaveLength(1);
}); });
@@ -862,6 +862,10 @@ export class HubRelationshipHarness {
return this.codex.creations; return this.codex.creations;
} }
executionProviderCreations(): number {
return this.codex.createdConfigs.filter((config) => config.internal !== true).length;
}
providerResumes(): number { providerResumes(): number {
return this.codex.resumes; return this.codex.resumes;
} }
@@ -9,7 +9,12 @@ import {
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.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"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -304,6 +309,8 @@ export const PersistedConfigSchema = z
.optional(), .optional(),
providers: ProvidersSchema.optional(), providers: ProvidersSchema.optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(), worktrees: WorktreesConfigSchema.optional(),
agents: z agents: z
.object({ .object({
@@ -0,0 +1,206 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse } from "@babel/parser";
import { build, type Plugin } from "esbuild";
type PluginBuildTarget = "client" | "server";
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set(["addSurface", "addSidebarItem", "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<string>,
ranges: Array<{ start: number; end: number }>,
): void {
if (node === null || typeof node !== "object") return;
if (Reflect.get(node, "type") === "ExpressionStatement") {
const name = registrationName(node, contextName);
if (name && removedNames.has(name)) {
const start = Reflect.get(node, "start");
const end = Reflect.get(node, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate plugin context ${name} registration in index.tsx`);
}
ranges.push({ start, end });
return;
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value)
collectRemovedRegistrationRanges(child, contextName, removedNames, ranges);
} else if (value !== null && typeof value === "object") {
collectRemovedRegistrationRanges(value, contextName, removedNames, ranges);
}
}
}
function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string {
const ast = parse(source, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const pluginFunction = defaultPluginFunction(ast.program.body);
const ranges: Array<{ start: number; end: number }> = [];
collectRemovedRegistrationRanges(
pluginFunction.body,
pluginFunction.contextName,
REGISTRATIONS_REMOVED_BY_TARGET[target],
ranges,
);
let output = source;
for (const range of ranges.toReversed()) {
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
}
return output;
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
const filter =
target === "server"
? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/
: /^node:/;
return {
name: `paseo-plugin-${target}-unused-platform-modules`,
setup(buildContext) {
buildContext.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "paseo-unused-platform-module",
sideEffects: false,
}));
buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({
contents: "module.exports = {};",
loader: "js",
}));
},
};
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const source = await readFile(entryPath, "utf8");
const filteredSource = removeOppositeTargetRegistrations(source, target);
const result = await build({
stdin: {
contents: filteredSource,
loader: "tsx",
resolveDir: path.dirname(entryPath),
sourcefile: entryPath,
},
bundle: true,
format: "cjs",
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
external:
target === "client"
? [
"@paseo/plugin",
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: ["@paseo/plugin", "zod"],
plugins: [createUnusedPlatformModulePlugin(target)],
treeShaking: true,
write: false,
});
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPath: string): Promise<{
clientBundle: string;
serverBundle: string;
}> {
const [clientBundle, serverBundle] = await Promise.all([
compileTarget(entryPath, "client"),
compileTarget(entryPath, "server"),
]);
return { clientBundle, serverBundle };
}
@@ -0,0 +1,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<string> {
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<string, { source: "directory"; path: string; enabled?: boolean }> = {},
): 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<string, { source: "directory"; path: string; enabled?: boolean }> = {},
runtime?: ConstructorParameters<typeof PluginService>[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<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[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<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const starts: string[] = [];
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[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);
});
});
+278
View File
@@ -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<unknown>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>;
stopAll(): Promise<void>;
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<string, string>();
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<void> {
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<PluginListItem> {
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<PluginListItem> {
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<PluginListItem> {
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<PluginListItem> {
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<void> {
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<unknown> {
return this.runtime.invoke(pluginId, method, input);
}
async stopAllPlugins(): Promise<void> {
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<void> {
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<void> {
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<T>(operation: () => Promise<T>): Promise<T> {
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);
}
}
@@ -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<typeof PluginManifestSchema>;
export async function readPluginManifest(directory: string): Promise<PluginManifest> {
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")));
}

Some files were not shown because too many files have changed in this diff Show More