Make local plugins manageable and debuggable (#3446)

* feat(plugins): expose Paseo API to local plugins

Plugin surfaces borrow the selected host connection while subprocess handlers receive an exclusively owned IPC session, so plugins can use normal workspace and agent operations without inventing another transport.

* feat(plugins): reload global enablement safely

Treat the global plugin switch as a live daemon setting and teach plugin automation to inspect it before acting. Enabling remains permission-gated because plugin backend code can access the daemon machine and client contributions execute inside the app.

* feat(plugins): expose plugin logs

Capture plugin stdout and stderr separately from IPC so authors can debug initialization, RPC, cleanup, and crash behavior from the app or CLI. Keep a bounded tail across lifecycle recovery while preserving structured output in the daemon log.

* fix(build): order plugin dependency builds

* test(plugins): align startup and CLI expectations
This commit is contained in:
Mohamed Boudra
2026-08-16 18:54:44 +08:00
committed by GitHub
parent d99258cdd0
commit 16120ebff1
62 changed files with 2945 additions and 202 deletions
+55 -8
View File
@@ -34,7 +34,10 @@ The daemon stores directory sources under the root `plugins` object:
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`.
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
@@ -62,11 +65,21 @@ The config key is the runtime plugin ID. The manifest ID is the default selected
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Never enable plugins on a user's behalf without explicit permission. Before asking, check the
target daemon's current `pluginsEnabled` value. State that plugins are trusted, unsandboxed code:
backend code can access the daemon machine, while client contributions run inside the Paseo app.
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.
Server contributions can write to stdout and stderr with normal Node logging. Inspect the recent
in-memory tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and
process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the
tail, but structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets,
so do not log credentials or tokens.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
@@ -77,7 +90,7 @@ resolving dependencies.
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
import { defineRpc, type PluginContext, usePaseo, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
@@ -86,16 +99,36 @@ const greetRpc = defineRpc({
});
function Greeting() {
const paseo = usePaseo();
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
const createWorkspace = () =>
paseo.workspaces
.create({
source: {
kind: "worktree",
cwd: "/absolute/path/to/project",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
})
.then((workspace) =>
workspace.agents.create({
config: { provider: "codex/gpt-5" },
prompt: "Review PR #42",
}),
);
return <Text onPress={() => void createWorkspace()}>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.handle(greetRpc, async ({ name }, { paseo }) => {
const { config } = await paseo.config.get();
return { message: `${name}: ${config.pluginsEnabled ? "enabled" : "disabled"}` };
});
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
@@ -107,10 +140,11 @@ export default function contribute(plugin: PluginContext) {
}
```
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.
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it 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, closes the plugin's daemon session, 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.
@@ -119,6 +153,19 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess.
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents,
providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
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.