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
+1
View File
@@ -74,6 +74,7 @@ describe("canonical CLI surface", () => {
expect(plugin?.commands.map((command) => command.name())).toEqual([
"init",
"ls",
"logs",
"install",
"reload",
"enable",
+44 -3
View File
@@ -1,19 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const listPlugins = vi.fn(async () => []);
const getPluginLogs = vi.fn(async () => [
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout" as const,
message: "ready",
},
]);
const close = vi.fn(async () => undefined);
const features: { pluginManagement?: boolean; pluginLogs?: boolean } = {};
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
getLastServerInfoMessage: () => ({ features: { pluginManagement: false } }),
getLastServerInfoMessage: () => ({ features }),
listPlugins,
getPluginLogs,
close,
})),
}));
import { runPluginListCommand } from "./index.js";
import { render } from "../../output/index.js";
import { runPluginListCommand, runPluginLogsCommand } from "./index.js";
describe("plugin management commands", () => {
beforeEach(() => {
features.pluginManagement = false;
features.pluginLogs = false;
vi.clearAllMocks();
});
it("requires host support before attempting a management RPC", async () => {
await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
@@ -22,4 +39,28 @@ describe("plugin management commands", () => {
expect(listPlugins).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
});
it("requires plugin log support before attempting the RPC", async () => {
await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to view plugin logs.",
});
expect(getPluginLogs).not.toHaveBeenCalled();
});
it("returns readable and JSON plugin log output", async () => {
features.pluginLogs = true;
const result = await runPluginLogsCommand("example", {}, {} as never);
expect(getPluginLogs).toHaveBeenCalledWith("example");
expect(render(result, { noColor: true })).toContain("ready");
expect(JSON.parse(render(result, { format: "json" }))).toEqual([
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
]);
});
});
+23 -2
View File
@@ -1,10 +1,10 @@
import { Command } from "commander";
import type { PluginListItem } from "@getpaseo/protocol/messages";
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js";
import { withPluginManagementClient } from "./shared.js";
import { withPluginLogsClient, withPluginManagementClient } from "./shared.js";
interface PluginOptions extends CommandOptions {
host?: string;
@@ -30,6 +30,15 @@ const scaffoldSchema: OutputSchema<PluginScaffold> = {
],
};
const pluginLogsSchema: OutputSchema<PluginLogEntry> = {
idField: (entry) => String(entry.sequence),
columns: [
{ header: "TIME", field: "timestamp", width: 24 },
{ header: "STREAM", field: "stream", width: 8 },
{ header: "MESSAGE", field: "message", width: 80 },
],
};
export async function runPluginInitCommand(
directory: string,
options: PluginOptions,
@@ -50,6 +59,15 @@ export async function runPluginListCommand(
return { type: "list", data, schema: pluginSchema };
}
export async function runPluginLogsCommand(
pluginId: string,
options: PluginOptions,
_command: Command,
): Promise<ListResult<PluginLogEntry>> {
const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId));
return { type: "list", data, schema: pluginLogsSchema };
}
async function install(
directory: string,
options: PluginOptions,
@@ -98,6 +116,9 @@ export function createPluginCommand(): Command {
addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action(
withOutput(runPluginListCommand),
);
addJsonAndDaemonHostOptions(
plugin.command("logs").description("Show recent plugin output").argument("<id>"),
).action(withOutput(runPluginLogsCommand));
addJsonAndDaemonHostOptions(
plugin
.command("install")
@@ -36,6 +36,57 @@ describe("plugin scaffold", () => {
});
});
it("typechecks client and server Paseo API access", async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "paseo-api-plugin");
await scaffoldPluginDirectory(directory);
await writeFile(
path.join(directory, "index.tsx"),
`import React from "react";
import { Text } from "react-native";
import { z } from "zod";
import { defineRpc, type PluginContext, usePaseo } from "@paseo/plugin";
const inspect = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({ configured: z.boolean() }),
});
function Surface() {
const paseo = usePaseo();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
});
void createWorkspace;
return <Text>Paseo API</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(inspect, async (_input, { paseo }) => ({
configured: Boolean((await paseo.config.get()).config),
}));
plugin.addSurface("main", Surface);
return () => undefined;
}
`,
);
const configPath = path.join(directory, "tsconfig.json");
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
const diagnostics = ts.getPreEmitDiagnostics(
ts.createProgram(parsed.fileNames, parsed.options),
);
expect(
diagnostics.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
),
).toEqual([]);
}, 20_000);
it("refuses to write into a non-empty directory", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(directory);
+10 -1
View File
@@ -4,6 +4,7 @@ import { PluginIdSchema } from "@getpaseo/protocol/messages";
const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
export interface PluginSurfaceProps {
@@ -56,11 +57,16 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
@@ -68,7 +74,7 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export type PluginCleanup = () => void;
export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
@@ -81,6 +87,8 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
export function usePaseo(): PaseoApi;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
@@ -143,6 +151,7 @@ export async function scaffoldPluginDirectory(
version: "0.0.0",
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/client": "^0.4.0",
"@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0",
react: "19.1.0",
+28 -4
View File
@@ -2,17 +2,20 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
export async function withPluginManagementClient<T>(
type PluginFeature = "pluginManagement" | "pluginLogs";
async function withPluginClient<T>(
host: string | undefined,
feature: PluginFeature,
updateMessage: string,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
const client = await connectToDaemon({ host });
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
if (client.getLastServerInfoMessage()?.features?.pluginManagement !== true) {
if (client.getLastServerInfoMessage()?.features?.[feature] !== true) {
await client.close().catch(() => undefined);
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
message: updateMessage,
} satisfies CommandError;
}
try {
@@ -21,3 +24,24 @@ export async function withPluginManagementClient<T>(
await client.close().catch(() => undefined);
}
}
export async function withPluginManagementClient<T>(
host: string | undefined,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14.
return withPluginClient(
host,
"pluginManagement",
"Update the host to use plugin management.",
run,
);
}
export async function withPluginLogsClient<T>(
host: string | undefined,
run: (client: DaemonClient) => Promise<T>,
): Promise<T> {
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run);
}