* 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
174 lines
6.0 KiB
Markdown
174 lines
6.0 KiB
Markdown
# 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.
|