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:
@@ -1498,6 +1498,51 @@ test("sends and parses daemon config reload", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("gets a structured plugin log snapshot", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { pluginLogs: true } });
|
||||
await connectPromise;
|
||||
|
||||
const response = client.getPluginLogs("example");
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toMatchObject({ type: "plugin.logs.get.request", pluginId: "example" });
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.logs.get.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
pluginId: "example",
|
||||
entries: [
|
||||
{
|
||||
sequence: 3,
|
||||
timestamp: "2026-08-16T12:00:00.000Z",
|
||||
stream: "stdout",
|
||||
message: "ready",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(response).resolves.toEqual([
|
||||
{
|
||||
sequence: 3,
|
||||
timestamp: "2026-08-16T12:00:00.000Z",
|
||||
stream: "stdout",
|
||||
message: "ready",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -103,6 +103,7 @@ import type {
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceRecoveryState,
|
||||
PluginListItem,
|
||||
PluginLogEntry,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -4752,6 +4753,16 @@ export class DaemonClient {
|
||||
return payload.plugins;
|
||||
}
|
||||
|
||||
async getPluginLogs(pluginId: string): Promise<PluginLogEntry[]> {
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.logs.get.request", requestId, pluginId },
|
||||
responseType: "plugin.logs.get.response",
|
||||
});
|
||||
return payload.entries;
|
||||
}
|
||||
|
||||
async installDirectoryPlugin(path: string, id?: string): Promise<PluginListItem> {
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createPaseoClient } from "./index.js";
|
||||
import { createPaseoApi, createPaseoClient } from "./index.js";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import type { PaseoAgent, PaseoClient, PaseoWorkspace } from "./index.js";
|
||||
|
||||
type FakeWebSocketHandler = (...args: unknown[]) => void;
|
||||
@@ -214,6 +215,20 @@ test("createPaseoClient exposes workspace list through the daemon client", async
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("createPaseoApi borrows daemon capabilities without exposing connection ownership", () => {
|
||||
const daemonClient = new DaemonClient({
|
||||
url: "ws://daemon.test",
|
||||
clientId: "borrowed-api",
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
const paseo = createPaseoApi(daemonClient);
|
||||
|
||||
expect(Object.keys(paseo).sort()).toEqual(["agents", "config", "providers", "workspaces"]);
|
||||
expect("connect" in paseo).toBe(false);
|
||||
expect("close" in paseo).toBe(false);
|
||||
});
|
||||
|
||||
test("agent actions list the daemon directory without exposing the low-level client", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const listedAgent = createAgent({ title: "Planner" });
|
||||
@@ -375,18 +390,28 @@ test("workspace handles keep identity and refresh snapshots through existing dri
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("workspace create is fresh and workspace-scoped agent create owns placement", async () => {
|
||||
test("plugin-shaped PR workspace create and agent create use the existing daemon RPCs", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const createdWorkspace = createWorkspace({ id: "workspace_fresh", name: "Issue 42" });
|
||||
|
||||
const workspacePromise = client.workspaces.create({
|
||||
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: "/repo/sdk",
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||||
},
|
||||
title: "Issue 42",
|
||||
});
|
||||
const workspaceRequest = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(workspaceRequest).toMatchObject({
|
||||
type: "workspace.create.request",
|
||||
source: { kind: "directory", path: "/repo/sdk", projectId: "project_sdk" },
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: "/repo/sdk",
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||||
},
|
||||
title: "Issue 42",
|
||||
});
|
||||
ws.message(
|
||||
|
||||
@@ -353,11 +353,14 @@ export interface PaseoConfigActions {
|
||||
): Promise<{ requestId: string; config: MutableDaemonConfig }>;
|
||||
}
|
||||
|
||||
export interface PaseoClient {
|
||||
export interface PaseoApi {
|
||||
readonly workspaces: PaseoWorkspaceActions;
|
||||
readonly agents: PaseoAgentActions;
|
||||
readonly providers: PaseoProviderActions;
|
||||
readonly config: PaseoConfigActions;
|
||||
}
|
||||
|
||||
export interface PaseoClient extends PaseoApi {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
ensureConnected(): void;
|
||||
@@ -370,6 +373,16 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
|
||||
clientId: config.clientId ?? createGeneratedClientId(),
|
||||
clientType: "cli",
|
||||
});
|
||||
return {
|
||||
...createPaseoApi(daemonClient),
|
||||
connect: () => daemonClient.connect(),
|
||||
close: () => daemonClient.close(),
|
||||
ensureConnected: () => daemonClient.ensureConnected(),
|
||||
getConnectionState: () => daemonClient.getConnectionState(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPaseoApi(daemonClient: DaemonClient): PaseoApi {
|
||||
const createAgentHandle = createAgentHandleFactory(daemonClient);
|
||||
const createAgent = async (
|
||||
options: PaseoAgentCreateOptions,
|
||||
@@ -447,10 +460,6 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
|
||||
get: (requestId) => daemonClient.getDaemonConfig(requestId),
|
||||
patch: (patch, requestId) => daemonClient.patchDaemonConfig(patch, requestId),
|
||||
},
|
||||
connect: () => daemonClient.connect(),
|
||||
close: () => daemonClient.close(),
|
||||
ensureConnected: () => daemonClient.ensureConnected(),
|
||||
getConnectionState: () => daemonClient.getConnectionState(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user