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
+9
View File
@@ -1817,6 +1817,15 @@ export const en = {
idPlaceholder: "Manifest default",
install: "Install directory",
installing: "Installing…",
logs: {
action: "Logs",
title: "Logs: {{id}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
errorTitle: "Unable to load plugin logs",
empty: "No plugin output yet",
},
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
reload: "Reload",
@@ -1,5 +1,14 @@
const shared = {
directoryPlaceholder: "/absolute/path/on/host",
logs: {
action: "Logs",
title: "Logs: {{id}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
errorTitle: "Unable to load plugin logs",
empty: "No plugin output yet",
},
} as const;
export const pluginSettings = {
+5 -2
View File
@@ -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);
}
+3 -1
View File
@@ -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),
};
}
+17 -13
View File
@@ -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>
@@ -6,7 +6,7 @@ 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 type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostPluginsPage } from "./plugins-page";
@@ -15,6 +15,7 @@ void testI18n;
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
logsSupported: true,
client: null as DaemonClient | null,
}));
@@ -24,9 +25,37 @@ vi.mock("@/runtime/host-runtime", () => ({
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: () => runtime.supported,
useHostFeature: (_serverId: string, feature: string) =>
feature === "pluginLogs" ? runtime.logsSupported : runtime.supported,
}));
vi.mock("@/components/adaptive-modal-sheet", async () => {
const ReactModule = await vi.importActual<typeof import("react")>("react");
const actual = await vi.importActual<typeof import("@/components/adaptive-modal-sheet")>(
"@/components/adaptive-modal-sheet",
);
return {
...actual,
AdaptiveModalSheet: ({
header,
children,
onClose,
}: {
header: { title: string; actions?: React.ReactNode };
children: React.ReactNode;
onClose(): void;
}) =>
ReactModule.createElement(
"div",
{ role: "dialog" },
ReactModule.createElement("h2", null, header.title),
header.actions,
ReactModule.createElement("button", { type: "button", onClick: onClose }, "Close"),
children,
),
};
});
vi.mock("react-native-reanimated", () => ({
default: { View: "div" },
Easing: { ease: "ease", inOut: (value: unknown) => value },
@@ -79,6 +108,7 @@ function createClient() {
enablePlugin: vi.fn(async () => plugin()),
disablePlugin: vi.fn(async () => plugin(false)),
removePlugin: vi.fn(async () => undefined),
getPluginLogs: vi.fn(async (): Promise<PluginLogEntry[]> => []),
};
}
@@ -105,6 +135,7 @@ describe("HostPluginsPage", () => {
vi.stubGlobal("React", React);
runtime.connected = true;
runtime.supported = true;
runtime.logsSupported = true;
runtime.client = null;
vi.stubGlobal(
"confirm",
@@ -170,4 +201,61 @@ describe("HostPluginsPage", () => {
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined);
});
it("hides the logs action when the host does not advertise support", async () => {
runtime.logsSupported = false;
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
renderPage(client);
await screen.findByText("example");
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
});
it("opens readable stdout and stderr logs and refreshes them", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
client.getPluginLogs.mockResolvedValue([
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
{
sequence: 2,
timestamp: "2026-08-16T12:00:01.000Z",
stream: "stderr",
message: "warning",
},
]);
renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" }));
expect(await screen.findByRole("dialog")).toBeDefined();
expect(screen.getByText("Logs: example")).toBeDefined();
expect(await screen.findByText("ready")).toBeDefined();
expect(screen.getByText("warning")).toBeDefined();
expect(screen.getByText(/stdout/)).toBeDefined();
expect(screen.getByText(/stderr/)).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
});
it("shows empty and recoverable error states for plugin logs", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded"));
renderPage(client);
fireEvent.click(await screen.findByRole("button", { name: "Logs" }));
expect(await screen.findByText("Unable to load plugin logs")).toBeDefined();
expect(screen.getByText("logs exploded")).toBeDefined();
fireEvent.click(screen.getAllByRole("button", { name: "Refresh" }).at(-1)!);
await waitFor(() => expect(client.getPluginLogs).toHaveBeenCalledTimes(2));
expect(await screen.findByText("No plugin output yet")).toBeDefined();
});
});
@@ -3,7 +3,8 @@ 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 type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
@@ -39,12 +40,16 @@ function PluginRow({
pending,
pendingAction,
onAction,
onOpenLogs,
supportsLogs,
}: {
plugin: PluginListItem;
clientError?: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
@@ -53,6 +58,7 @@ function PluginRow({
[onAction, plugin],
);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const status = clientError ? "failed" : plugin.status;
let badgeVariant: "success" | "error" | "muted" = "muted";
if (status === "running") badgeVariant = "success";
@@ -76,6 +82,11 @@ function PluginRow({
) : null}
</View>
<View style={styles.actions}>
{supportsLogs ? (
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
@@ -94,11 +105,85 @@ function PluginRow({
);
}
function PluginLogsSheet({
client,
pluginId,
serverId,
onClose,
}: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
pluginId: string;
serverId: string;
onClose(): void;
}) {
const { t } = useTranslation();
const logs = useFetchQuery({
queryKey: ["plugin-logs", serverId, pluginId],
queryFn: () => client.getPluginLogs(pluginId),
dataShape: "list",
staleTimeMs: 0,
});
const refresh = useCallback(() => {
void logs.refetch();
}, [logs]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.plugins.logs.title", { id: pluginId }),
actions: (
<Button variant="outline" size="sm" onPress={refresh} disabled={logs.isFetching}>
{logs.isFetching
? t("settings.plugins.logs.refreshing")
: t("settings.plugins.logs.refresh")}
</Button>
),
}),
[logs.isFetching, pluginId, refresh, t],
);
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
if (logs.isError) {
content = (
<Alert
variant="error"
title={t("settings.plugins.logs.errorTitle")}
description={errorMessage(logs.error)}
>
<Button variant="outline" size="sm" onPress={refresh}>
{t("settings.plugins.logs.refresh")}
</Button>
</Alert>
);
} else if (!logs.isLoading) {
content = logs.data?.length ? (
<View style={styles.logList}>
{logs.data.map((entry: PluginLogEntry) => (
<View key={entry.sequence} style={styles.logEntry}>
<Text style={entry.stream === "stderr" ? styles.logStderr : styles.logStdout}>
{entry.timestamp} {entry.stream}
</Text>
<Text style={styles.logMessage}>{entry.message}</Text>
</View>
))}
</View>
) : (
<Text style={styles.logsState}>{t("settings.plugins.logs.empty")}</Text>
);
}
return (
<AdaptiveModalSheet header={header} visible onClose={onClose} testID="plugin-logs-sheet">
{content}
</AdaptiveModalSheet>
);
}
export function HostPluginsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
const logsSupported = useHostFeature(serverId, "pluginLogs");
const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins();
@@ -107,6 +192,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const [pluginId, setPluginId] = useState("");
const [directoryResetKey, setDirectoryResetKey] = useState(0);
const [pluginIdResetKey, setPluginIdResetKey] = useState(0);
const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null,
);
@@ -223,6 +309,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const retry = useCallback(() => {
void refetchPlugins();
}, [refetchPlugins]);
const closeLogs = useCallback(() => setLogsPluginId(null), []);
if (pageState === "offline") {
return (
@@ -265,6 +352,8 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
pending={mutation.isPending}
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
onAction={action}
onOpenLogs={setLogsPluginId}
supportsLogs={logsSupported}
/>
);
})
@@ -340,6 +429,14 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
) : null}
{catalogContent}
</SettingsSection>
{client && logsPluginId ? (
<PluginLogsSheet
client={client}
pluginId={logsPluginId}
serverId={serverId}
onClose={closeLogs}
/>
) : null}
</View>
);
}
@@ -356,4 +453,32 @@ const styles = StyleSheet.create((theme) => ({
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" },
logsState: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
paddingVertical: theme.spacing[6],
},
logList: { gap: theme.spacing[2] },
logEntry: {
padding: theme.spacing[3],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
logStdout: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logStderr: {
color: theme.colors.statusDanger,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
logMessage: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
}));