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
@@ -37,6 +37,7 @@ import {
Code2,
Smartphone,
Sparkles,
Blocks,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -108,6 +109,7 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-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: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks },
];
function renderHostSettingsContent(
@@ -216,6 +219,8 @@ function renderHostSettingsContent(
return <HostUsagePage serverId={view.serverId} />;
case "terminals":
return <HostTerminalsPage serverId={view.serverId} />;
case "plugins":
return <HostPluginsPage serverId={view.serverId} />;
case "host":
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" },
}));