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:
@@ -10,6 +10,7 @@ import {
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
usePaseo,
|
||||
useRpc,
|
||||
} from "@paseo/plugin";
|
||||
import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host";
|
||||
@@ -91,7 +92,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
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 === "@paseo/plugin") return { defineAttachmentSource, defineRpc, usePaseo, 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`);
|
||||
@@ -119,7 +120,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
cleanup();
|
||||
void Promise.resolve(cleanup()).catch((cleanupError) => {
|
||||
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ class PluginRegistry {
|
||||
this.disposed.add(plugin);
|
||||
plugin.queryClient.clear();
|
||||
try {
|
||||
plugin.cleanup();
|
||||
void Promise.resolve(plugin.cleanup()).catch((error) => {
|
||||
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { PaseoApiProvider } from "@paseo/plugin/host";
|
||||
import { usePaseo } from "@paseo/plugin";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||
|
||||
function clientWithWorkspace(id: string) {
|
||||
const createWorkspace = vi.fn(async () => ({
|
||||
error: null,
|
||||
workspace: {
|
||||
id,
|
||||
projectId: `project-${id}`,
|
||||
workspaceDirectory: `/tmp/${id}`,
|
||||
name: id,
|
||||
status: "active",
|
||||
},
|
||||
}));
|
||||
const createAgent = vi.fn(async () => ({
|
||||
id: `agent-${id}`,
|
||||
provider: "codex",
|
||||
cwd: `/tmp/${id}`,
|
||||
workspaceId: id,
|
||||
status: "idle",
|
||||
}));
|
||||
const invokePluginRpc = vi.fn(async () => id);
|
||||
return {
|
||||
client: { createWorkspace, createAgent, invokePluginRpc } as unknown as DaemonClient,
|
||||
createWorkspace,
|
||||
createAgent,
|
||||
invokePluginRpc,
|
||||
};
|
||||
}
|
||||
|
||||
function borrowFromAppProvider(paseo: PaseoApi): PaseoApi {
|
||||
let borrowed: PaseoApi | null = null;
|
||||
function PluginSurface() {
|
||||
borrowed = usePaseo();
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginSurface />
|
||||
</PaseoApiProvider>,
|
||||
);
|
||||
if (!borrowed) throw new Error("Plugin surface did not receive Paseo API");
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
describe("plugin surface host runtime", () => {
|
||||
it("creates a PR worktree and agent through usePaseo on the selected app host", async () => {
|
||||
const selected = clientWithWorkspace("workspace-a");
|
||||
const runtime = createPluginSurfaceRuntime(selected.client, "workspace-plugin");
|
||||
if (!runtime) throw new Error("Expected selected host runtime");
|
||||
|
||||
const paseo = borrowFromAppProvider(runtime.paseo);
|
||||
const workspace = await paseo.workspaces.create({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: "/tmp/repository",
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||||
},
|
||||
});
|
||||
const agent = await workspace.agents.create({
|
||||
config: { provider: "codex/gpt-5" },
|
||||
prompt: "Review PR #42",
|
||||
});
|
||||
|
||||
expect(workspace.id).toBe("workspace-a");
|
||||
expect(agent.id).toBe("agent-workspace-a");
|
||||
expect(selected.createWorkspace).toHaveBeenCalledOnce();
|
||||
expect(selected.createAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("switches all plugin calls when the selected host changes", async () => {
|
||||
const hostA = clientWithWorkspace("workspace-a");
|
||||
const hostB = clientWithWorkspace("workspace-b");
|
||||
const first = createPluginSurfaceRuntime(hostA.client, "same-plugin");
|
||||
const second = createPluginSurfaceRuntime(hostB.client, "same-plugin");
|
||||
if (!first || !second) throw new Error("Expected online host runtimes");
|
||||
|
||||
await first.invoke("host", {});
|
||||
await borrowFromAppProvider(second.paseo).workspaces.create({
|
||||
source: { kind: "directory", path: "/tmp/workspace-b" },
|
||||
});
|
||||
|
||||
expect(hostA.invokePluginRpc).toHaveBeenCalledWith("same-plugin", "host", {});
|
||||
expect(hostA.createWorkspace).not.toHaveBeenCalled();
|
||||
expect(hostB.createWorkspace).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an offline selected host unavailable instead of borrowing another host", () => {
|
||||
const otherHost = clientWithWorkspace("workspace-online");
|
||||
|
||||
expect(createPluginSurfaceRuntime(null, "same-plugin")).toBeNull();
|
||||
expect(otherHost.createWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export interface PluginSurfaceRuntime {
|
||||
paseo: PaseoApi;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export function createPluginSurfaceRuntime(
|
||||
client: DaemonClient | null,
|
||||
pluginId: string,
|
||||
): PluginSurfaceRuntime | null {
|
||||
if (!client) return null;
|
||||
return {
|
||||
paseo: createPaseoApi(client),
|
||||
invoke: (method, input) => client.invokePluginRpc(pluginId, method, input),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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 { PaseoApiProvider } from "@paseo/plugin/host";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import { ChevronDown, X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { Platform, Pressable, Text, View } from "react-native";
|
||||
@@ -20,6 +22,7 @@ import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import { rememberPluginContributionHost } from "./sidebar-groups";
|
||||
import { SurfaceErrorBoundary } from "./surface-error-boundary";
|
||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||
|
||||
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
@@ -49,6 +52,7 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
|
||||
function SurfaceRenderer({
|
||||
Surface,
|
||||
invoke,
|
||||
paseo,
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
@@ -56,6 +60,7 @@ function SurfaceRenderer({
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
paseo: PaseoApi;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
@@ -63,9 +68,11 @@ function SurfaceRenderer({
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PluginRpcProvider invoke={invoke}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
</PluginRpcProvider>
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={invoke}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -150,6 +157,7 @@ export function PluginSurfaceScreen() {
|
||||
const installations = usePluginInstallations(pluginId);
|
||||
const hosts = useHosts();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
|
||||
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
|
||||
@@ -163,13 +171,6 @@ export function PluginSurfaceScreen() {
|
||||
);
|
||||
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)}`);
|
||||
@@ -217,7 +218,7 @@ export function PluginSurfaceScreen() {
|
||||
<View style={styles.screen}>
|
||||
<ScreenHeader left={headerLeft} right={headerRight} />
|
||||
<View style={styles.body}>
|
||||
{plugin && surface ? (
|
||||
{plugin && surface && runtime ? (
|
||||
<SurfaceErrorBoundary
|
||||
key={`${serverId}/${pluginId}/${contributionId}`}
|
||||
installation={plugin}
|
||||
@@ -225,7 +226,8 @@ export function PluginSurfaceScreen() {
|
||||
>
|
||||
<ThemedSurfaceRenderer
|
||||
Surface={surface.Component}
|
||||
invoke={invoke}
|
||||
invoke={runtime.invoke}
|
||||
paseo={runtime.paseo}
|
||||
plugin={plugin}
|
||||
host={host}
|
||||
layout={layout}
|
||||
@@ -233,7 +235,9 @@ export function PluginSurfaceScreen() {
|
||||
/>
|
||||
</SurfaceErrorBoundary>
|
||||
) : (
|
||||
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user