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
+4
View File
@@ -92,6 +92,10 @@ facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config.
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
### `packages/app` — Mobile + web client (Expo)
Cross-platform React Native app that connects to one or more daemons.
+2
View File
@@ -226,6 +226,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
providers: [{ provider, model?, thinkingOptionId? }]
}
},
pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
+55 -8
View File
@@ -34,7 +34,10 @@ The daemon stores directory sources under the root `plugins` object:
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`.
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
@@ -62,11 +65,21 @@ The config key is the runtime plugin ID. The manifest ID is the default selected
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Never enable plugins on a user's behalf without explicit permission. Before asking, check the
target daemon's current `pluginsEnabled` value. State that plugins are trusted, unsandboxed code:
backend code can access the daemon machine, while client contributions run inside the Paseo app.
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
Server contributions can write to stdout and stderr with normal Node logging. Inspect the recent
in-memory tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and
process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the
tail, but structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets,
so do not log credentials or tokens.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
@@ -77,7 +90,7 @@ resolving dependencies.
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
import { defineRpc, type PluginContext, usePaseo, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
@@ -86,16 +99,36 @@ const greetRpc = defineRpc({
});
function Greeting() {
const paseo = usePaseo();
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
const createWorkspace = () =>
paseo.workspaces
.create({
source: {
kind: "worktree",
cwd: "/absolute/path/to/project",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
})
.then((workspace) =>
workspace.agents.create({
config: { provider: "codex/gpt-5" },
prompt: "Review PR #42",
}),
);
return <Text onPress={() => void createWorkspace()}>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.handle(greetRpc, async ({ name }, { paseo }) => {
const { config } = await paseo.config.get();
return { message: `${name}: ${config.pluginsEnabled ? "enabled" : "disabled"}` };
});
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
@@ -107,10 +140,11 @@ export default function contribute(plugin: PluginContext) {
}
```
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
@@ -119,6 +153,19 @@ RPC contracts validate inputs and outputs in both the app and plugin subprocess.
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: workspaces, agents,
providers, and daemon config. They do not expose connection lifecycle. A surface borrows the
selected host's existing connection; switching the screen's host changes both `usePaseo()` and
`useRpc()` to that host. An offline selected host fails there and never falls through to another
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
grace. During daemon startup, plugin sessions may connect while application WebSockets remain
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
+1
View File
@@ -24,6 +24,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
+1 -1
View File
@@ -1 +1 @@
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
sha256-XSalbyEP7UNN13EVpnvQHPxvEoxSDKuYhiaRg4Tyez4=
+2
View File
@@ -38592,6 +38592,7 @@
"name": "@paseo/plugin",
"version": "0.3.0",
"devDependencies": {
"@getpaseo/client": "0.4.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38600,6 +38601,7 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.4.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
+4 -4
View File
@@ -55,13 +55,13 @@
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server-deps": "npm run build:client && concurrently --kill-others-on-fail --names highlight,plugin,relay --prefix-colors yellow,green,blue \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\"",
"build:server-deps:clean": "npm run build:client:clean && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build:plugin && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build:plugin:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
+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,
},
}));
@@ -30,6 +30,10 @@ const testTheme = {
sm: 14,
base: 16,
},
fontFamily: {
ui: "sans-serif",
mono: "monospace",
},
fontWeight: {
normal: "400",
medium: "500",
+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);
}
+45
View File
@@ -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();
+11
View File
@@ -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({
+29 -4
View File
@@ -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(
+14 -5
View File
@@ -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(),
};
}
+2
View File
@@ -28,6 +28,7 @@
"test": "vitest run"
},
"devDependencies": {
"@getpaseo/client": "0.4.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -36,6 +37,7 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.4.0",
"react": "19.1.0",
"zod": "^4.4.3"
}
+7 -1
View File
@@ -1,4 +1,5 @@
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "./rpc.js";
@@ -35,11 +36,16 @@ export interface PluginAttachmentSourceContribution {
search: PluginRpcContract;
}
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;
@@ -47,6 +53,6 @@ export interface PluginContext {
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export type PluginCleanup = () => void;
export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
+2 -1
View File
@@ -7,6 +7,7 @@ import type {
PluginSurfaceProps,
} from "./contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "./rpc.js";
import type { ComponentType } from "react";
@@ -47,4 +48,4 @@ export async function searchPluginAttachments(
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export { callPluginRpc, PluginRpcProvider };
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
+2
View File
@@ -9,10 +9,12 @@ export type {
PluginCleanup,
PluginContribution,
PluginContext,
PluginHandlerContext,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export { useRpc } from "./rpc-context.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
+14
View File
@@ -0,0 +1,14 @@
import type { PaseoApi } from "@getpaseo/client";
import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PaseoApi | null>(null);
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PaseoApi {
const paseo = useContext(PaseoApiContext);
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
}
@@ -48,6 +48,73 @@ describe("plugin protocol compatibility", () => {
).toBe("plugin.directory.install.response");
});
it("uses a namespaced snapshot RPC for structured plugin logs", () => {
expect(
SessionInboundMessageSchema.parse({
type: "plugin.logs.get.request",
requestId: "request-logs",
pluginId: "example",
}),
).toEqual({
type: "plugin.logs.get.request",
requestId: "request-logs",
pluginId: "example",
});
expect(
SessionOutboundMessageSchema.parse({
type: "plugin.logs.get.response",
payload: {
requestId: "request-logs",
pluginId: "example",
entries: [
{
sequence: 7,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stderr",
message: "failed to connect",
},
],
},
}),
).toEqual({
type: "plugin.logs.get.response",
payload: {
requestId: "request-logs",
pluginId: "example",
entries: [
{
sequence: 7,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stderr",
message: "failed to connect",
},
],
},
});
});
it("keeps the plugin logs capability optional for older server info", () => {
const older = StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "older-host",
features: { pluginManagement: true },
},
});
const current = StatusMessageSchema.parse({
type: "status",
payload: {
status: "server_info",
serverId: "current-host",
features: { pluginManagement: true, pluginLogs: true },
},
});
expect(older.payload.features?.pluginLogs).toBeUndefined();
expect(current.payload.features?.pluginLogs).toBe(true);
});
it("keeps the catalog change notification safe for older clients", () => {
expect(
StatusMessageSchema.parse({
+27
View File
@@ -1353,6 +1353,12 @@ export const PluginListRequestSchema = z.object({
requestId: z.string(),
});
export const PluginLogsGetRequestSchema = z.object({
type: z.literal("plugin.logs.get.request"),
requestId: z.string(),
pluginId: PluginIdSchema,
});
export const PluginDirectoryInstallRequestSchema = z.object({
type: z.literal("plugin.directory.install.request"),
requestId: z.string(),
@@ -2855,6 +2861,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
DiagnosticsRequestSchema,
PluginCatalogGetRequestSchema,
PluginListRequestSchema,
PluginLogsGetRequestSchema,
PluginDirectoryInstallRequestSchema,
PluginDirectoryInspectRequestSchema,
PluginReloadRequestSchema,
@@ -3190,6 +3197,8 @@ export const ServerInfoStatusPayloadSchema = z
plugins: z.boolean().optional(),
// COMPAT(pluginManagement): added in v0.4.0, remove gate after 2027-08-14.
pluginManagement: z.boolean().optional(),
// COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16.
pluginLogs: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -5858,11 +5867,28 @@ export const PluginListItemSchema = z.object({
});
export type PluginListItem = z.infer<typeof PluginListItemSchema>;
export const PluginLogEntrySchema = z.object({
sequence: z.number().int().nonnegative(),
timestamp: z.string().datetime(),
stream: z.enum(["stdout", "stderr"]),
message: z.string(),
});
export type PluginLogEntry = z.infer<typeof PluginLogEntrySchema>;
export const PluginListResponseSchema = z.object({
type: z.literal("plugin.list.response"),
payload: z.object({ requestId: z.string(), plugins: z.array(PluginListItemSchema) }),
});
export const PluginLogsGetResponseSchema = z.object({
type: z.literal("plugin.logs.get.response"),
payload: z.object({
requestId: z.string(),
pluginId: PluginIdSchema,
entries: z.array(PluginLogEntrySchema),
}),
});
export const PluginDirectoryInstallResponseSchema = z.object({
type: z.literal("plugin.directory.install.response"),
payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }),
@@ -5905,6 +5931,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
BrowserAutomationExecuteRequestSchema,
PluginCatalogGetResponseSchema,
PluginListResponseSchema,
PluginLogsGetResponseSchema,
PluginDirectoryInstallResponseSchema,
PluginDirectoryInspectResponseSchema,
PluginReloadResponseSchema,
@@ -48,16 +48,6 @@ type WebSocketProbeResult =
| { status: "connected" }
| { status: "rejected"; statusCode: number | null };
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
}
describe("paseo daemon bootstrap", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -593,7 +583,7 @@ describe("paseo daemon bootstrap", () => {
}
});
test("rolls back already-open standalone listener when main daemon listen fails", async () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
@@ -648,10 +638,7 @@ export default function contribute(plugin: unknown) {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
if (!isPlatform("win32")) {
const pluginPid = Number(await readFile(pluginPidPath, "utf8"));
await vi.waitFor(() => {
expect(processExists(pluginPid)).toBe(false);
});
await expect(readFile(pluginPidPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
}
} finally {
await daemon.stop().catch(() => undefined);
+4 -1
View File
@@ -1491,7 +1491,6 @@ export async function createPaseoDaemon(
const start = async () => {
let mainStarted = false;
try {
await pluginRuntime.start();
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
@@ -1580,6 +1579,7 @@ export async function createPaseoDaemon(
getHostnames: () => configuredHostnames,
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
startPaused: true,
},
workspaceAutoName,
config.auth,
@@ -1632,6 +1632,9 @@ export async function createPaseoDaemon(
workspaceSetupRuntime,
pluginRuntime,
);
pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start();
wsServer.beginAcceptingConnections();
relayRuntime = createRelayRuntime({
config: {
enabled: relayEnabled,
@@ -36,6 +36,8 @@ function reloadableConfig(
maxProcessConcurrency: git.maxProcessConcurrency ?? 8,
},
app: { baseUrl: "https://app.paseo.sh" },
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins ?? {},
};
}
@@ -940,6 +942,31 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
});
test("applies the global plugin switch in both directions", () => {
const { paseoHome, store, persisted } = createReloadableStore({
initialPersisted: { version: 1, pluginsEnabled: false },
});
const changes: unknown[] = [];
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
expect(store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
expect(store.get().pluginsEnabled).toBe(true);
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
expect(store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
expect(store.get().pluginsEnabled).toBe(false);
expect(changes).toEqual([true, false]);
});
test("classifies every leaf when a parent subtree is added", () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
describe("plugin daemon IPC transport", () => {
it("preserves incoming text and binary frame order", async () => {
let receive: ((message: PluginProcessRequest) => void) | null = null;
const sent: PluginProcessMessage[] = [];
const factory = createPluginDaemonTransportFactory({
send(message) {
sent.push(message);
},
onMessage(handler) {
receive = handler;
return () => {
receive = null;
};
},
});
const transport = factory({ url: "ipc://plugin" });
const frames: Array<{ data: unknown; isBinary: boolean }> = [];
transport.onMessage((data, isBinary) => frames.push({ data, isBinary }));
const opened = new Promise<void>((resolve) => transport.onOpen(resolve));
await opened;
receive?.({ type: "paseo_frame", data: "first", isBinary: false });
receive?.({ type: "paseo_frame", data: new Uint8Array([7, 8]), isBinary: true });
transport.send("outbound");
expect(frames).toEqual([
{ data: "first", isBinary: false },
{ data: new Uint8Array([7, 8]), isBinary: true },
]);
expect(sent).toEqual([{ type: "paseo_frame", data: "outbound", isBinary: false }]);
});
});
@@ -0,0 +1,72 @@
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
interface PluginProcessPort {
send(message: PluginProcessMessage): void;
onMessage(handler: (message: PluginProcessRequest) => void): () => void;
}
function normalizeFrame(data: string | Uint8Array | ArrayBuffer): string | Uint8Array {
if (typeof data === "string") return data;
if (data instanceof Uint8Array) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return new Uint8Array(data);
}
export function createPluginDaemonTransportFactory(
port: PluginProcessPort,
): DaemonTransportFactory {
return () => {
const messageHandlers = new Set<(data: unknown, isBinary: boolean) => void>();
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
let isOpen = true;
const unsubscribe = port.onMessage((message) => {
if (message.type === "paseo_frame") {
for (const handler of messageHandlers) handler(message.data, message.isBinary);
}
if (message.type === "paseo_close") closeTransport();
});
function closeTransport(): void {
if (!isOpen) return;
isOpen = false;
unsubscribe();
for (const handler of closeHandlers) handler();
}
queueMicrotask(() => {
if (!isOpen) return;
for (const handler of openHandlers) handler();
});
return {
send(data) {
if (!isOpen) throw new Error("Plugin daemon transport is closed");
const frame = normalizeFrame(data);
port.send({ type: "paseo_frame", data: frame, isBinary: typeof frame !== "string" });
},
close() {
if (!isOpen) return;
port.send({ type: "paseo_close" });
closeTransport();
},
onMessage(handler) {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen(handler) {
openHandlers.add(handler);
return () => openHandlers.delete(handler);
},
onClose(handler) {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
onError() {
return () => undefined;
},
};
};
}
@@ -38,7 +38,46 @@ function createService(
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
runtime?: ConstructorParameters<typeof PluginService>[2],
): PluginService {
return new PluginService(pino({ level: "silent" }), createStore(home, plugins), runtime);
return bindTestSessionHost(
new PluginService(pino({ level: "silent" }), createStore(home, plugins), runtime),
);
}
function bindTestSessionHost(service: PluginService): PluginService {
service.bindPaseoSessionHost({
async attachPluginSocket(_pluginId, socket) {
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
socket.on("message", (data) => {
if (typeof data !== "string") return;
const message = JSON.parse(data);
if (message.type !== "hello") return;
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "plugin-service-test",
hostname: "plugin-service-test",
version: "0.4.0",
features: {},
},
},
}),
);
});
return { closed };
},
});
return service;
}
function catalogIds(service: PluginService): string[] {
return service
.catalog()
.map(({ id }) => id)
.sort();
}
afterEach(async () => {
@@ -58,6 +97,8 @@ function createPausedRuntime() {
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
markStarted();
await startGate;
@@ -69,6 +110,7 @@ function createPausedRuntime() {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return { runtime, started, releaseStart };
}
@@ -87,6 +129,8 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
starts.push(pluginId);
if (pluginId === pausedPluginId) {
@@ -101,11 +145,78 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return { runtime, started, releaseStart, starts };
}
describe("PluginService", () => {
it("retains logs when disabled and clears them only when removed", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const entries = [
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout" as const,
message: "ready",
},
];
const cleared: string[] = [];
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
catalog: () => [],
invoke: async () => undefined,
getLogs: () => entries,
clearLogs: (pluginId) => {
cleared.push(pluginId);
entries.length = 0;
},
startPlugin: async () => undefined,
stopPluginById: async () => false,
stopAll: async () => undefined,
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const service = createService(
home,
{ example: { source: "directory", path: "/plugins/example", enabled: false } },
runtime,
);
expect(service.getLogs("example")).toEqual(entries);
await service.disablePlugin("example");
expect(service.getLogs("example")).toEqual(entries);
expect(cleared).toEqual([]);
await service.removePlugin("example");
expect(cleared).toEqual(["example"]);
expect(entries).toEqual([]);
});
it("publishes each configured plugin after its startup state settles", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const first = await createPlugin(
"startup-first",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const second = await createPlugin(
"startup-second",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const service = createService(home, {
"startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second },
});
const snapshots: string[][] = [];
service.subscribe(() => snapshots.push(catalogIds(service)));
await service.start();
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
await service.stopAllPlugins();
}, 20_000);
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
@@ -114,7 +225,7 @@ describe("PluginService", () => {
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
const service = bindTestSessionHost(new PluginService(pino({ level: "silent" }), store));
await service.start();
await expect(
@@ -140,7 +251,7 @@ describe("PluginService", () => {
);
await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" });
await service.stopAllPlugins();
});
}, 20_000);
it("disables and removes a plugin without touching its source directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
@@ -155,7 +266,7 @@ export default function contribute(plugin: unknown) {
}`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
const service = bindTestSessionHost(new PluginService(pino({ level: "silent" }), store));
await service.start();
await service.installDirectory({ path: directory });
@@ -168,7 +279,7 @@ export default function contribute(plugin: unknown) {
expect(service.listPlugins()).toEqual([]);
await expect(stat(directory)).resolves.toMatchObject({});
await service.stopAllPlugins();
});
}, 20_000);
it("detaches every plugin synchronously when the global switch turns off and recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
@@ -182,7 +293,7 @@ export default function contribute(plugin: unknown) {
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
const service = bindTestSessionHost(new PluginService(pino({ level: "silent" }), store));
await service.start();
await service.installDirectory({ path: first });
await service.installDirectory({ path: second });
@@ -200,7 +311,7 @@ export default function contribute(plugin: unknown) {
{ id: "second", status: "running" },
]);
await service.stopAllPlugins();
});
}, 20_000);
it("does not publish an in-flight start after a later global disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
@@ -2,6 +2,7 @@ import path from "node:path";
import type pino from "pino";
import {
PluginIdSchema,
type PluginLogEntry,
type PluginListItem,
type PluginSource,
} from "@getpaseo/protocol/messages";
@@ -12,10 +13,13 @@ import { PluginRuntime } from "./runtime.js";
interface PluginRuntimePort {
catalog(): Array<{ id: string; clientBundle: string }>;
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>;
stopAll(): Promise<void>;
subscribe(listener: (pluginId: string, error?: string) => void): () => void;
bindPaseoSessionHost(sessionHost: Parameters<PluginRuntime["bindPaseoSessionHost"]>[0]): void;
}
export class PluginService {
@@ -45,6 +49,10 @@ export class PluginService {
return () => this.listeners.delete(listener);
}
bindPaseoSessionHost(sessionHost: Parameters<PluginRuntime["bindPaseoSessionHost"]>[0]): void {
this.runtime.bindPaseoSessionHost(sessionHost);
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
@@ -54,6 +62,7 @@ export class PluginService {
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue;
await this.startConfigured(pluginId);
this.notify(pluginId);
}
}
this.configStore.onFieldChange("pluginsEnabled", (value) => {
@@ -83,6 +92,11 @@ export class PluginService {
.sort((left, right) => left.id.localeCompare(right.id));
}
getLogs(pluginId: string): PluginLogEntry[] {
this.requireSource(pluginId);
return this.runtime.getLogs(pluginId);
}
catalog(): Array<{ id: string; clientBundle: string }> {
return this.runtime.catalog();
}
@@ -164,6 +178,7 @@ export class PluginService {
this.configStore.patch({ plugins: sources });
await this.enqueue(async () => {
await stopping;
this.runtime.clearLogs(pluginId);
this.errors.delete(pluginId);
this.notify(pluginId);
});
@@ -0,0 +1,165 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("plugin handlers create workspaces and agents through their Paseo API", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-"));
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-"));
roots.push(pluginDirectory, workspaceDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "paseo-api" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
`import { defineRpc, type PluginContext } from "@paseo/plugin";
import { z } from "zod";
const create = defineRpc({
name: "create",
input: z.object({ path: z.string() }),
output: z.object({ workspaceId: z.string(), agentId: z.string() }),
});
export default function contribute(plugin: PluginContext) {
plugin.handle(create, async ({ path }, { paseo }) => {
const workspace = await paseo.workspaces.create({
source: { kind: "directory", path },
title: "Plugin workspace",
});
const agent = await workspace.agents.create({
config: { provider: "codex/test" },
prompt: "Created by a plugin handler",
});
return { workspaceId: workspace.id, agentId: agent.id };
});
return () => undefined;
}`,
);
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.4.0",
});
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await expect(client.installDirectoryPlugin(pluginDirectory)).resolves.toMatchObject({
id: "paseo-api",
status: "running",
});
const created = await client.invokePluginRpc("paseo-api", "create", {
path: workspaceDirectory,
});
expect(created).toEqual({
workspaceId: expect.stringMatching(/^wks_/),
agentId: expect.stringMatching(/^[0-9a-f-]{36}$/),
});
if (typeof created !== "object" || created === null) {
throw new Error("Plugin returned an invalid creation result");
}
await client.removePlugin("paseo-api");
const workspaces = await client.fetchWorkspaces();
const agents = await client.fetchAgents();
expect(workspaces.entries.map((workspace) => workspace.id)).toContain(
Reflect.get(created, "workspaceId"),
);
expect(agents.entries.map((entry) => entry.agent.id)).toContain(
Reflect.get(created, "agentId"),
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
}
}, 60_000);
test("daemon config reload enables and disables configured plugins without restarting", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-"));
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
roots.push(pluginDirectory, paseoHomeRoot);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "reloadable-plugin" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
`export default function contribute(plugin: unknown) {
void plugin;
return () => undefined;
}`,
);
const plugins = {
"reloadable-plugin": { source: "directory" as const, path: pluginDirectory, enabled: true },
};
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ version: 1, pluginsEnabled: false, plugins }, null, 2)}\n`,
);
const daemon = await createTestPaseoDaemon({
paseoHomeRoot,
cleanup: false,
pluginsEnabled: false,
plugins,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.4.0",
});
const configPath = path.join(daemon.paseoHome, "config.json");
async function setPluginsEnabled(enabled: boolean): Promise<void> {
const config = JSON.parse(await readFile(configPath, "utf8"));
await writeFile(
configPath,
`${JSON.stringify({ ...config, pluginsEnabled: enabled }, null, 2)}\n`,
);
}
try {
await client.connect();
await expect(client.listPlugins()).resolves.toEqual([
expect.objectContaining({ id: "reloadable-plugin", status: "disabled" }),
]);
await setPluginsEnabled(true);
await expect(client.reloadDaemonConfig()).resolves.toMatchObject({
requestId: expect.any(String),
appliedPaths: expect.arrayContaining(["pluginsEnabled"]),
restartRequiredPaths: [],
overrideControlledPaths: [],
});
await expect
.poll(async () => (await client.listPlugins()).find(({ id }) => id === "reloadable-plugin"))
.toMatchObject({ enabled: true, status: "running" });
await setPluginsEnabled(false);
await expect(client.reloadDaemonConfig()).resolves.toEqual({
requestId: expect.any(String),
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
await expect
.poll(async () => (await client.listPlugins()).find(({ id }) => id === "reloadable-plugin"))
.toMatchObject({ enabled: true, status: "disabled" });
} finally {
await client.close().catch(() => undefined);
await daemon.close();
}
}, 60_000);
@@ -1,10 +1,14 @@
export type PluginProcessRequest =
| { type: "initialize"; bundle: string }
| { type: "initialize"; pluginId: string; bundle: string }
| { type: "invoke"; requestId: string; method: string; input: unknown }
| { type: "shutdown" };
| { type: "shutdown" }
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
| { type: "paseo_close" };
export type PluginProcessMessage =
| { type: "ready"; methods: string[] }
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "fatal"; error: string };
| { type: "fatal"; error: string }
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
| { type: "paseo_close" };
@@ -1,8 +1,16 @@
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@paseo/plugin";
import {
defineAttachmentSource,
defineRpc,
type PluginHandlerContext,
type PluginRpcContract,
} from "@paseo/plugin";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
type RpcHandler = (input: unknown) => unknown | Promise<unknown>;
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
interface RegisteredRpc {
contract: PluginRpcContract;
@@ -10,13 +18,26 @@ interface RegisteredRpc {
}
const handlers = new Map<string, RegisteredRpc>();
let cleanup: (() => void) | null = null;
let cleanup: (() => void | Promise<void>) | null = null;
let daemonClient: DaemonClient | null = null;
let paseo: PaseoApi | null = null;
let stopping = false;
const nodeRequire = createRequire(import.meta.url);
function send(message: PluginProcessMessage): void {
process.send?.(message);
}
function sendAndWait(message: PluginProcessMessage): Promise<void> {
return new Promise((resolve) => {
if (!process.send) {
resolve();
return;
}
process.send(message, () => resolve());
});
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -62,29 +83,59 @@ function evaluateBundle(bundle: string): void {
cleanup = contributedCleanup;
}
const transportFactory = createPluginDaemonTransportFactory({
send,
onMessage(handler) {
process.on("message", handler);
return () => process.off("message", handler);
},
});
async function initialize(message: Extract<PluginProcessRequest, { type: "initialize" }>) {
daemonClient = new DaemonClient({
url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`,
clientId: `plugin:${message.pluginId}`,
clientType: "cli",
reconnect: { enabled: false },
transportFactory,
});
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
evaluateBundle(message.bundle);
send({ type: "ready", methods: [...handlers.keys()].sort() });
}
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
const currentCleanup = cleanup;
cleanup = null;
try {
await currentCleanup?.();
} catch (error) {
console.error("Plugin cleanup failed", error);
}
await daemonClient?.close().catch(() => undefined);
await sendAndWait({ type: "paseo_close" });
daemonClient = null;
paseo = null;
process.disconnect();
}
process.on("message", (message: PluginProcessRequest) => {
if (message.type === "initialize") {
try {
evaluateBundle(message.bundle);
send({ type: "ready", methods: [...handlers.keys()].sort() });
} catch (error) {
void initialize(message).catch(async (error) => {
send({ type: "fatal", error: describeError(error) });
}
await daemonClient?.close().catch(() => undefined);
});
return;
}
if (message.type === "shutdown") {
const currentCleanup = cleanup;
cleanup = null;
if (currentCleanup) {
try {
currentCleanup();
} catch (error) {
console.error("Plugin cleanup failed", error);
}
}
process.disconnect();
void shutdown();
return;
}
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
if (stopping) return;
const registered = handlers.get(message.method);
if (!registered) {
send({
@@ -96,7 +147,10 @@ process.on("message", (message: PluginProcessRequest) => {
}
void registered.contract.input
.parseAsync(message.input)
.then(registered.handler)
.then((input) => {
if (!paseo) throw new Error("Plugin Paseo API is unavailable");
return registered.handler(input, { paseo });
})
.then((output) => registered.contract.output.parseAsync(output))
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
@@ -1,10 +1,12 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PluginRuntime } from "./runtime.js";
import type { PluginSessionSocket } from "./session-socket.js";
const temporaryDirectories: string[] = [];
@@ -22,6 +24,8 @@ function createReloadChild(name: string, events: string[], methods: string[] = [
for (const listener of listeners.get(event) ?? []) listener(message as never);
};
return {
stdout: new PassThrough(),
stderr: new PassThrough(),
connected: true,
killed: false,
send(message: { type: string }, callback?: (error: Error | null) => void) {
@@ -58,6 +62,84 @@ function createReloadChild(name: string, events: string[], methods: string[] = [
};
}
function createTestRuntime(
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[1]> = {},
logger = pino({ level: "silent" }),
): PluginRuntime {
return new PluginRuntime(logger, {
...dependencies,
sessionHost: dependencies.sessionHost ?? {
async attachPluginSocket(_pluginId, socket) {
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
socket.on("message", (data) => {
if (typeof data !== "string") return;
const message = JSON.parse(data);
if (message.type !== "hello") return;
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "plugin-test",
hostname: "plugin-test",
version: "0.4.0",
features: {},
},
},
}),
);
});
return { closed };
},
},
});
}
function createTrackedSessionHost() {
const active = new Set<object>();
return {
active,
host: {
async attachPluginSocket(_pluginId: string, socket: PluginSessionSocket) {
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
active.add(socket);
socket.once("close", () => active.delete(socket));
socket.on("message", (data) => {
if (typeof data !== "string") return;
const message = JSON.parse(data);
if (message.type !== "hello") return;
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "tracked-plugin-test",
hostname: "tracked-plugin-test",
version: "0.4.0",
features: {},
},
},
}),
);
});
return { closed };
},
},
};
}
function lifecycleMessages(runtime: PluginRuntime): string[] {
return runtime
.getLogs("lifecycle-output")
.map((entry) => entry.message)
.filter((message) => message === "initialized" || message === "initialization warning")
.sort();
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
@@ -65,6 +147,122 @@ afterEach(async () => {
});
describe("PluginRuntime", () => {
it("frames stdout and stderr, normalizes CRLF, and flushes final fragments once", async () => {
const directory = await createPlugin(
"output",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("output", []);
const runtime = createTestRuntime({ spawnChild: () => child });
await runtime.startPlugin("output", directory);
child.stdout.write("first\r");
child.stdout.write("\n");
child.stderr.write("problem\nfinal stderr");
child.stdout.write("final stdout");
await runtime.stopPluginById("output");
expect(runtime.getLogs("output").map(({ stream, message }) => ({ stream, message }))).toEqual([
{ stream: "stdout", message: "first" },
{ stream: "stderr", message: "problem" },
{ stream: "stderr", message: "final stderr" },
{ stream: "stdout", message: "final stdout" },
]);
expect(runtime.getLogs("output").map((entry) => entry.sequence)).toEqual([1, 2, 3, 4]);
});
it("writes plugin output through the daemon logger with structured identity fields", async () => {
const directory = await createPlugin(
"tagged-output",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("tagged-output", []);
const records: Array<Record<string, unknown>> = [];
const logger = pino(
{ level: "info" },
{ write: (line: string) => records.push(JSON.parse(line) as Record<string, unknown>) },
);
const runtime = createTestRuntime({ spawnChild: () => child }, logger);
await runtime.startPlugin("tagged-output", directory);
child.stderr.write("connection failed\n");
expect(records.find((record) => record.msg === "Plugin output")).toMatchObject({
module: "plugins",
pluginId: "tagged-output",
sequence: 1,
stream: "stderr",
message: "connection failed",
});
expect(records.find((record) => record.msg === "Plugin output")?.timestamp).toEqual(
expect.any(String),
);
await runtime.stopAll();
});
it("bounds retained output by entry count, total bytes, and individual line bytes", async () => {
const directory = await createPlugin(
"noisy",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("noisy", []);
const runtime = createTestRuntime({ spawnChild: () => child });
await runtime.startPlugin("noisy", directory);
child.stdout.write(
`${Array.from({ length: 510 }, (_, index) => `line-${index}`).join("\n")}\n`,
);
expect(runtime.getLogs("noisy")).toHaveLength(500);
expect(runtime.getLogs("noisy")[0]?.message).toBe("line-10");
child.stdout.write(`${Array.from({ length: 20 }, () => "x".repeat(16 * 1024)).join("\n")}\n`);
expect(runtime.getLogs("noisy")).toHaveLength(16);
child.stderr.write("y".repeat(20 * 1024));
await runtime.stopPluginById("noisy");
const logs = runtime.getLogs("noisy");
expect(logs).toHaveLength(16);
expect(logs.at(-1)).toMatchObject({ stream: "stderr", message: "y".repeat(16 * 1024) });
});
it("captures output emitted during initialization and cleanup across reloads", async () => {
const directory = await createPlugin(
"lifecycle-output",
`export default function contribute(plugin: unknown) {
void plugin;
console.log("initialized");
console.error("initialization warning");
return () => process.stdout.write("cleanup fragment");
}`,
);
const runtime = createTestRuntime();
await runtime.startPlugin("lifecycle-output", directory);
await expect
.poll(() => lifecycleMessages(runtime))
.toEqual(["initialization warning", "initialized"]);
await runtime.stopPluginById("lifecycle-output");
await runtime.startPlugin("lifecycle-output", directory);
await runtime.stopPluginById("lifecycle-output");
const logs = runtime.getLogs("lifecycle-output");
expect(
logs
.filter((entry) => entry.stream === "stdout")
.map((entry) => entry.message)
.filter((message) => message === "initialized" || message === "cleanup fragment"),
).toEqual(["initialized", "cleanup fragment", "initialized", "cleanup fragment"]);
expect(
logs
.filter((entry) => entry.stream === "stderr")
.map((entry) => entry.message)
.filter((message) => message === "initialization warning"),
).toEqual(["initialization warning", "initialization warning"]);
expect(logs.map((entry) => entry.sequence)).toEqual(
Array.from({ length: logs.length }, (_, index) => index + 1),
);
});
it("waits for the old subprocess to exit before starting its replacement", async () => {
const directory = await createPlugin(
"reloadable",
@@ -72,7 +270,7 @@ describe("PluginRuntime", () => {
);
const events: string[] = [];
const children = [createReloadChild("old", events), createReloadChild("new", events)];
const runtime = new PluginRuntime(pino({ level: "silent" }), {
const runtime = createTestRuntime({
spawnChild: () => {
const child = children.shift();
if (!child) throw new Error("Unexpected extra child");
@@ -94,7 +292,7 @@ describe("PluginRuntime", () => {
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("pending", [], ["wait"]);
const runtime = new PluginRuntime(pino({ level: "silent" }), {
const runtime = createTestRuntime({
spawnChild: () => child,
});
await runtime.startPlugin("pending", directory);
@@ -107,6 +305,65 @@ describe("PluginRuntime", () => {
await rejection;
});
it("waits for asynchronous plugin cleanup before stopping", async () => {
const cleanupFile = path.join(tmpdir(), `paseo-plugin-cleanup-${Date.now()}`);
const directory = await createPlugin(
"async-cleanup",
`import { writeFile } from "node:fs/promises";
export default function contribute(plugin: unknown) {
void plugin;
return async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
await writeFile(${JSON.stringify(cleanupFile)}, "cleaned");
};
}`,
);
const runtime = createTestRuntime();
await runtime.startPlugin("async-cleanup", directory);
await runtime.stopPluginById("async-cleanup");
await expect(readFile(cleanupFile, "utf8")).resolves.toBe("cleaned");
await rm(cleanupFile, { force: true });
});
it("does not kill a healthy child while its graceful cleanup is still running", async () => {
vi.useFakeTimers();
try {
const directory = await createPlugin(
"held-cleanup",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const events: string[] = [];
const child = createReloadChild("held-cleanup", events);
const originalSend = child.send.bind(child);
let releaseCleanup = () => undefined;
child.send = (message, callback) => {
if (message.type !== "shutdown") return originalSend(message, callback);
callback?.(null);
events.push("shutdown:held-cleanup");
releaseCleanup = () => {
child.connected = false;
child.kill();
child.killed = false;
};
return true;
};
const runtime = createTestRuntime({ spawnChild: () => child });
await runtime.startPlugin("held-cleanup", directory);
const stopping = runtime.stopPluginById("held-cleanup");
await vi.advanceTimersByTimeAsync(60_000);
expect(child.killed).toBe(false);
releaseCleanup();
await stopping;
expect(events).toContain("shutdown:held-cleanup");
} finally {
vi.useRealTimers();
}
});
it("kills a plugin child that fails initialization", async () => {
const directory = await createPlugin(
"broken",
@@ -142,7 +399,7 @@ describe("PluginRuntime", () => {
return this;
},
};
const runtime = new PluginRuntime(pino({ level: "silent" }), {
const runtime = createTestRuntime({
spawnChild: () => child,
});
@@ -157,7 +414,7 @@ describe("PluginRuntime", () => {
"missing-cleanup",
`export default function contribute(plugin: unknown) { void plugin; }`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const runtime = createTestRuntime();
await expect(runtime.startPlugin("missing-cleanup", directory)).rejects.toThrow(
"must return a cleanup function",
@@ -171,7 +428,7 @@ describe("PluginRuntime", () => {
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugin-examples/linear",
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const runtime = createTestRuntime();
await runtime.startPlugin("linear", directory);
@@ -221,7 +478,7 @@ export default function contribute(plugin: any) {
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const runtime = createTestRuntime();
await runtime.startPlugin("hello", directory);
@@ -255,7 +512,7 @@ export default function contribute(plugin: any) {
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const runtime = createTestRuntime();
await runtime.startPlugin("invalid-output", directory);
@@ -268,7 +525,7 @@ export default function contribute(plugin: any) {
"actual",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const runtime = createTestRuntime();
await runtime.startPlugin("configured", directory);
@@ -283,7 +540,7 @@ export default function contribute(plugin: any) {
);
const events: string[] = [];
const child = createReloadChild("blocked", events);
const runtime = new PluginRuntime(pino({ level: "silent" }), { spawnChild: () => child });
const runtime = createTestRuntime({ spawnChild: () => child });
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
"Plugin start cancelled: blocked",
@@ -298,11 +555,13 @@ export default function contribute(plugin: any) {
"crashing",
`export default function contribute(plugin: unknown) {
void plugin;
process.stdout.write("before crash");
setTimeout(() => process.exit(17), 20);
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const sessions = createTrackedSessionHost();
const runtime = createTestRuntime({ sessionHost: sessions.host });
const crashed = new Promise<string>((resolve) => {
runtime.subscribe((pluginId, error) => {
if (pluginId === "crashing" && error) resolve(error);
@@ -311,7 +570,9 @@ export default function contribute(plugin: any) {
await runtime.startPlugin("crashing", directory);
await expect(crashed).resolves.toBe("Plugin process exited: crashing");
expect(sessions.active.size).toBe(0);
expect(runtime.catalog()).toEqual([]);
expect(runtime.getLogs("crashing").map((entry) => entry.message)).toContain("before crash");
await expect(runtime.invoke("crashing", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
+194 -15
View File
@@ -4,16 +4,27 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import type pino from "pino";
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js";
const ENTRY_FILENAME = "index.tsx";
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
const MAX_LOG_LINE_BYTES = 16 * 1024;
interface PluginOutputStream {
on(event: "data", listener: (chunk: Buffer | string) => void): this;
}
interface PluginChild {
connected: boolean;
killed: boolean;
stdout?: PluginOutputStream | null;
stderr?: PluginOutputStream | null;
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean;
kill(): boolean;
disconnect(): void;
@@ -32,11 +43,95 @@ interface LoadedPlugin {
clientBundle: string;
methods: ReadonlySet<string>;
child: PluginChild;
outputCapture: PluginOutputCapture;
pending: Map<string, PendingInvocation>;
sessionSocket: PluginSessionSocket;
sessionClosed: Promise<void>;
}
interface PluginRuntimeDependencies {
spawnChild?: () => PluginChild;
sessionHost?: PluginPaseoSessionHost;
}
interface PluginLogTail {
entries: PluginLogEntry[];
bytes: number;
nextSequence: number;
}
class PluginOutputCapture {
private readonly pending = new Map<PluginLogEntry["stream"], Buffer>([
["stdout", Buffer.alloc(0)],
["stderr", Buffer.alloc(0)],
]);
private readonly overflowed = new Set<PluginLogEntry["stream"]>();
private readonly lastActivity = new Map<PluginLogEntry["stream"], number>();
private activitySequence = 0;
private flushed = false;
constructor(
child: PluginChild,
private readonly emit: (stream: PluginLogEntry["stream"], message: string) => void,
) {
child.stdout?.on("data", (chunk) => this.write("stdout", chunk));
child.stderr?.on("data", (chunk) => this.write("stderr", chunk));
child.on("close", () => this.flush());
}
private write(stream: PluginLogEntry["stream"], chunk: Buffer | string): void {
if (this.flushed) return;
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
let offset = 0;
while (offset < data.length) {
const newline = data.indexOf(0x0a, offset);
const end = newline === -1 ? data.length : newline;
this.append(stream, data.subarray(offset, end));
if (newline === -1) return;
this.emitLine(stream);
offset = newline + 1;
}
}
private append(stream: PluginLogEntry["stream"], chunk: Buffer): void {
if (chunk.length > 0) this.lastActivity.set(stream, ++this.activitySequence);
const current = this.pending.get(stream) ?? Buffer.alloc(0);
const remaining = MAX_LOG_LINE_BYTES - current.length;
if (chunk.length > remaining) this.overflowed.add(stream);
if (remaining <= 0) return;
this.pending.set(stream, Buffer.concat([current, chunk.subarray(0, remaining)]));
}
private emitLine(stream: PluginLogEntry["stream"]): void {
let line = this.pending.get(stream) ?? Buffer.alloc(0);
if (!this.overflowed.has(stream) && line.at(-1) === 0x0d) line = line.subarray(0, -1);
this.emit(stream, line.toString("utf8"));
this.pending.set(stream, Buffer.alloc(0));
this.overflowed.delete(stream);
this.lastActivity.delete(stream);
}
private flush(): void {
if (this.flushed) return;
this.flushed = true;
const pendingStreams = (["stdout", "stderr"] as const)
.filter(
(stream) => (this.pending.get(stream)?.length ?? 0) > 0 || this.overflowed.has(stream),
)
.sort(
(left, right) => (this.lastActivity.get(left) ?? 0) - (this.lastActivity.get(right) ?? 0),
);
for (const stream of pendingStreams) {
this.emitLine(stream);
}
}
}
export interface PluginPaseoSessionHost {
attachPluginSocket(
pluginId: string,
socket: PluginSessionSocket,
): Promise<{ closed: Promise<void> }>;
}
function resolveWorkerUrl(): URL {
@@ -65,7 +160,7 @@ function spawnPluginChild(): PluginChild {
return fork(fileURLToPath(resolveWorkerUrl()), [], {
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
}) as PluginChild;
}
@@ -93,13 +188,22 @@ async function requireRegularFile(filePath: string, label: string): Promise<void
export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly logTails = new Map<string, PluginLogTail>();
private readonly logger: pino.Logger;
private readonly spawnChild: () => PluginChild;
private sessionHost: PluginPaseoSessionHost | null;
private readonly listeners = new Set<(pluginId: string, error?: string) => void>();
constructor(logger: pino.Logger, dependencies: PluginRuntimeDependencies = {}) {
this.logger = logger.child({ module: "plugins" });
this.spawnChild = dependencies.spawnChild ?? spawnPluginChild;
this.sessionHost = dependencies.sessionHost ?? null;
}
bindPaseoSessionHost(sessionHost: PluginPaseoSessionHost): void {
if (this.plugins.size > 0)
throw new Error("Cannot replace the plugin session host while running");
this.sessionHost = sessionHost;
}
subscribe(listener: (pluginId: string, error?: string) => void): () => void {
@@ -136,6 +240,21 @@ export class PluginRuntime {
.sort((left, right) => left.id.localeCompare(right.id));
}
getLogs(pluginId: string): PluginLogEntry[] {
return (
this.logTails.get(pluginId)?.entries.map((entry) => ({
sequence: entry.sequence,
timestamp: entry.timestamp,
stream: entry.stream,
message: entry.message,
})) ?? []
);
}
clearLogs(pluginId: string): void {
this.logTails.delete(pluginId);
}
async invoke(pluginId: string, method: string, input: unknown): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
@@ -174,8 +293,21 @@ export class PluginRuntime {
const entryPath = path.join(directory, ENTRY_FILENAME);
await requireRegularFile(entryPath, "Plugin entry point");
const bundles = await compilePlugin(entryPath);
const sessionHost = this.sessionHost;
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
const child = this.spawnChild();
const outputCapture = new PluginOutputCapture(child, (stream, message) => {
this.appendLog(pluginId, stream, message);
});
const sessionSocket = new PluginSessionSocket(child);
const pending = new Map<string, PendingInvocation>();
const sessionAttachment = await sessionHost
.attachPluginSocket(pluginId, sessionSocket)
.catch((error) => {
terminatePluginChild(child);
throw error;
});
let loaded: LoadedPlugin | null = null;
let methods: string[];
try {
methods = await new Promise<string[]>((resolve, reject) => {
@@ -191,31 +323,51 @@ export class PluginRuntime {
reject(error);
};
child.on("message", (message) => {
if (message.type === "ready") {
if (message.type === "paseo_frame") {
sessionSocket.receive(message.data, message.isBinary);
} else if (message.type === "paseo_close") {
sessionSocket.peerClosed();
} else if (message.type === "ready") {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(message.methods);
} else if (message.type === "fatal") {
fail(new Error(message.error));
} else if (loaded) {
this.handleChildMessage(loaded, message);
}
});
child.on("close", () => fail(new Error(`Plugin ${pluginId} exited during initialization`)));
void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(fail);
child.on("close", () => {
sessionSocket.peerClosed();
if (!loaded) {
fail(new Error(`Plugin ${pluginId} exited during initialization`));
return;
}
void this.handleChildClose(loaded);
});
void send(child, {
type: "initialize",
pluginId,
bundle: bundles.serverBundle,
}).catch(fail);
});
} catch (error) {
sessionSocket.close();
await sessionAttachment.closed;
terminatePluginChild(child);
throw error;
}
const loaded: LoadedPlugin = {
loaded = {
id: pluginId,
clientBundle: bundles.clientBundle,
methods: new Set(methods),
child,
outputCapture,
pending,
sessionSocket,
sessionClosed: sessionAttachment.closed,
};
child.on("message", (message) => this.handleChildMessage(loaded, message));
child.on("close", () => this.handleChildClose(loaded));
this.logger.info({ pluginId, methods }, "Loaded plugin");
return loaded;
}
@@ -230,29 +382,34 @@ export class PluginRuntime {
else pending.reject(new Error(message.error));
}
private handleChildClose(loaded: LoadedPlugin): void {
if (this.plugins.get(loaded.id) === loaded) {
private async handleChildClose(loaded: LoadedPlugin): Promise<void> {
loaded.sessionSocket.peerClosed();
const wasPublished = this.plugins.get(loaded.id) === loaded;
if (wasPublished) {
this.plugins.delete(loaded.id);
this.notify(loaded.id, `Plugin process exited: ${loaded.id}`);
}
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
await loaded.sessionClosed;
if (wasPublished) this.notify(loaded.id, `Plugin process exited: ${loaded.id}`);
}
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
if (loaded.child.killed) return;
let didClose = false;
if (loaded.child.killed) {
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
return;
}
const closed = new Promise<void>((resolve) =>
loaded.child.on("close", () => {
didClose = true;
resolve();
}),
);
if (loaded.child.connected) {
await send(loaded.child, { type: "shutdown" }).catch(() => undefined);
}
await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 1_000))]);
if (!didClose) terminatePluginChild(loaded.child);
await closed;
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
}
private rejectPending(loaded: LoadedPlugin, message: string): void {
@@ -263,6 +420,28 @@ export class PluginRuntime {
loaded.pending.clear();
}
private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void {
let tail = this.logTails.get(pluginId);
if (!tail) {
tail = { entries: [], bytes: 0, nextSequence: 1 };
this.logTails.set(pluginId, tail);
}
const entry: PluginLogEntry = {
sequence: tail.nextSequence++,
timestamp: new Date().toISOString(),
stream,
message,
};
tail.entries.push(entry);
tail.bytes += Buffer.byteLength(message);
while (tail.entries.length > MAX_LOG_ENTRIES || tail.bytes > MAX_LOG_BYTES) {
const removed = tail.entries.shift();
if (!removed) break;
tail.bytes -= Buffer.byteLength(removed.message);
}
this.logger.info({ pluginId, ...entry }, "Plugin output");
}
private notify(pluginId: string, error?: string): void {
for (const listener of this.listeners) listener(pluginId, error);
}
@@ -0,0 +1,32 @@
import { fork } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import type { PluginProcessMessage } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js";
describe("PluginSessionSocket child-process transport", () => {
it("round-trips ordered text and binary frames through a real child process", async () => {
const child = fork(
fileURLToPath(new URL("./test-fixtures/plugin-session-echo.cjs", import.meta.url)),
[],
{ serialization: "advanced", stdio: ["ignore", "ignore", "ignore", "ipc"] },
);
const socket = new PluginSessionSocket(child);
const received: Array<string | Uint8Array> = [];
socket.on("message", (data) => received.push(data as string | Uint8Array));
child.on("message", (message: PluginProcessMessage) => {
if (message.type === "paseo_frame") socket.receive(message.data, message.isBinary);
});
try {
socket.send("first");
socket.send(new Uint8Array([1, 2, 3]));
socket.send("last");
await expect.poll(() => received.length).toBe(3);
expect(received).toEqual(["first", new Uint8Array([1, 2, 3]), "last"]);
} finally {
child.kill();
}
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { PluginProcessRequest } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js";
describe("PluginSessionSocket", () => {
it("preserves text and binary frame order across IPC", () => {
const sent: PluginProcessRequest[] = [];
const socket = new PluginSessionSocket({
send(message, callback) {
sent.push(message);
callback?.(null);
return true;
},
});
socket.send("first");
socket.send(new Uint8Array([1, 2, 3]));
socket.send("last");
expect(sent).toEqual([
{ type: "paseo_frame", data: "first", isBinary: false },
{ type: "paseo_frame", data: new Uint8Array([1, 2, 3]), isBinary: true },
{ type: "paseo_frame", data: "last", isBinary: false },
]);
});
it("reports bytes queued in child-process IPC for terminal backpressure", () => {
const callbacks: Array<(error: Error | null) => void> = [];
const socket = new PluginSessionSocket({
send(_message, callback) {
if (callback) callbacks.push(callback);
return true;
},
});
socket.send("hello");
socket.send(new Uint8Array([1, 2, 3]));
expect(socket.bufferedAmount).toBe(8);
callbacks[0]?.(null);
expect(socket.bufferedAmount).toBe(3);
callbacks[1]?.(null);
expect(socket.bufferedAmount).toBe(0);
});
});
@@ -0,0 +1,93 @@
import { Buffer } from "node:buffer";
import type { PluginProcessRequest } from "./plugin-process-protocol.js";
interface PluginProcessSender {
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean;
}
type SocketEvent = "message" | "close" | "error";
type SocketListener = (...args: unknown[]) => void;
function normalizeBinaryFrame(data: Uint8Array | ArrayBuffer): Uint8Array {
if (data instanceof Uint8Array) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
return new Uint8Array(data);
}
function frameByteLength(data: string | Uint8Array): number {
return typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
}
export class PluginSessionSocket {
readyState = 1;
bufferedAmount = 0;
private readonly listeners = new Map<SocketEvent, Set<SocketListener>>();
private readonly onceListeners = new Map<SocketEvent, Set<SocketListener>>();
constructor(private readonly child: PluginProcessSender) {}
send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void): void {
if (this.readyState !== 1) {
callback?.(new Error("Plugin session socket is closed"));
return;
}
const frame = typeof data === "string" ? data : normalizeBinaryFrame(data);
const byteLength = frameByteLength(frame);
this.bufferedAmount += byteLength;
try {
this.child.send(
{ type: "paseo_frame", data: frame, isBinary: typeof frame !== "string" },
(error) => {
this.bufferedAmount = Math.max(0, this.bufferedAmount - byteLength);
callback?.(error ?? undefined);
},
);
} catch (error) {
this.bufferedAmount = Math.max(0, this.bufferedAmount - byteLength);
callback?.(error instanceof Error ? error : new Error(String(error)));
}
}
receive(data: string | Uint8Array, isBinary: boolean): void {
if (this.readyState !== 1) return;
this.emit("message", data, isBinary);
}
close(code?: number, reason?: string): void {
if (this.readyState === 3) return;
this.readyState = 3;
try {
this.child.send({ type: "paseo_close" });
} catch {
// The child has already gone; local session cleanup still has to run.
}
this.emit("close", code, reason);
}
peerClosed(): void {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
}
on(event: SocketEvent, listener: SocketListener): void {
const listeners = this.listeners.get(event) ?? new Set<SocketListener>();
listeners.add(listener);
this.listeners.set(event, listeners);
}
once(event: "close" | "error", listener: SocketListener): void {
const listeners = this.onceListeners.get(event) ?? new Set<SocketListener>();
listeners.add(listener);
this.onceListeners.set(event, listeners);
}
private emit(event: SocketEvent, ...args: unknown[]): void {
for (const listener of this.listeners.get(event) ?? []) listener(...args);
const once = this.onceListeners.get(event);
if (!once) return;
this.onceListeners.delete(event);
for (const listener of once) listener(...args);
}
}
@@ -0,0 +1,4 @@
process.on("message", (message) => {
if (message?.type !== "paseo_frame") return;
process.send?.(message);
});
+20 -2
View File
@@ -426,9 +426,12 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
return new Session(sessionOptions);
}
test("routes plugin management requests and catalog notifications", async () => {
test("routes plugin requests and releases its owned catalog subscription on cleanup", async () => {
const messages: SessionOutboundMessage[] = [];
const listeners = new Set<(pluginId: string) => void>();
const releasePluginSubscription = vi.fn((listener: (pluginId: string) => void) => {
listeners.delete(listener);
});
const plugin = {
id: "example",
path: "/plugins/example",
@@ -437,6 +440,14 @@ test("routes plugin management requests and catalog notifications", async () =>
};
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
listPlugins: () => [plugin],
getLogs: () => [
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
],
installDirectory: async () => plugin,
inspectDirectory: async () => ({ id: "example" }),
reloadPlugin: async () => plugin,
@@ -445,7 +456,7 @@ test("routes plugin management requests and catalog notifications", async () =>
removePlugin: async () => undefined,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
return () => releasePluginSubscription(listener);
},
catalog: () => [{ id: "example", clientBundle: "bundle" }],
invokePluginRpc: async () => ({ ok: true }),
@@ -453,6 +464,11 @@ test("routes plugin management requests and catalog notifications", async () =>
const session = createSessionForTest({ messages, pluginRuntime });
await session.handleMessage({ type: "plugin.list.request", requestId: "list" });
await session.handleMessage({
type: "plugin.logs.get.request",
requestId: "logs",
pluginId: "example",
});
await session.handleMessage({
type: "plugin.directory.install.request",
requestId: "install",
@@ -477,6 +493,7 @@ test("routes plugin management requests and catalog notifications", async () =>
expect(messages.map((message) => message.type)).toEqual([
"plugin.list.response",
"plugin.logs.get.response",
"plugin.directory.install.response",
"plugin.reload.response",
"plugin.disable.response",
@@ -489,6 +506,7 @@ test("routes plugin management requests and catalog notifications", async () =>
});
await session.cleanup();
expect(listeners.size).toBe(0);
expect(releasePluginSubscription).toHaveBeenCalledOnce();
});
describe("session authorization scopes", () => {
+13
View File
@@ -462,6 +462,7 @@ export interface SessionOptions {
daemonConfigStore: DaemonConfigStore;
pluginRuntime?: {
listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[];
getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[];
installDirectory(input: {
path: string;
id?: string;
@@ -1898,6 +1899,18 @@ export class Session {
});
return undefined;
}
if (msg.type === "plugin.logs.get.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
this.emit({
type: "plugin.logs.get.response",
payload: {
requestId: msg.requestId,
pluginId: msg.pluginId,
entries: this.pluginRuntime.getLogs(msg.pluginId),
},
});
return undefined;
}
if (msg.type === "plugin.reload.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.reloadPlugin(msg.pluginId).then((plugin) => {
@@ -48,6 +48,8 @@ interface TestPaseoDaemonOptions {
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
agentProfiles?: AgentProfile[];
autoArchiveAfterMerge?: boolean;
pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"];
plugins?: PaseoDaemonConfig["plugins"];
}
export interface TestPaseoDaemon {
@@ -199,6 +201,8 @@ async function prepareTestDaemonConfig(
downloadTokenTtlMs: options.downloadTokenTtlMs,
agentProfiles: options.agentProfiles,
autoArchiveAfterMerge: options.autoArchiveAfterMerge,
pluginsEnabled: options.pluginsEnabled,
plugins: options.plugins,
};
return { config, paseoHomeRoot, paseoHome, staticDir };
}
@@ -220,6 +220,7 @@ function createWorkspaceAutoNameStub(): WorkspaceAutoName {
function createServer(options?: {
speechReadiness?: SpeechReadinessSnapshot | null;
logger?: ReturnType<typeof createLogger>;
startPaused?: boolean;
}) {
const speechReadiness = options?.speechReadiness ?? null;
const daemonConfigStore = {
@@ -248,7 +249,7 @@ function createServer(options?: {
"/tmp/paseo-test",
createStub<DaemonConfigStore>(daemonConfigStore),
null,
{ allowedOrigins: new Set() },
{ allowedOrigins: new Set(), startPaused: options?.startPaused },
createWorkspaceAutoNameStub(),
undefined,
speechReadiness
@@ -503,6 +504,39 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("gives every plugin socket an exclusively owned session and cleans it immediately", async () => {
const server = createServer();
const firstSocket = new MockSocket();
const firstAttachment = await server.attachPluginSocket("exclusive", firstSocket);
firstSocket.emit("message", JSON.stringify(createHelloMessage("plugin:exclusive")));
const secondSocket = new MockSocket();
const secondAttachment = await server.attachPluginSocket("exclusive", secondSocket);
secondSocket.emit("message", JSON.stringify(createHelloMessage("plugin:exclusive")));
expect(sessionMock.instances).toHaveLength(2);
firstSocket.emit("close", 1000, "plugin stopped");
await firstAttachment.closed;
expect(sessionMock.instances[0]?.cleanup).toHaveBeenCalledOnce();
expect(sessionMock.instances[1]?.cleanup).not.toHaveBeenCalled();
secondSocket.emit("close", 1000, "plugin stopped");
await secondAttachment.closed;
expect(sessionMock.instances[1]?.cleanup).toHaveBeenCalledOnce();
await server.close();
});
test("rejects ordinary sockets that claim the reserved plugin client id", async () => {
const server = createServer();
const socket = new MockSocket();
await server.attachExternalSocket(socket, { transport: "relay" });
socket.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin")));
expect(socket.readyState).toBe(3);
expect(sessionMock.instances).toHaveLength(0);
await server.close();
});
test("passes hello capabilities through to the created session", async () => {
const server = createServer();
const socket = new MockSocket();
@@ -555,6 +589,27 @@ describe("relay external socket reconnect behavior", () => {
}
});
test("accepts plugin startup sessions while application sessions remain paused", async () => {
const server = createServer({ startPaused: true });
const applicationSocket = new MockSocket();
await server.attachExternalSocket(applicationSocket, { transport: "relay" });
expect(applicationSocket.readyState).toBe(3);
const pluginSocket = new MockSocket();
const attachment = await server.attachPluginSocket("startup", pluginSocket);
pluginSocket.emit("message", JSON.stringify(createHelloMessage("plugin:startup")));
expect(sessionMock.instances).toHaveLength(1);
server.beginAcceptingConnections();
const readySocket = new MockSocket();
await attachRelayAndHello({ server, socket: readySocket, clientId: "ready-client" });
expect(sessionMock.instances).toHaveLength(2);
pluginSocket.emit("close", 1000, "done");
await attachment.closed;
await server.close();
});
test("closes pending connection when hello timeout elapses", async () => {
const server = createServer();
@@ -923,6 +978,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.stableProjectIdentity).toBe(true);
expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true);
expect(serverInfo.features?.providersSnapshotCwd).toBe(true);
expect(serverInfo.features?.pluginLogs).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
+158 -54
View File
@@ -144,6 +144,7 @@ interface WebSocketServerConfig {
getHostnames?: () => HostnamesConfig | undefined;
daemonStatusRpc?: boolean;
relayConfig?: boolean;
startPaused?: boolean;
}
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
@@ -426,7 +427,7 @@ export interface WebSocketLike {
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
}
interface TrustedSessionConnection {
interface TrustedSessionConnectionBase {
kind: "trusted";
session: Session;
clientId: string;
@@ -434,9 +435,20 @@ interface TrustedSessionConnection {
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>;
}
interface ReconnectableSessionConnection extends TrustedSessionConnectionBase {
lifecycle: "reconnectable";
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
}
interface PluginSessionConnection extends TrustedSessionConnectionBase {
lifecycle: "ephemeral-plugin";
pluginId: string;
}
type TrustedSessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface HubConnection {
kind: "hub";
session: Session;
@@ -529,7 +541,9 @@ export class VoiceAssistantWebSocketServer {
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
private readonly socketIdentities: Map<WebSocketLike, WebSocketConnectionIdentity> = new Map();
private readonly externalSessionsByKey: Map<string, TrustedSessionConnection> = new Map();
private readonly externalSessionsByKey: Map<string, ReconnectableSessionConnection> = new Map();
private readonly pluginSocketIds = new WeakMap<WebSocketLike, string>();
private readonly pluginSocketCleanup = new WeakMap<WebSocketLike, () => void>();
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined;
@@ -583,7 +597,7 @@ export class VoiceAssistantWebSocketServer {
private readonly browserToolsBroker: BrowserToolsBroker | null;
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
private acceptingConnections = true;
private connectionLifecycle: "starting" | "accepting" | "stopping" = "accepting";
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
private readonly directorySync = new DirectorySyncService();
@@ -638,6 +652,7 @@ export class VoiceAssistantWebSocketServer {
this.workspaceSetupRuntime = workspaceSetupRuntime;
this.advertiseDaemonStatusRpc = wsConfig.daemonStatusRpc !== false;
this.advertiseRelayConfig = wsConfig.relayConfig !== false;
this.connectionLifecycle = wsConfig.startPaused === true ? "starting" : "accepting";
this.serverId = serverId;
if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
throw new MissingDaemonVersionError();
@@ -849,8 +864,8 @@ export class VoiceAssistantWebSocketServer {
hostnames: HostnamesConfig | undefined,
callback: (res: boolean, code?: number, message?: string) => void,
): void {
if (!this.acceptingConnections) {
callback(false, 503, "Server shutting down");
if (this.connectionLifecycle !== "accepting") {
callback(false, 503, "Server not ready");
return;
}
@@ -951,6 +966,29 @@ export class VoiceAssistantWebSocketServer {
await this.attachSocket(ws, undefined, metadata);
}
public async attachPluginSocket(
pluginId: string,
ws: WebSocketLike,
): Promise<{ closed: Promise<void> }> {
if (this.connectionLifecycle === "stopping") {
throw new Error(`Cannot attach plugin session while shutting down: ${pluginId}`);
}
let resolve: () => void = () => undefined;
const closed = new Promise<void>((finish) => {
resolve = finish;
});
this.pluginSocketIds.set(ws, pluginId);
this.pluginSocketCleanup.set(ws, resolve);
try {
await this.attachSocket(ws, undefined, undefined, true);
} catch (error) {
this.pluginSocketIds.delete(ws);
this.finishPluginSocketCleanup(ws);
throw error;
}
return { closed };
}
public async attachHubSocket(
ws: WebSocketLike,
options: {
@@ -959,7 +997,7 @@ export class VoiceAssistantWebSocketServer {
agents: HubExecutionAgents;
},
): Promise<void> {
if (!this.acceptingConnections) {
if (this.connectionLifecycle !== "accepting") {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
return;
}
@@ -990,7 +1028,13 @@ export class VoiceAssistantWebSocketServer {
}
public prepareForShutdown(): void {
this.acceptingConnections = false;
this.connectionLifecycle = "stopping";
}
public beginAcceptingConnections(): void {
if (this.connectionLifecycle === "starting") {
this.connectionLifecycle = "accepting";
}
}
public async close(): Promise<void> {
@@ -1029,7 +1073,11 @@ export class VoiceAssistantWebSocketServer {
const cleanupPromises: Promise<void>[] = [];
for (const connection of uniqueConnections) {
if (connection.kind === "trusted" && connection.externalDisconnectCleanupTimeout) {
if (
connection.kind === "trusted" &&
connection.lifecycle === "reconnectable" &&
connection.externalDisconnectCleanupTimeout
) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
connection.externalDisconnectCleanupTimeout = null;
}
@@ -1213,8 +1261,12 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike,
request?: unknown,
metadata?: ExternalSocketMetadata,
allowDuringStartup = false,
): Promise<void> {
if (!this.acceptingConnections) {
if (
this.connectionLifecycle === "stopping" ||
(this.connectionLifecycle === "starting" && !allowDuringStartup)
) {
try {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
} catch {
@@ -1271,8 +1323,9 @@ export class VoiceAssistantWebSocketServer {
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
lifecycle: { kind: "reconnectable" } | { kind: "ephemeral-plugin"; pluginId: string };
}): TrustedSessionConnection {
const { ws, clientId, appVersion, clientCapabilities, connectionLogger } = params;
const { ws, clientId, appVersion, clientCapabilities, connectionLogger, lifecycle } = params;
let connection: TrustedSessionConnection | null = null;
const session = this.createSocketSession({
@@ -1327,7 +1380,7 @@ export class VoiceAssistantWebSocketServer {
hubRelationships: this.hubRelationships ?? undefined,
});
connection = {
const base: TrustedSessionConnectionBase = {
kind: "trusted",
session,
clientId,
@@ -1335,8 +1388,11 @@ export class VoiceAssistantWebSocketServer {
clientCapabilities,
connectionLogger,
sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null,
};
connection =
lifecycle.kind === "ephemeral-plugin"
? { ...base, lifecycle: "ephemeral-plugin", pluginId: lifecycle.pluginId }
: { ...base, lifecycle: "reconnectable", externalDisconnectCleanupTimeout: null };
session.updateClientCapabilities(clientCapabilities, ws);
return connection;
}
@@ -1477,48 +1533,26 @@ export class VoiceAssistantWebSocketServer {
return;
}
const pluginId = this.pluginSocketIds.get(ws);
const expectedPluginClientId = pluginId ? `plugin:${pluginId}` : null;
if (
(expectedPluginClientId !== null && clientId !== expectedPluginClientId) ||
(expectedPluginClientId === null && clientId.startsWith("plugin:"))
) {
this.clearPendingConnection(ws);
pending.connectionLogger.warn({ clientId }, "Rejected reserved plugin clientId");
ws.close(WS_CLOSE_INVALID_HELLO, "Invalid plugin clientId");
return;
}
this.clearPendingConnection(ws);
pending.identity.clientId = clientId;
if (message.appVersion) {
pending.identity.appVersion = message.appVersion;
}
const existing = this.externalSessionsByKey.get(clientId);
const existing = pluginId ? undefined : this.externalSessionsByKey.get(clientId);
if (existing) {
this.incrementRuntimeCounter("helloResumed");
if (existing.externalDisconnectCleanupTimeout) {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
const newAppVersion = message.appVersion ?? null;
if (newAppVersion && newAppVersion !== existing.appVersion) {
existing.appVersion = newAppVersion;
existing.session.updateAppVersion(newAppVersion);
}
const newClientCapabilities = message.capabilities ?? null;
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed
// hello resets membership before server_info so stale retained-session
// state cannot leak. Remove after 2027-01-12.
existing.session.updateClientCapabilities(newClientCapabilities, ws);
if (
JSON.stringify(existing.clientCapabilities ?? null) !==
JSON.stringify(newClientCapabilities ?? null)
) {
existing.clientCapabilities = newClientCapabilities;
this.syncBrowserToolsClientRegistration(existing);
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
pending.identity.sessionId = existing.session.getSessionId();
this.syncBrowserToolsClientRegistration(existing);
this.sendToClient(ws, this.createServerInfoMessage());
pending.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
resumed: true,
totalSessions: this.sessions.size,
},
"Client connected via hello",
);
this.resumeSession({ ws, message, pending, existing });
return;
}
@@ -1530,9 +1564,12 @@ export class VoiceAssistantWebSocketServer {
appVersion: message.appVersion ?? null,
clientCapabilities: message.capabilities ?? null,
connectionLogger,
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
});
this.sessions.set(ws, connection);
this.externalSessionsByKey.set(clientId, connection);
if (connection.lifecycle === "reconnectable") {
this.externalSessionsByKey.set(clientId, connection);
}
pending.identity.sessionId = connection.session.getSessionId();
this.syncBrowserToolsClientRegistration(connection);
this.sendToClient(ws, this.createServerInfoMessage());
@@ -1546,6 +1583,50 @@ export class VoiceAssistantWebSocketServer {
);
}
private resumeSession(params: {
ws: WebSocketLike;
message: WSHelloMessage;
pending: PendingConnection;
existing: ReconnectableSessionConnection;
}): void {
const { ws, message, pending, existing } = params;
this.incrementRuntimeCounter("helloResumed");
if (existing.externalDisconnectCleanupTimeout) {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
const newAppVersion = message.appVersion ?? null;
if (newAppVersion && newAppVersion !== existing.appVersion) {
existing.appVersion = newAppVersion;
existing.session.updateAppVersion(newAppVersion);
}
const newClientCapabilities = message.capabilities ?? null;
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed
// hello resets membership before server_info so stale retained-session
// state cannot leak. Remove after 2027-01-12.
existing.session.updateClientCapabilities(newClientCapabilities, ws);
if (
JSON.stringify(existing.clientCapabilities ?? null) !==
JSON.stringify(newClientCapabilities ?? null)
) {
existing.clientCapabilities = newClientCapabilities;
this.syncBrowserToolsClientRegistration(existing);
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
pending.identity.sessionId = existing.session.getSessionId();
this.syncBrowserToolsClientRegistration(existing);
this.sendToClient(ws, this.createServerInfoMessage());
pending.connectionLogger.info(
{
...toConnectionLogFields(pending.identity),
resumed: true,
totalSessions: this.sessions.size,
},
"Client connected via hello",
);
}
private buildServerInfoStatusPayload(): ServerInfoStatusPayload {
return {
status: "server_info",
@@ -1583,6 +1664,7 @@ export class VoiceAssistantWebSocketServer {
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
plugins: true,
pluginManagement: true,
pluginLogs: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -1756,6 +1838,7 @@ export class VoiceAssistantWebSocketServer {
"Pending client disconnected",
);
this.socketIdentities.delete(ws);
this.finishPluginSocketCleanup(ws);
return;
}
@@ -1772,6 +1855,7 @@ export class VoiceAssistantWebSocketServer {
);
this.socketIdentities.delete(ws);
}
this.finishPluginSocketCleanup(ws);
return;
}
@@ -1783,6 +1867,7 @@ export class VoiceAssistantWebSocketServer {
"Hub session disconnected",
);
connection.session.cleanup();
this.finishPluginSocketCleanup(ws);
return;
}
connection.sockets.delete(ws);
@@ -1791,6 +1876,12 @@ export class VoiceAssistantWebSocketServer {
if (connection.sockets.size === 0) {
this.unregisterBrowserToolsClient(connection.clientId);
if (connection.lifecycle === "ephemeral-plugin") {
this.pluginSocketIds.delete(ws);
await this.cleanupConnection(connection, "Plugin session disconnected");
this.finishPluginSocketCleanup(ws);
return;
}
this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect");
if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
@@ -1831,6 +1922,14 @@ export class VoiceAssistantWebSocketServer {
}
await this.cleanupConnection(connection, "Client disconnected");
this.finishPluginSocketCleanup(ws);
}
private finishPluginSocketCleanup(ws: WebSocketLike): void {
const resolve = this.pluginSocketCleanup.get(ws);
if (!resolve) return;
this.pluginSocketCleanup.delete(ws);
resolve();
}
private async cleanupConnection(
@@ -1838,7 +1937,7 @@ export class VoiceAssistantWebSocketServer {
logMessage: string,
): Promise<void> {
this.incrementRuntimeCounter("sessionCleanup");
if (connection.externalDisconnectCleanupTimeout) {
if (connection.lifecycle === "reconnectable" && connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout);
connection.externalDisconnectCleanupTimeout = null;
}
@@ -1848,9 +1947,11 @@ export class VoiceAssistantWebSocketServer {
this.socketIdentities.delete(socket);
}
connection.sockets.clear();
const existing = this.externalSessionsByKey.get(connection.clientId);
if (existing === connection) {
this.externalSessionsByKey.delete(connection.clientId);
if (connection.lifecycle === "reconnectable") {
const existing = this.externalSessionsByKey.get(connection.clientId);
if (existing === connection) {
this.externalSessionsByKey.delete(connection.clientId);
}
}
this.unregisterBrowserToolsClient(connection.clientId);
@@ -2054,7 +2155,10 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike,
data: Buffer | ArrayBuffer | Buffer[] | string,
): void {
if (!this.acceptingConnections) {
if (
this.connectionLifecycle === "stopping" ||
(this.connectionLifecycle === "starting" && !this.pluginSocketIds.has(ws))
) {
return;
}
@@ -12,6 +12,7 @@ import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./te
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { isSameOrDescendantPath } from "../server/path-utils.js";
import { PluginSessionSocket } from "../server/plugins/session-socket.js";
function deferred<T>(): {
promise: Promise<T>;
@@ -755,6 +756,21 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true);
});
test("uses plugin IPC queued bytes to enter the snapshot backpressure path", async () => {
const socket = new PluginSessionSocket({
send() {
return true;
},
});
socket.send(new Uint8Array(8 * 1024 * 1024));
const { pushOutput, frames } = await setup(() => socket.bufferedAmount);
pushOutput("p".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
});
test("falls back to a snapshot at the byte threshold when no backpressure signal exists", async () => {
// A null reading means the transport (e.g. the multiplexed relay socket) gives
// no signal; we can't distinguish a slow client from a fast one, so we keep the
+21 -1
View File
@@ -1,6 +1,6 @@
---
title: CLI
description: "Paseo CLI reference: manage agents, workspaces, scripts, schedules, daemons, and permissions from your terminal."
description: "Paseo CLI reference: manage agents, workspaces, plugins, scripts, schedules, daemons, and permissions from your terminal."
nav: CLI
order: 3
category: Getting started
@@ -114,6 +114,26 @@ By default, Paseo selects the workspace whose directory is the current directory
The output includes each script's lifecycle and supervised terminal ID. Services also include their assigned port, proxy URL, and health. See [Git worktrees](/docs/worktrees#scripts-and-services) for `paseo.json` configuration.
## Plugins
Create and manage trusted local plugins on a daemon:
```bash
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
`paseo plugin logs <id>` returns the plugin's recent daemon-side stdout and stderr. Add `--json` for
structured entries or `--host <target>` for another daemon. See the
[Plugin reference](/docs/plugins/reference) for installation, trust, lifecycle, and log-retention
behavior.
## Listing agents
```bash
+1 -1
View File
@@ -63,7 +63,7 @@ The daemon validates the complete file before applying anything. It applies runt
paseo daemon restart
```
Runtime-safe settings include relay enablement, MCP settings, browser tools, hostnames, CORS origins, trusted proxies, Git process limits, agent and terminal profiles, provider definitions, metadata generation, the app base URL, and provider catalog timeout. Removing one of these settings applies its omitted-field behavior; removing a provider removes it from future launches.
Runtime-safe settings include relay enablement, MCP settings, browser tools, hostnames, CORS origins, trusted proxies, Git process limits, agent and terminal profiles, provider definitions, metadata generation, the app base URL, provider catalog timeout, and the global plugin switch. Removing one of these settings applies its omitted-field behavior; removing a provider removes it from future launches.
New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config.
+1
View File
@@ -55,6 +55,7 @@ The image runs the daemon and serves the bundled web UI. It does not bundle agen
- [Workspaces](/docs/workspaces), the project, workspace, and session model Paseo is built around.
- [Providers](/docs/providers), what a provider is and how Paseo wraps existing CLIs.
- [Orchestration](/docs/orchestration), let one agent delegate work to other providers and models.
- [Plugins](/docs/plugins), add trusted local surfaces, sidebar actions, daemon behavior, and composer attachments.
- [CLI reference](/docs/cli), every command.
- [Self-hosting the web UI](/docs/web-ui), serve the browser app from your own daemon.
- [GitHub repo](https://github.com/getpaseo/paseo)
+120
View File
@@ -0,0 +1,120 @@
---
title: Plugin quickstart
description: Build, install, and reload a trusted local Paseo plugin with a native sidebar surface.
nav: Quickstart
order: 45
category: Plugins
---
# Plugin quickstart
Paseo plugins add native surfaces, sidebar items, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app.
On the target host, open **Settings → Plugins** and turn on **Enable plugins**. This is the global switch for every configured plugin on that daemon.
You can also change the root `pluginsEnabled` field in the daemon's `config.json`, then apply it without restarting:
```bash
paseo reload --json
```
Enabling starts configured plugins; disabling tears them down. Automation must inspect the current value first and obtain your explicit permission before changing a disabled or omitted value to `true`.
## Create a plugin
Use an absolute path on the daemon machine:
```bash
paseo plugin init /absolute/path/to/counter-plugin
cd /absolute/path/to/counter-plugin
npm install
```
`init` creates a strict TSX project. It does not run the package manager.
Replace `index.tsx` with:
```tsx
import type { PluginContext } from "@paseo/plugin";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function Counter() {
const [count, setCount] = useState(0);
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<Text style={{ fontSize: 48 }}>{count}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increment counter, currently ${count}`}
onPress={() => setCount((value) => value + 1)}
style={{ padding: 16, borderRadius: 12, backgroundColor: "#238253" }}
>
<Text style={{ color: "white", textAlign: "center" }}>Count me in</Text>
</Pressable>
</View>
);
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Counter);
plugin.addSidebarItem({
id: "main",
title: "Counter",
icon: "ListPlus",
surface: "main",
});
return () => undefined;
}
```
The icon is a [Lucide](https://lucide.dev/icons/) icon name. The surface uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients.
## Check and install it
```bash
npm run typecheck
paseo plugin install /absolute/path/to/counter-plugin
paseo plugin ls
```
Open **Counter** in the Paseo sidebar. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
## Edit and reload
Source changes are explicit:
```bash
npm run typecheck
paseo plugin reload counter-plugin
```
A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. A failed reload stays failed and reports its load error; fix the source and reload again.
## Debug backend output
Use normal Node logging in daemon-side handlers and cleanup:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI:
```bash
paseo plugin logs counter-plugin
paseo plugin logs counter-plugin --json
```
The log tail survives reloads and crashes, so inspect it when a plugin fails to start or an RPC
rejects. See [Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and
security behavior.
## Next
- [Plugin reference](/docs/plugins/reference), add daemon behavior, use the Paseo SDK, contribute attachments, and manage lifecycle.
- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins.
+284
View File
@@ -0,0 +1,284 @@
---
title: Plugin reference
description: Local plugin files, contributions, Paseo SDK access, RPCs, attachments, logs, lifecycle, hosts, and CLI commands.
nav: Reference
order: 46
category: Plugins
---
# Plugin reference
Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute:
- React Native surfaces and sidebar items to Paseo clients;
- schema-validated RPC handlers running beside the daemon;
- normal Paseo operations through the TypeScript SDK;
- searchable external resources in the message composer.
Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Backend contributions run in a subprocess with access to the daemon machine, including its files, processes, credentials, and network.
## Project files
`paseo plugin init /absolute/path/to/my-plugin` creates:
```text
my-plugin/
paseo-plugin.json
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
The manifest contains the default plugin ID:
```json
{ "id": "my-plugin" }
```
Plugin, surface, sidebar-item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
## Entry point and cleanup
`index.tsx` default-exports one contribution function. It must return cleanup, even when it has nothing to clean:
```tsx
import type { PluginContext } from "@paseo/plugin";
export default function contribute(plugin: PluginContext) {
// Register contributions here.
return () => undefined;
}
```
Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown.
## Surfaces and sidebar items
Register a component, then point a sidebar item at its surface ID:
```tsx
import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin";
import { Text } from "react-native";
function Main({ host, layout }: PluginSurfaceProps) {
return <Text>{`${host.label} · ${layout.platform}`}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Main);
plugin.addSidebarItem({
id: "main",
title: "My plugin",
icon: "Blocks",
surface: "main",
});
return () => undefined;
}
```
`PluginSurfaceProps` contains:
| Field | Meaning |
| -------- | --------------------------------------------------------- |
| `theme` | Paseo theme values. Validate the keys your surface reads. |
| `host` | Selected host `id` and display `label`. |
| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. Icons use [Lucide](https://lucide.dev/icons/) names.
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances.
## Use the Paseo SDK
Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client.
```tsx
import { usePaseo } from "@paseo/plugin";
import { Pressable, Text } from "react-native";
function PullRequestAction() {
const paseo = usePaseo();
async function createReviewWorkspace() {
const workspace = await paseo.workspaces.create({
title: "Review PR 42",
source: {
kind: "worktree",
cwd: "/absolute/path/to/repository",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
});
await workspace.agents.create({
config: { provider: "codex/gpt-5.5" },
prompt: "Review PR #42.",
});
}
return (
<Pressable accessibilityRole="button" onPress={() => void createReviewWorkspace()}>
<Text>Create review workspace</Text>
</Pressable>
);
}
```
The returned API covers workspaces, agents, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
## Add plugin-specific backend behavior
Use plugin RPC only for work that is not a normal Paseo operation: reading a vendor API, accessing daemon-local resources, or keeping credentials off the client.
Define one contract with Zod, handle it in the subprocess, and call it from the surface:
```tsx
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
import { z } from "zod";
const greeting = defineRpc({
name: "greeting.create",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function GreetingButton() {
const createGreeting = useRpc(greeting);
// Call createGreeting({ name: "Ada" }) from an event or query.
return null;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greeting, async ({ name }, { paseo }) => {
const { config } = await paseo.config.get();
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
});
plugin.addSurface("main", GreetingButton);
return () => undefined;
}
```
Inputs and outputs are validated on both sides. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations.
Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. Backend code can use Node APIs and dependencies installed in the plugin directory.
## Debug backend output
Backend contributions can write to stdout and stderr with normal Node logging:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Paseo captures output emitted during initialization, RPC handlers, cleanup, and process failure.
Protocol traffic uses a separate channel, so `console.log()` cannot corrupt plugin RPCs.
Open **Settings → Plugins → Logs** for the plugin, or inspect the same recent tail from the daemon
CLI:
```bash
paseo plugin logs my-plugin
paseo plugin logs my-plugin --json
paseo plugin logs my-plugin --host <url>
```
The command returns a snapshot rather than following live output. Refresh the settings view or run
the command again for newer entries. Each entry includes its timestamp, stdout or stderr stream,
sequence, and message.
Paseo retains up to 500 entries and 256 KiB per plugin in memory. Individual lines are capped at
16 KiB. Reload, disable, initialization failure, and process failure retain the tail. Removing the
plugin clears it, and a daemon restart starts a new tail. Structured copies are also written to the
daemon log at `$PASEO_HOME/daemon.log`.
Only daemon-side output is captured. Logs from client surfaces remain in the app runtime. Do not log
credentials, access tokens, or other secrets: connected users can read the retained tail, and the
daemon log persists it.
## Add a composer attachment source
An attachment source searches external resources and returns a stable text snapshot for an agent prompt. Keep credentials and vendor calls in the backend handler.
```tsx
import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin";
import { z } from "zod";
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
}
```
Paseo owns the composer menu, search picker, selected pill, draft state, and submission. The `text` value is the complete snapshot sent to the agent.
## Hosts and lifecycle
Plugins are installed per daemon. When the same contribution exists on several connected hosts, Paseo shows one sidebar item and adds a host picker. The selected host supplies the bundle, Paseo API, RPC transport, and query cache. Calls never fall through to another host when the selected host is offline.
Attachment sources remain scoped to each composer's host.
## CLI reference
```bash
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
Pass `--host <url>` to management commands when the target is not the CLI's default daemon. `remove` deletes only the daemon configuration; it never deletes the source directory. The install-time `--id` is the runtime ID and allows the same directory to be installed more than once.
Run `npm run typecheck` before install or reload. Never edit the daemon config directly.
The daemon-wide **Enable plugins** switch lives under **Settings → Plugins**. A configured plugin remains `disabled` until that switch and the plugin's own enabled state are both on.
The switch is the root `pluginsEnabled` field in `config.json`. After changing it, run `paseo reload --json`. Enabling starts every configured plugin whose own `enabled` value is not `false`; disabling tears down all plugins. No daemon restart is required. Manual edits to plugin source entries are not reloaded—use the plugin lifecycle commands for those.
## Load failures
Use `paseo plugin ls` to read the current status and error.
| Symptom | Check |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Sidebar item is missing | The plugin is `running`, the item references an existing surface, the icon name is valid, and the client is on the installation's host. |
| Client module is unavailable | Import only the host-provided client modules listed above. |
| RPC rejects | Check both Zod schemas and the daemon-side handler error. |
| Edited code does not appear | Run `npm run typecheck`, then `paseo plugin reload <id>`. |
| Reload fails | Read `paseo plugin ls` and `paseo plugin logs <id>`, fix the source error, then reload; Paseo does not restore the previous bundle. |
| Plugin exits unexpectedly | Read `paseo plugin logs <id>` for retained initialization, cleanup, stderr, and final crash output. |
+262 -18
View File
@@ -1,12 +1,28 @@
---
name: paseo-plugin
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, or remove a Paseo plugin.
user-invocable: true
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add a native surface or sidebar item; use Paseo from plugin code; add plugin RPCs; or contribute composer attachments.
---
# Paseo plugins
Start by scaffolding a strict, typecheckable TSX project:
Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable.
**User's request:** $ARGUMENTS
## Check current documentation
Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. Select and fetch the current plugin Markdown pages from that index before changing a plugin:
- [Plugin quickstart](https://paseo.sh/docs/plugins.md) ([browser page](https://paseo.sh/docs/plugins))
- [Plugin reference](https://paseo.sh/docs/plugins/reference.md) ([browser page](https://paseo.sh/docs/plugins/reference))
Use the deployed docs when they disagree with this skill. Do not send the user away to read them instead of completing the work.
When working in the Paseo repository, also read `docs/plugins.md` and the relevant example under `plugin-examples/`.
## Create the project
Use an absolute path on the daemon machine. `init` writes files but does not install packages.
```bash
paseo plugin init /absolute/path/to/my-plugin
@@ -14,43 +30,271 @@ cd /absolute/path/to/my-plugin
npm install
```
The generated directory contains `paseo-plugin.json`, `index.tsx`, `paseo-plugin.d.ts`,
`package.json`, and `tsconfig.json`. The manifest contains one default ID:
The generated project contains:
```text
my-plugin/
paseo-plugin.json
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
The manifest supplies the default install ID:
```json
{ "id": "my-plugin" }
```
Default-export one contribution function from `index.tsx`. Use `@paseo/plugin` and the host-provided React, React Native, TanStack Query, and Zod dependencies. The function must return cleanup for resources created by plugin code, even when it has nothing to clean up:
Default-export one contribution function. It must return cleanup, even when there is nothing to clean:
```tsx
import type { PluginContext } from "@paseo/plugin";
import React from "react";
import { Text } from "react-native";
function MainSurface() {
return <Text>Hello from my plugin</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", MainSurface);
// Register contributions here.
return () => undefined;
}
```
Manage the plugin through the daemon CLI:
Cleanup can be async. Use it for timers, watchers, sockets, and other resources created by plugin code. Paseo removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin session, and stops the subprocess when the plugin stops.
## Add a sidebar surface
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item:
```tsx
import type { PluginContext } from "@paseo/plugin";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function Counter() {
const [count, setCount] = useState(0);
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<Text style={{ fontSize: 48 }}>{count}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increment counter, currently ${count}`}
onPress={() => setCount((value) => value + 1)}
>
<Text>Count me in</Text>
</Pressable>
</View>
);
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Counter);
plugin.addSidebarItem({
id: "main",
title: "Counter",
icon: "ListPlus",
surface: "main",
});
return () => undefined;
}
```
Icons are Lucide icon names. `PluginSurfaceProps` provides `theme`, selected `host`, and `layout`. Validate the theme keys the surface reads. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install dependencies locally for typechecking; Paseo supplies these runtime modules.
## Choose the correct API
Use the existing Paseo SDK for normal Paseo operations. Use plugin RPC only for plugin-specific backend behavior.
### Call Paseo from a surface
`usePaseo()` borrows the selected host's current connection. Never create another client inside a surface.
```tsx
import { usePaseo } from "@paseo/plugin";
function PullRequestAction() {
const paseo = usePaseo();
async function createReviewWorkspace() {
const workspace = await paseo.workspaces.create({
title: "Review PR 42",
source: {
kind: "worktree",
cwd: "/absolute/path/to/repository",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
});
await workspace.agents.create({
config: { provider: "codex/gpt-5.5" },
prompt: "Review PR #42.",
});
}
// Wire createReviewWorkspace to a Pressable.
return null;
}
```
The API covers workspaces, agents, providers, and daemon config. It omits connection lifecycle because Paseo owns the connection. Consult the current [SDK reference](https://paseo.sh/docs/sdk/reference.md) for method details.
### Add daemon-side behavior
Define one Zod contract, register its subprocess handler, and call it with `useRpc()`:
```tsx
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
import { z } from "zod";
const greeting = defineRpc({
name: "greeting.create",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function Greeting() {
const createGreeting = useRpc(greeting);
// Use createGreeting({ name: "Ada" }) in a query, mutation, or event.
return null;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greeting, async ({ name }, { paseo }) => {
const { config } = await paseo.config.get();
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
});
plugin.addSurface("main", Greeting);
return () => undefined;
}
```
Inputs and outputs are validated on both sides. Backend handlers receive the same `PaseoApi` as `{ paseo }`; their IPC-backed daemon session lives exactly as long as the subprocess. Backend code can use Node APIs and installed dependencies. Keep credentials, filesystem access, shell commands, and vendor API calls in the handler rather than the client surface.
Use TanStack Query for async request state, caching, and mutations.
### Debug daemon-side behavior
Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;
`console.error()` writes to stderr. Paseo captures both streams without interfering with plugin IPC.
Inspect recent output after install, reload, an RPC failure, or a subprocess crash:
```bash
paseo plugin logs my-plugin
paseo plugin logs my-plugin --json
paseo plugin logs my-plugin --host <url>
```
The same tail is available from **Settings → Plugins → Logs**. It includes initialization, handler,
cleanup, and final crash output. Reload, disable, and process failure retain the tail. Removing the
plugin clears it; restarting the daemon clears the in-memory tail. Structured copies also go to the
daemon log. Never log credentials or other secrets.
## Add a composer attachment source
Define a search RPC and register a declarative source:
```tsx
import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin";
import { z } from "zod";
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
}
```
Return complete text snapshots. Paseo owns the composer menu, picker, pills, drafts, and submission. Credentials and vendor calls stay in the daemon handler.
## Hosts and trust
Plugins are installed per daemon and are trusted, unsandboxed code. Backend code can access files, processes, credentials, and network services on the daemon machine. Client contributions run inside the Paseo app. Do not install a plugin the user has not authorized or source code you have not inspected.
### Check the global switch before installing
Identify the target daemon and inspect its root `pluginsEnabled` value in `config.json`. For the local daemon, `paseo daemon status --json` reports its `home`; the file is `<home>/config.json`. Treat a missing field as `false`. Do not infer the global value from a plugin's `disabled` status, because an individual plugin can also be disabled.
If `pluginsEnabled` is already `true`, continue without asking the user to enable it.
If it is false or absent, stop and ask the user for explicit permission before editing or enabling anything. Include this warning in the request:
> Plugins are trusted, unsandboxed code. Backend plugin code can access your daemon machine, including files, processes, credentials, and network services. Client plugin code runs inside the Paseo app. May I enable plugins on this daemon?
Do not continue unless the user agrees. After permission:
1. Preserve the rest of `config.json` and set the root `pluginsEnabled` field to `true`.
2. Run `paseo reload --json` against that daemon.
3. Require `pluginsEnabled` in `appliedPaths`, or accept an empty `appliedPaths` only after re-reading the file and confirming the live plugin catalog is enabled.
4. Run `paseo plugin ls` and verify the intended plugin reaches `running` after installation.
If the user asks to disable the global switch, set `pluginsEnabled` to `false`, run `paseo reload --json`, and verify configured plugins report `disabled`.
Do not edit a local config when the target is a remote daemon. Perform the edit on the daemon machine, or ask the user to use **Settings → Plugins → Enable plugins**. `paseo reload --host <url>` reloads the remote daemon's own file but does not edit it.
When the same sidebar contribution exists on several connected hosts, Paseo shows it once with a host picker. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host.
## Typecheck and manage
Always typecheck before install or reload:
```bash
npm run typecheck
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin reload my-plugin
paseo plugin ls
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
After source edits, reload explicitly and use the reported load error to correct the plugin. Use `enable`, `disable`, and `remove` for recovery. Never edit `$PASEO_HOME/config.json` directly. Plugins are trusted code and run without sandboxing.
Use `--host <url>` when managing a daemon other than the CLI default. Plugin source edits require `paseo plugin reload`; config changes to the global switch require `paseo reload`. A failed plugin reload stays failed; inspect `paseo plugin ls` for the load error and `paseo plugin logs <id>` for subprocess output, fix the source, typecheck, and reload again. `remove` deletes configuration, never the source directory.
Run `npm run typecheck` before every install or reload. The installed packages and local SDK
declarations exist only for static checking; Paseo provides the runtime modules.
Do not restart the daemon to load source changes. Restarting it can kill the agent performing the work.
## Verify the outcome
After a change:
1. Run `npm run typecheck`.
2. Install or reload the exact runtime ID.
3. Run `paseo plugin ls` and require `running` with no error.
4. Confirm the contribution on the intended host. For UI work, check both a wide client and a compact/mobile client when available.
5. Exercise the changed action or RPC, including its error state.
Common failures:
- Missing sidebar item: wrong host, plugin not `running`, invalid Lucide icon, or sidebar item points to a missing surface.
- Unavailable client module: client bundles can use only the host-provided modules listed above.
- RPC rejection: input or output failed its Zod schema, or the handler threw. Inspect `paseo plugin logs <id>` for handler output.
- Plugin exits or reload fails: inspect `paseo plugin ls` for status and `paseo plugin logs <id>` for initialization, cleanup, or crash output.
- Stale UI: source was edited without `paseo plugin reload <id>`.