feat(plugins): split client and server plugin entries (#4206)

* feat(plugins): phase 1 split runtime entries after green gate

The compiler now builds explicit client and server entries, so runtime boundaries are source-owned instead of registration-name filtering. Compiler and runtime tests prove the entry split, directory and suffix boundaries, client Node import errors, migration failure, and client-only operation without a subprocess.

* feat(plugins): phase 2 run client entries after green gate

Explicit client and server contexts replace the mixed plugin context. The app runner owns every client registration and its idempotent removal, including late contributions and composer pills; focused contribution tests prove the removal contract, and all five migrated examples reached running on the isolated worktree daemon.

* feat(cli): phase 3 scaffold runtime split after green gate

The generated project demonstrates the required shared RPC contract, server handler, client surface, and sidebar wiring. The scaffold test and an isolated init, typecheck, install, and running check prove the phase acceptance path.

* docs(plugins): phase 4 publish migration after green gate

Gate: current docs and the paseo-plugin skill describe only explicit client/server entries. The standalone migration guide maps every former registration, and migration-doc.test.ts proves every client add* method remains represented.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Gate: compiler 13, runtime 24, typecheck, lint, and format passed. The dependency fixture proved node_modules/client was incorrectly treated as a plugin boundary.

* fixup! feat(plugins): phase 2 run client entries after green gate

Gate: client runtime and registry tests, typecheck, lint, format

* fixup! docs(plugins): phase 4 publish migration after green gate

Gate: migration docs test, paragraph audit, typecheck, lint, format

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Gate: compiler organization tests, typecheck, lint, targeted format

* fixup! feat(plugins): phase 2 run client entries after green gate

Gate: shared SDK tests, runtime tests, typecheck, lint, targeted format

* fixup! feat(cli): phase 3 scaffold runtime split after green gate

* fixup! docs(plugins): phase 4 publish migration after green gate

* fixup! feat(plugins): phase 1 split runtime entries after green gate

* docs(plugins): rewrite public plugin docs for runtime entries and mobile guardrails

* fixup! feat(cli): phase 3 scaffold runtime split after green gate

The scaffold now keeps DOM globals out of the program and declares only window.open inside client/web.ts. The scaffold test invokes tsc in a fresh process, proves the generated project passes, and proves a stray document access fails.

* fixup! docs(plugins): phase 4 publish migration after green gate

The internal guide and plugin skill now forbid both the DOM lib and triple-slash DOM references. They direct web adapters to declare only the globals used by client/web.ts.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Removing the obsolete Babel parser changes the locked npm dependency graph. The macOS Nix desktop gate reported the new fixed-output hash, which this commit records.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Reject relative imports that escape the plugin root while continuing to skip resolved node_modules internals. The compiler regression test proves the escaped import is rejected and the dependency-internal fixture remains accepted.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Classify absolute imports and reject plugin-authored paths that escape into node_modules. Dependency internals remain exempt based on their importer path. Red-first compiler regressions cover both bypasses, and the existing dependency fixture remains green.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Classify canonical esbuild resolution results so dependency-relative and symlink imports cannot escape client/server boundaries. The two compiler regressions failed before the fix and pass afterward.

* test(ci): synchronize flaky state transitions

Wait for repository watcher registration before emitting buffered ref events, let sidebar order polling retry unlaid-out rows, and wait for the inactive browser parking state before screenshot capture. These changes directly address the three observed CI failures; the affected server, Playwright, and desktop browser tests pass locally.

* test(ci): synchronize Mermaid completion layout assertion

The Playwright streaming acceptance test exposed a completion remount between visibility and layout sampling. Check the existing completion promise around the measurement so that transition is not reported as diagram loss.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Resolve bare package imports before boundary classification so symlinked dependency entries cannot expose server modules to the client bundle. The focused regression proves the bypass and the compiler file passes 20/20.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Allow canonical paths only within a matching linked package root while preserving runtime and containment checks for imports that leave it. The linked-dependency regression fails before the fix and compiler tests pass 21/21 afterward.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Reject matching package manifests that contain the plugin or live inside it, so linked-dependency roots cannot exempt plugin or workspace files. The ancestor-manifest regression fails before the fix and compiler tests pass 22/22 afterward.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Validate plugin-local lexical boundaries before canonical linked-root exemptions and require remembered-root imports to originate inside that root. The linked-root bypass regression fails before the fix and compiler tests pass 23/23 afterward.
This commit is contained in:
Mohamed Boudra
2026-09-02 19:11:45 +02:00
committed by GitHub
parent fa1f01ebbc
commit 53d824a320
79 changed files with 2436 additions and 1361 deletions
+89 -52
View File
@@ -1,9 +1,8 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
client slash commands, timeline items, composer pills, app themes, and composer attachment sources
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime.
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
@@ -42,15 +41,19 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
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:
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
directories, and local typechecking support. At least one entry is required.
```text
my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
package.json
tsconfig.json
index.client.tsx
index.server.ts
client/greeting.tsx
server/greeting.ts
shared/greeting.ts
```
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
@@ -136,16 +139,19 @@ log credentials or tokens.
## Contribute behavior and UI
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
code lives behind filename boundaries:
Default export one contribution function from each runtime entry. Keep the entries to registration
wiring. Runtime code lives behind directory boundaries:
| Suffix | Owns |
| -------------- | -------------------------------------------------------------------- |
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
| Path | Owns |
| -------------------------------- | -------------------------------------------------------------------- |
| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. |
| `shared/` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -154,28 +160,45 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text`
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
server bundle.
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
Native calls such as `StyleSheet.create` belong in `client/`.
The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference lib="dom" />`, so
browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
pattern.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { Greeting } from "./greeting.client";
import { createGreeting } from "./greeting.server";
import { greetRpc } from "./greeting.shared";
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greetRpc } from "./shared/greeting";
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, createGreeting);
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
export default function contribute(server: PluginServerContext) {
server.handle(greetRpc, createGreeting);
return () => {};
}
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { Greeting } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
return () => {};
}
```
@@ -232,10 +255,10 @@ Plugins do not receive Expo Router or workspace-layout store access.
## Contribute composer pills
Register a headless client entrypoint, then add and remove targeted pills from that client
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
client state without mounting a panel or surface.
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
surface.
```tsx
export function contributeClient(client: PluginClientContext) {
@@ -266,8 +289,8 @@ export function contributeClient(client: PluginClientContext) {
}
```
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
`addComposerPill` returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
@@ -306,21 +329,22 @@ await paseo.agents.ref(agentId).timeline.append({
```
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. The row stays
in canonical history and renders an unavailable placeholder when its renderer is not installed.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
plugin.addClientSlashCommand({
client.addSlashCommand({
name: "review",
description: "Run the review bot",
argumentHint: "[scope]",
@@ -343,13 +367,24 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
credentials and vendor API calls stay in the daemon handler.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { search } from "./issues.server";
import { issues, searchIssues } from "./issues.shared";
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, search);
plugin.addAttachmentSource(issues);
export default function contribute(server: PluginServerContext) {
server.handle(searchIssues, search);
return () => {};
}
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { issues } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
client.addAttachmentSource(issues);
return () => {};
}
```
@@ -368,8 +403,8 @@ contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
for the runtime-patching rules the appearance settings share.
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
that predates it does not, and the plugin fails to start there. Daemons advertise
`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it
cannot evaluate that entry. Daemons advertise
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
a host without it contributes no themes.
@@ -382,6 +417,8 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
`plugin-examples/catppuccin` for a theme.
+1 -1
View File
@@ -1 +1 @@
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
-47
View File
@@ -39056,7 +39056,6 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
@@ -39106,52 +39105,6 @@
"vitest": "^4.1.6"
}
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -119,7 +119,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -121,7 +121,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
@@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.tsx"),
path.join(directory, "index.client.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
@@ -159,13 +159,13 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
@@ -173,7 +173,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
@@ -54,7 +54,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
@@ -26,18 +26,11 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginSource(input: { workspaceId: string; agentId: string }): string {
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -111,23 +104,49 @@ function contributeClient(client) {
};
}
export default function contribute(plugin) {
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
export default function contribute(client) {
client.addSurface("collision", DirectCollisionSurface);
client.addSurface("sidebar-destination", SidebarCollisionSurface);
client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
return contributeClient(client);
}`;
}
const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});`;
const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc";
export default function contribute(server) {
server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
plugin.addClientSide(contributeClient);
return () => {};
}`;
async function writePluginSources(
directory: string,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)),
writeFile(path.join(directory, "index.server.ts"), pluginServerSource),
writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource),
]);
}
async function searchCommands(page: Page, query: string) {
@@ -181,10 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
});
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
@@ -260,10 +279,10 @@ test.describe("plugin workspace panels and Command Center", () => {
modeId: "load-test",
});
const navigationAgentId = agent.id;
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: navigationAgentId,
});
await primaryClient.reloadPlugin(PLUGIN_ID);
await runCommand(page, "Open direct collision surface");
+7 -1
View File
@@ -30,10 +30,16 @@ export async function expectDiagramRemainsRenderedWhileStreaming(
completion: Promise<void>,
): Promise<void> {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
const completed = completion.then(() => true);
let turnCompleted = false;
const completed = completion.then(() => {
turnCompleted = true;
return true;
});
for (;;) {
expect(await diagram.isVisible()).toBe(true);
if (turnCompleted) return completion;
const box = await diagram.boundingBox();
if (turnCompleted) return completion;
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
expect(
box?.height,
@@ -1,16 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
import type { PluginComposerPillProps } from "@getpaseo/plugin";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { startPluginClientSide } from "./lifecycle";
import { pluginComposerPillStore } from "./store";
import type { InstalledPlugin } from "./types";
import { createPluginClientRuntime } from "./client-runtime";
import { pluginComposerPillStore } from "./composer-pills/store";
function Pill(_props: PluginComposerPillProps) {
return null;
}
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
function installation(): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
@@ -21,7 +21,7 @@ function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): I
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -39,19 +39,10 @@ afterEach(() => {
});
describe("plugin client-side lifecycle", () => {
it("lets client code add and remove a targeted composer pill at runtime", async () => {
const captured: { client?: PluginClientContext } = {};
let cleanupCount = 0;
const plugin = installation((context) => {
captured.client = context;
return () => {
cleanupCount += 1;
};
});
it("lets the client runtime add and remove a targeted composer pill", () => {
const plugin = installation();
installations.push(plugin);
const stop = startPluginClientSide(plugin, daemonClient);
const client = captured.client;
if (!client) throw new Error("Client lifecycle did not start");
const client = createPluginClientRuntime(plugin, daemonClient);
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
const remove = client.addComposerPill({
@@ -73,23 +64,10 @@ describe("plugin client-side lifecycle", () => {
remove();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
await stop();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
expect(cleanupCount).toBe(1);
});
it("rejects duplicate pills only within the same plugin target", () => {
const plugin = installation(() => () => undefined);
const plugin = installation();
installations.push(plugin);
const contribution = {
id: "review-ready",
@@ -1,25 +1,21 @@
import type {
PluginCleanup,
PluginClientContext,
PluginClientOpenPanelOptions,
} from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin";
import {
createPluginAgentActionContext,
createPluginCapabilities,
createPluginWorkspaceActionContext,
} from "../actions";
import { createPluginClientStateSource } from "../client-state/source";
import { createPluginNavigation } from "../navigation";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
import { pluginComposerPillStore } from "./store";
} from "./actions";
import { createPluginClientStateSource } from "./client-state/source";
import type { PluginClientRuntime } from "./evaluate";
import { createPluginNavigation } from "./navigation";
import { pluginComposerPillStore } from "./composer-pills/store";
import { createPluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
export function startPluginClientSide(
export function createPluginClientRuntime(
installation: InstalledPlugin,
daemonClient: DaemonClient,
): PluginCleanup {
if (!installation.clientSide) return () => undefined;
): PluginClientRuntime {
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
if (!runtime) throw new Error("Plugin host is offline");
const state = createPluginClientStateSource(installation.serverId);
@@ -28,7 +24,7 @@ export function startPluginClientSide(
runtime,
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
);
const context: PluginClientContext = {
return {
...capabilities,
addComposerPill(contribution) {
return pluginComposerPillStore.add(installation, contribution);
@@ -37,25 +33,6 @@ export function startPluginClientSide(
openClientPanel({ installation, runtime, state, panelId, options });
},
};
let cleanup: PluginCleanup;
try {
cleanup = installation.clientSide(context);
if (typeof cleanup !== "function") {
throw new Error("Plugin client-side entrypoint must return a cleanup function");
}
} catch (error) {
pluginComposerPillStore.removeInstallation(installation);
throw error;
}
let stopped = false;
return async () => {
if (stopped) return;
stopped = true;
pluginComposerPillStore.removeInstallation(installation);
await cleanup();
};
}
function openClientPanel(input: {
@@ -97,7 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
onSelect: onAgentSelect,
},
],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
+89 -18
View File
@@ -1,5 +1,19 @@
import { describe, expect, it } from "vitest";
import { evaluatePluginClientBundle } from "./evaluate";
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
paseo: {},
async rpc() {},
openSurface() {},
openPanel() {},
addComposerPill() {
return () => undefined;
},
} as unknown as PluginClientRuntime;
function evaluatePluginClientBundle(id: string, source: string) {
return runPluginClientBundle(id, source, runtime);
}
function bundle(body: string): string {
return `(function() {
@@ -10,6 +24,73 @@ function bundle(body: string): string {
}
describe("evaluatePluginClientBundle", () => {
it("returns idempotent removers for every client registration", () => {
let pillCount = 0;
const plugin = runPluginClientBundle(
"removals",
bundle(`
function Component() { return null; }
const schema = { safeParse(value) { return { success: true, data: value }; } };
globalThis.__pluginRemovals = [
plugin.addSurface("main", Component),
plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }),
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
plugin.addTimelineRenderer({ kind: "card", version: 1, schema, Component }),
];
`),
{
...runtime,
addComposerPill() {
pillCount += 1;
return () => {
pillCount -= 1;
};
},
},
);
const removals = Reflect.get(globalThis, "__pluginRemovals") as Array<() => void>;
expect(
[
plugin.surfaces,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
plugin.attachmentSources,
plugin.themes,
plugin.timelineTransformers,
plugin.timelineRenderers,
].every((items) => items.length === 1),
).toBe(true);
expect(pillCount).toBe(1);
for (const remove of removals) {
remove();
remove();
}
expect(
[
plugin.surfaces,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
plugin.attachmentSources,
plugin.themes,
plugin.timelineTransformers,
plugin.timelineRenderers,
].every((items) => items.length === 0),
).toBe(true);
expect(pillCount).toBe(0);
Reflect.deleteProperty(globalThis, "__pluginRemovals");
});
it("collects timeline transformers and renderers", () => {
const plugin = evaluatePluginClientBundle(
"reports",
@@ -212,26 +293,16 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate Command Center item: review");
});
it("collects one explicit client-side entrypoint", () => {
it("runs the client entry with the full runtime context", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
if (!plugin.paseo || !plugin.rpc || !plugin.openSurface || !plugin.openPanel || !plugin.addComposerPill) {
throw new Error("missing client runtime");
}
`),
);
expect(plugin.clientSide).toBeTypeOf("function");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
plugin.addClientSide(contributeClient);
`),
),
).toThrow("Plugin has more than one client-side entrypoint");
expect(plugin.id).toBe("review");
});
it("rejects duplicate attachment source ids", () => {
@@ -397,11 +468,11 @@ describe("evaluatePluginClientBundle", () => {
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
it("resolves shared RPC helpers from @getpaseo/plugin", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin/server");
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin");
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
const module = { exports: {} };
module.exports.default = function(plugin) {
+131 -59
View File
@@ -10,7 +10,8 @@ import {
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginClientContribution,
type PluginCleanup,
type PluginClientContext,
type PluginClientSlashCommandContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
@@ -23,7 +24,6 @@ import {
useWorkspace,
useRpc,
} from "@getpaseo/plugin";
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { Icon, resolvePluginIcon } from "./icons";
@@ -68,14 +68,23 @@ function requireId(value: string, label: string): string {
return id;
}
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
const collector: PluginRegistrationCollector = {
export type PluginClientRuntime = Pick<
PluginClientContext,
"paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill"
>;
export function runPluginClientBundle(
id: string,
bundle: string,
runtime: PluginClientRuntime,
onChange: () => void = () => undefined,
): EvaluatedPlugin {
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -90,14 +99,38 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
const themeIds = new Set<string>();
const timelineTransformerIds = new Set<string>();
const timelineRendererIds = new Set<string>();
const pluginContext = createPluginContext({
const removals = new Set<PluginCleanup>();
let setupComplete = false;
const notifyChange = () => {
if (setupComplete) onChange();
};
function register<T>(items: T[], item: T, release: () => void): PluginCleanup {
items.push(item);
notifyChange();
let active = true;
const remove = () => {
if (!active) return;
active = false;
const index = items.indexOf(item);
if (index !== -1) items.splice(index, 1);
release();
removals.delete(remove);
notifyChange();
};
removals.add(remove);
return remove;
}
const pluginContext: PluginClientContext = {
...runtime,
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
if (typeof Component !== "function")
throw new Error(`Surface ${normalizedId} is not a component`);
surfaceIds.add(normalizedId);
collector.surfaces.push({ id: normalizedId, Component });
return register(collector.surfaces, { id: normalizedId, Component }, () =>
surfaceIds.delete(normalizedId),
);
},
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
@@ -107,12 +140,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
sidebarItemIds.add(normalizedId);
collector.sidebarItems.push({
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
});
return register(
collector.sidebarItems,
{
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
},
() => sidebarItemIds.delete(normalizedId),
);
},
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
const normalizedId = requireId(contribution.id, "workspace panel id");
@@ -132,13 +169,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
collector.workspacePanels.push({
...contribution,
id: normalizedId,
title,
icon,
locations,
});
return register(
collector.workspacePanels,
{
...contribution,
id: normalizedId,
title,
icon,
locations,
},
() => workspacePanelIds.delete(normalizedId),
);
},
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
const normalizedId = requireId(contribution.id, "Command Center item id");
@@ -161,15 +202,19 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
collector.commandCenterItems.push({
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
});
return register(
collector.commandCenterItems,
{
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
},
() => commandCenterItemIds.delete(normalizedId),
);
},
addClientSlashCommand(contribution: PluginClientSlashCommandContribution) {
addSlashCommand(contribution: PluginClientSlashCommandContribution) {
const name = requireId(contribution.name, "client slash command name");
if (clientSlashCommandNames.has(name)) {
throw new Error(`Duplicate client slash command: ${name}`);
@@ -183,19 +228,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Client slash command ${name} has no callback`);
}
clientSlashCommandNames.add(name);
collector.clientSlashCommands.push({
...contribution,
name,
description,
argumentHint: contribution.argumentHint.trim(),
});
},
addClientSide(contribution: PluginClientContribution) {
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
if (typeof contribution !== "function") {
throw new Error("Plugin client-side entrypoint is not a function");
}
collector.clientSide = contribution;
return register(
collector.clientSlashCommands,
{
...contribution,
name,
description,
argumentHint: contribution.argumentHint.trim(),
},
() => clientSlashCommandNames.delete(name),
);
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
@@ -216,21 +258,25 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
collector.attachmentSources.push({
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
});
return register(
collector.attachmentSources,
{
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
},
() => attachmentSourceIds.delete(normalizedId),
);
},
addTheme(contribution: PluginThemeContribution) {
const normalizedId = requireId(contribution.id, "theme id");
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
themeIds.add(normalizedId);
collector.themes.push(theme);
return register(collector.themes, theme, () => themeIds.delete(normalizedId));
},
addTimelineTransformer(contribution: PluginTimelineTransformerContribution) {
const normalizedId = requireId(contribution.id, "timeline transformer id");
@@ -252,7 +298,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Invalid timeline transformer id: ${contribution.id}`);
}
timelineTransformerIds.add(normalizedId);
collector.timelineTransformers.push(contribution);
return register(collector.timelineTransformers, contribution, () =>
timelineTransformerIds.delete(normalizedId),
);
},
addTimelineRenderer(contribution: PluginTimelineRendererContribution) {
const kind = requireId(contribution.kind, "timeline renderer kind");
@@ -270,9 +318,23 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Timeline renderer ${rendererId} is not a component`);
}
timelineRendererIds.add(rendererId);
collector.timelineRenderers.push({ ...contribution, kind });
return register(collector.timelineRenderers, { ...contribution, kind }, () =>
timelineRendererIds.delete(rendererId),
);
},
});
addComposerPill(contribution) {
const removePill = runtime.addComposerPill(contribution);
let active = true;
const remove = () => {
if (!active) return;
active = false;
removePill();
removals.delete(remove);
};
removals.add(remove);
return remove;
},
};
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
@@ -291,8 +353,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@getpaseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") {
return {};
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
@@ -308,8 +370,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
const cleanup = setup(pluginContext);
if (typeof cleanup !== "function") {
const entryCleanup = setup(pluginContext);
if (typeof entryCleanup !== "function") {
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
}
@@ -321,7 +383,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
} catch (error) {
try {
void Promise.resolve(cleanup()).catch((cleanupError) => {
void Promise.resolve(entryCleanup()).catch((cleanupError) => {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
});
} catch (cleanupError) {
@@ -329,6 +391,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
throw error;
}
setupComplete = true;
let stopped = false;
const cleanup = async () => {
if (stopped) return;
stopped = true;
try {
await entryCleanup();
} finally {
for (const remove of removals) remove();
}
};
return {
id,
cleanup,
@@ -337,7 +410,6 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSlashCommands: collector.clientSlashCommands,
clientSide: collector.clientSide,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
@@ -5,10 +5,10 @@ import { renderHook } from "@testing-library/react";
import { expect, it, vi } from "vitest";
vi.mock("./evaluate", () => ({
evaluatePluginClientBundle: vi.fn(),
runPluginClientBundle: vi.fn(),
}));
vi.mock("./composer-pills/lifecycle", () => ({
startPluginClientSide: vi.fn(),
vi.mock("./client-runtime", () => ({
createPluginClientRuntime: vi.fn(),
}));
import { usePluginInstallations } from "./registry";
+46 -2
View File
@@ -1,5 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import { pluginRegistry } from "./registry";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { pluginRegistry as registry } from "./registry";
vi.mock("./navigation", () => ({
createPluginNavigation: () => ({}),
}));
vi.mock("./client-runtime", () => ({
createPluginClientRuntime: () => ({
paseo: {},
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
}),
}));
const daemonClient = {} as DaemonClient;
const pluginRegistry = {
getSnapshot: registry.getSnapshot,
subscribe: registry.subscribe,
removeHost: registry.removeHost.bind(registry),
installCatalog(
serverId: string,
catalog: Parameters<typeof registry.installCatalog>[1],
options: { replacePluginId?: string } = {},
) {
return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient });
},
};
function bundle(marker: string): string {
return `(function() {
@@ -29,6 +57,10 @@ function timelineBundle(marker: string): string {
})`;
}
function installedPluginIds(): string[] {
return pluginRegistry.getSnapshot().map(({ id }) => id);
}
afterEach(() => {
pluginRegistry.removeHost("host-a");
pluginRegistry.removeHost("host-b");
@@ -36,6 +68,18 @@ afterEach(() => {
});
describe("PluginRegistry", () => {
it("publishes synchronous setup once after storing the installation", () => {
const snapshots: string[][] = [];
const unsubscribe = pluginRegistry.subscribe(() => {
snapshots.push(installedPluginIds());
});
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
unsubscribe();
expect(snapshots).toEqual([["example"]]);
});
it("reports timeline contribution changes", () => {
const first = timelineBundle("one");
expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe(
+24 -23
View File
@@ -1,8 +1,8 @@
import { useMemo, useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { startPluginClientSide } from "./composer-pills/lifecycle";
import { evaluatePluginClientBundle } from "./evaluate";
import { createPluginClientRuntime } from "./client-runtime";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
interface CatalogPlugin {
@@ -33,8 +33,8 @@ class PluginRegistry {
catalog: CatalogPlugin[],
options: {
replacePluginId?: string;
client?: DaemonClient;
} = {},
client: DaemonClient;
},
): boolean {
const previous = this.byHost.get(serverId) ?? [];
const previousTimelineBundles = previous
@@ -58,6 +58,7 @@ class PluginRegistry {
const installed = catalog.flatMap((entry) => {
const key = `${serverId}/${entry.id}`;
try {
if (!entry.clientBundle) return [];
const existing = preserved.find(
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
);
@@ -65,29 +66,29 @@ class PluginRegistry {
this.evaluationErrors.delete(key);
return [existing];
}
const queryClient = new QueryClient();
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
const installation: InstalledPlugin = {
...evaluated,
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
queryClient,
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
if (installation.clientSide) {
if (!options.client) throw new Error("Plugin client runtime is unavailable");
let clientCleanup;
try {
clientCleanup = startPluginClientSide(installation, options.client);
} catch (error) {
queryClient.clear();
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
throw error;
}
installation.cleanup = async () => {
await clientCleanup();
await evaluated.cleanup();
};
}
const evaluated = runPluginClientBundle(
entry.id,
entry.clientBundle,
createPluginClientRuntime(installation, options.client),
() => this.publish(),
);
Object.assign(installation, evaluated);
this.evaluationErrors.delete(key);
return [installation];
} catch (error) {
@@ -21,7 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -26,7 +26,7 @@ function installation(
})),
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
+1 -1
View File
@@ -54,7 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes,
timelineTransformers: [],
@@ -17,7 +17,7 @@ function plugin(input: {
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [
@@ -17,6 +17,15 @@ vi.mock("@/constants/layout", () => ({
vi.mock("../navigation", () => ({
createPluginNavigation: () => ({}),
}));
vi.mock("../client-runtime", () => ({
createPluginClientRuntime: () => ({
paseo: {},
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
}),
}));
vi.mock("../icons", () => ({
Icon: () => null,
resolvePluginIcon: () => () => null,
@@ -96,6 +105,7 @@ const recoveredTimelineItem = { ...timelineItem, data: { label: "Recovered" } };
const roots: Array<ReturnType<typeof createRoot>> = [];
const containers: HTMLElement[] = [];
const daemonClient = {} as DaemonClient;
beforeEach(() => {
vi.stubGlobal("React", React);
@@ -111,7 +121,9 @@ afterEach(() => {
describe("PluginTimelineItemView", () => {
it("validates and renders the matching plugin component", () => {
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }]);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], {
client: daemonClient,
});
const markup = renderToStaticMarkup(
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
@@ -123,7 +135,9 @@ describe("PluginTimelineItemView", () => {
it("contains renderer crashes to one timeline item", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }]);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], {
client: daemonClient,
});
const container = document.createElement("div");
containers.push(container);
document.body.appendChild(container);
@@ -148,7 +162,9 @@ describe("PluginTimelineItemView", () => {
it("recovers when a streaming item's data changes", async () => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }]);
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], {
client: daemonClient,
});
const container = document.createElement("div");
containers.push(container);
document.body.appendChild(container);
+1 -3
View File
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientContribution,
PluginClientSlashCommandContribution,
PluginCleanup,
PluginComposerPillContribution,
@@ -26,8 +25,7 @@ export interface EvaluatedPlugin {
sidebarItems: PluginSidebarContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands?: PluginClientSlashCommandContribution[];
clientSide: PluginClientContribution | null;
clientSlashCommands: PluginClientSlashCommandContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
timelineTransformers: PluginTimelineTransformerContribution[];
@@ -1,7 +1,21 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { pluginRegistry } from "../registry";
import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations";
vi.mock("../navigation", () => ({
createPluginNavigation: () => ({}),
}));
vi.mock("../client-runtime", () => ({
createPluginClientRuntime: () => ({
paseo: {},
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
}),
}));
function install(locations: readonly ("workspace" | "explorer")[]) {
const bundle = `(function() {
return { default: function(plugin) {
@@ -16,7 +30,9 @@ function install(locations: readonly ("workspace" | "explorer")[]) {
return function() {};
}};
})`;
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }]);
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], {
client: {} as DaemonClient,
});
return pluginRegistry.getSnapshot()[0]!;
}
@@ -23,7 +23,7 @@ function installed(): InstalledPlugin {
},
],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -1,11 +1,27 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { tmpdir } from "node:os";
import ts from "typescript";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
async function typecheckPlugin(directory: string): Promise<void> {
const configPath = path.join(directory, "tsconfig.json");
try {
await execFileAsync(process.execPath, [tscPath, "--noEmit", "-p", configPath], {
cwd: directory,
});
} catch (error) {
const failure = error as { stderr?: string; stdout?: string };
throw new Error(`${failure.stdout ?? ""}${failure.stderr ?? ""}`, { cause: error });
}
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
@@ -20,17 +36,12 @@ describe("plugin scaffold", () => {
await scaffoldPluginDirectory(directory);
const configPath = path.join(directory, "tsconfig.json");
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
expect(loaded.error).toBeUndefined();
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([]);
const config = JSON.parse(await readFile(configPath, "utf8")) as {
compilerOptions: { lib: string[]; types: string[] };
};
expect(config.compilerOptions.lib).toEqual(["ES2023"]);
expect(config.compilerOptions.types).toEqual(["react"]);
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin",
});
@@ -53,12 +64,38 @@ describe("plugin scaffold", () => {
},
});
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
'from "./main.client"',
await expect(readFile(path.join(directory, "index.client.tsx"), "utf8")).resolves.toContain(
'from "./client/greeting"',
);
await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain(
"Hello from my plugin",
await expect(readFile(path.join(directory, "index.server.ts"), "utf8")).resolves.toContain(
'from "./server/greeting"',
);
await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain(
"useRpc(greetingRpc)",
);
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.not.toContain(
`/// <reference lib="dom" />`,
);
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
`declare const window: { open(url: string, target: string, features: string): unknown };`,
);
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
`Platform.OS === "web"`,
);
await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain(
`openExternal("https://paseo.sh")`,
);
await expect(readFile(path.join(directory, "server/greeting.ts"), "utf8")).resolves.toContain(
'"Hello, " + name + "!"',
);
await expect(readFile(path.join(directory, "shared/greeting.ts"), "utf8")).resolves.toContain(
'name: "greeting.create"',
);
await writeFile(path.join(directory, "client/greeting.tsx"), '\ndocument.title = "x";\n', {
flag: "a",
});
await expect(typecheckPlugin(directory)).rejects.toThrow("Cannot find name 'document'.");
});
it("typechecks client and server Paseo API access", async () => {
@@ -68,8 +105,8 @@ describe("plugin scaffold", () => {
await scaffoldPluginDirectory(directory);
await Promise.all([
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@getpaseo/plugin/server";
path.join(directory, "shared", "inspect.ts"),
`import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const inspect = defineRpc({
@@ -80,13 +117,13 @@ export const inspect = defineRpc({
`,
),
writeFile(
path.join(directory, "inspect.server.ts"),
path.join(directory, "server", "inspect.ts"),
`import type { PluginHandlerContext } from "@getpaseo/plugin/server";
import type { output as ZodOutput } from "zod";
import { inspect } from "./inspect.shared";
import type { RpcInput } from "@getpaseo/plugin";
import { inspect } from "../shared/inspect";
export async function inspectConfig(
_input: ZodOutput<typeof inspect.input>,
_input: RpcInput<typeof inspect>,
{ paseo }: PluginHandlerContext,
) {
return { configured: Boolean((await paseo.config.get()).config) };
@@ -94,7 +131,7 @@ export async function inspectConfig(
`,
),
writeFile(
path.join(directory, "main.client.tsx"),
path.join(directory, "client", "main.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
@@ -107,7 +144,7 @@ import {
usePaseo,
useWorkspace,
} from "@getpaseo/plugin";
import { inspect } from "./inspect.shared";
import { inspect } from "../shared/inspect";
export function Surface({ navigation }: PluginSurfaceProps) {
const paseo = usePaseo();
@@ -155,23 +192,21 @@ export function contributeClient(client: PluginClientContext) {
`,
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { AgentPanel, contributeClient, Surface } from "./main.client";
import { inspectConfig } from "./inspect.server";
import { inspect } from "./inspect.shared";
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
import { AgentPanel, contributeClient, Surface } from "./client/main";
import { inspect } from "./shared/inspect";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspect, inspectConfig);
plugin.addSurface("main", Surface);
plugin.addWorkspacePanel({
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Surface);
client.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: AgentPanel,
});
plugin.addCommandCenterItem({
client.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
@@ -182,25 +217,25 @@ export default function contribute(plugin: PluginContext) {
openPanel("review");
},
});
plugin.addClientSide(contributeClient);
return contributeClient(client);
}
`,
),
writeFile(
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
import { inspectConfig } from "./server/inspect";
import { inspect } from "./shared/inspect";
export default function contribute(server: PluginServerContext) {
server.handle(inspect, inspectConfig);
return () => {};
}
`,
),
]);
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([]);
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
}, 20_000);
it("refuses to write into a non-empty directory", async () => {
+92 -12
View File
@@ -8,7 +8,8 @@ const TSCONFIG = {
target: "ES2020",
module: "ESNext",
moduleResolution: "Bundler",
lib: ["ES2023", "DOM"],
lib: ["ES2023"],
types: ["react"],
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
@@ -19,20 +20,60 @@ const TSCONFIG = {
include: ["**/*.ts", "**/*.tsx"],
};
const ENTRY = `import type { PluginContext } from "@getpaseo/plugin";
import { MainSurface } from "./main.client";
const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin";
import { GreetingSurface } from "./client/greeting";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", MainSurface);
export default function contribute(client: PluginClientContext) {
client.addSurface("greeting", GreetingSurface);
client.addSidebarItem({
id: "greeting",
title: "Greeting",
icon: "MessageCircle",
surface: "greeting",
});
return () => {};
}
`;
const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@getpaseo/plugin";
import React, { useMemo } from "react";
import { Text, View } from "react-native";
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
export function MainSurface({ theme, layout }: PluginSurfaceProps) {
export default function contribute(server: PluginServerContext) {
server.handle(greetingRpc, createGreeting);
return () => {};
}
`;
const SHARED_GREETING = `import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const greetingRpc = defineRpc({
name: "greeting.create",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
`;
const SERVER_GREETING = `import type { RpcInput } from "@getpaseo/plugin";
import { greetingRpc } from "../shared/greeting";
export function createGreeting({ name }: RpcInput<typeof greetingRpc>) {
return { message: "Hello, " + name + "!" };
}
`;
const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin";
import { useRpc } from "@getpaseo/plugin";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { greetingRpc } from "../shared/greeting";
import { openExternal } from "./web";
export function GreetingSurface({ theme, layout }: PluginSurfaceProps) {
const createGreeting = useRpc(greetingRpc);
const greeting = useMutation({ mutationFn: createGreeting });
const styles = useMemo(
() => ({
screen: {
@@ -41,17 +82,49 @@ export function MainSurface({ theme, layout }: PluginSurfaceProps) {
backgroundColor: theme.colors.surface0,
},
text: { color: theme.colors.foreground },
button: { padding: 12, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.text}>Hello from my plugin</Text>
<Text style={styles.text}>{greeting.data?.message ?? "Ask the daemon for a greeting."}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Create greeting"
style={styles.button}
onPress={() => greeting.mutate({ name: "Paseo" })}
>
<Text style={styles.buttonText}>Create greeting</Text>
</Pressable>
<Pressable
accessibilityRole="link"
accessibilityLabel="Open the Paseo website"
style={styles.button}
onPress={() => openExternal("https://paseo.sh")}
>
<Text style={styles.buttonText}>Open paseo.sh</Text>
</Pressable>
</View>
);
}
`;
const CLIENT_WEB = `import { Linking, Platform } from "react-native";
// This plugin typechecks without the DOM library. Declare only what this module uses.
declare const window: { open(url: string, target: string, features: string): unknown };
export async function openExternal(url: string): Promise<void> {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
await Linking.openURL(url);
}
`;
export interface PluginScaffold {
id: string;
directory: string;
@@ -88,9 +161,16 @@ export async function scaffoldPluginDirectory(
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["index.ts", ENTRY],
["main.client.tsx", CLIENT_SURFACE],
["index.client.tsx", CLIENT_ENTRY],
["index.server.ts", SERVER_ENTRY],
["shared/greeting.ts", SHARED_GREETING],
["server/greeting.ts", SERVER_GREETING],
["client/greeting.tsx", CLIENT_GREETING],
["client/web.ts", CLIENT_WEB],
]);
await Promise.all(
["shared", "server", "client"].map((name) => mkdir(path.join(directory, name))),
);
await Promise.all(
[...files].map(([filename, contents]) =>
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
const context = await createE2ETestContext({ timeout: 45_000 });
try {
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
const install = await context.paseo(["plugin", "install", directory, "--json"]);
assert.equal(install.exitCode, 0, install.stderr);
@@ -44,7 +44,7 @@ async function main(): Promise<void> {
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "git-cli-e2e" }),
);
await writeFile(path.join(gitDirectory, "index.ts"), pluginSource);
await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "initial"]);
@@ -58,7 +58,7 @@ async function main(): Promise<void> {
assert.equal(JSON.parse(gitInstall.stdout).source, "git");
await writeFile(
path.join(gitDirectory, "index.ts"),
path.join(gitDirectory, "index.server.ts"),
`${pluginSource}\nconst updated = true;\n`,
);
await git(gitDirectory, ["add", "-A"]);
+13 -1
View File
@@ -580,7 +580,19 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
const responsiveViewport = await readViewport(client, browserId);
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
await page.waitForTimeout(500);
await page.waitForFunction(
({ id, webContentsId }) => {
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
return (
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
webview.parentElement.style.width === "1px" &&
webview.parentElement.style.pointerEvents === "none" &&
webview.getWebContentsId() === webContentsId
);
},
{ id: browserId, webContentsId: firstGuest.webContentsId },
{ timeout: timeoutMs },
);
try {
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
} catch (error) {
+15 -16
View File
@@ -127,7 +127,20 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
@@ -296,7 +309,7 @@ export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginContext {
export interface PluginServerContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
@@ -304,22 +317,8 @@ export interface PluginContext {
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
addClientSide(contribution: PluginClientContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): void;
addTimelineRenderer<Schema extends ZodType>(
contribution: PluginTimelineRendererContribution<Schema>,
): void;
}
export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
+7 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import { defineAttachmentSource, defineRpc } from "./index.js";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js";
import { callPluginRpc, searchPluginAttachments } from "./host.js";
const contract = defineRpc({
@@ -10,6 +10,11 @@ const contract = defineRpc({
});
describe("plugin RPC contracts", () => {
it("derives handler input and output types from a contract", () => {
expectTypeOf<RpcInput<typeof contract>>().toEqualTypeOf<{ value: number }>();
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
});
it("validates input before invoking the daemon", async () => {
let invocations = 0;
const invoke = async () => {
+1 -90
View File
@@ -4,99 +4,10 @@ export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
} from "./runtime-context-bridge.js";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientContribution,
PluginClientSlashCommandContribution,
PluginContext,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginThemeContribution,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
PluginWorkspacePanelContribution,
} from "./contracts.js";
import type { PluginAttachmentSourceContribution } 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";
interface PluginCollector {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
addClientSide(contribution: PluginClientContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void;
addTimelineRenderer(contribution: PluginTimelineRendererContribution): void;
}
export interface PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands: PluginClientSlashCommandContribution[];
clientSide: PluginClientContribution | null;
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
timelineTransformers: PluginTimelineTransformerContribution[];
timelineRenderers: PluginTimelineRendererContribution[];
}
export function createPluginContext(
collector: PluginCollector,
): Pick<
PluginContext,
| "addSurface"
| "addSidebarItem"
| "addWorkspacePanel"
| "addCommandCenterItem"
| "addClientSlashCommand"
| "addClientSide"
| "addAttachmentSource"
| "addTheme"
| "addTimelineTransformer"
| "addTimelineRenderer"
> {
return {
addSurface(id, Component) {
collector.addSurface(id, Component);
},
addSidebarItem(contribution) {
collector.addSidebarItem(contribution);
},
addWorkspacePanel(contribution) {
collector.addWorkspacePanel(contribution);
},
addCommandCenterItem(contribution) {
collector.addCommandCenterItem(contribution);
},
addClientSlashCommand(contribution) {
collector.addClientSlashCommand(contribution);
},
addClientSide(contribution) {
collector.addClientSide(contribution);
},
addAttachmentSource(contribution) {
collector.addAttachmentSource(contribution);
},
addTheme(contribution) {
collector.addTheme(contribution);
},
addTimelineTransformer(contribution) {
collector.addTimelineTransformer(contribution);
},
addTimelineRenderer(contribution) {
collector.addTimelineRenderer(contribution);
},
};
}
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
+11 -7
View File
@@ -1,15 +1,19 @@
import type { ComponentType } from "react";
import type { PluginIconProps } from "./contracts.js";
import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
defineAttachmentSource,
defineRpc,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
type PluginRpcContract,
} from "./server.js";
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export declare const Icon: ComponentType<PluginIconProps>;
export type {
@@ -26,8 +30,8 @@ export type {
PluginClientOpenPanelOptions,
PluginComposerPillContribution,
PluginComposerPillProps,
PluginContribution,
PluginContext,
PluginServerContribution,
PluginServerContext,
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
+23
View File
@@ -0,0 +1,23 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("plugin runtime-entry migration guide", () => {
it("maps every client registration method", async () => {
const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8");
const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1];
if (!context) throw new Error("PluginClientContext was not found");
const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]);
const migration = await readFile(
fileURLToPath(new URL("../../../public-docs/plugins/migration.md", import.meta.url)),
"utf8",
);
const table = migration.slice(
migration.indexOf("| Old registration and location"),
migration.indexOf("## 4. Separate imports"),
);
expect(methods).not.toEqual([]);
for (const method of methods) expect(table).toContain(`client.${method}(`);
});
});
+3
View File
@@ -9,6 +9,9 @@ export interface PluginRpcContract<
output: OutputSchema;
}
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
name: string;
input: InputSchema;
+1 -16
View File
@@ -1,16 +1 @@
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
export type { PluginHandlerContext } from "./contracts.js";
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM"],
"lib": ["ES2023"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
-1
View File
@@ -71,7 +71,6 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
@@ -675,7 +675,7 @@ describe("paseo daemon bootstrap", () => {
JSON.stringify({ id: "startup-rollback" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
path.join(pluginDirectory, "index.server.ts"),
`import { writeFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -9,6 +9,17 @@ import {
} from "./compiler.js";
const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) =>
rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
),
);
});
describe("asar esbuild binary resolution", () => {
it("rewrites an asar package path to the unpacked platform binary", () => {
@@ -64,159 +75,432 @@ describe("asar esbuild binary resolution", () => {
});
});
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("plugin author module externals", () => {
// COMPAT(plugin-sdk-scope): plugins written against the unpublished @paseo/plugin name must
// keep compiling. Drop that case with the specifiers in plugin-sdk-specifiers.ts.
it.each(["@getpaseo/plugin", "@paseo/plugin"])(
"leaves %s/server external in both bundles",
async (sdk) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`import type { PluginContext } from "${sdk}";
import { Icon } from "${sdk}/react-native";
import { defineRpc } from "${sdk}/server";
import { z } from "zod";
const ping = defineRpc({
name: "ping",
input: z.object({}),
output: z.object({ ok: z.boolean() }),
});
function Surface() {
return Icon({ name: "Settings", size: 18 });
}
export default function contribute(plugin: PluginContext) {
plugin.handle(ping, async () => ({ ok: true }));
plugin.addSurface("main", Surface);
return () => undefined;
}
`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain(`${sdk}/react-native`);
expect(clientBundle).toContain(`${sdk}/server`);
expect(serverBundle).toContain(`${sdk}/server`);
expect(serverBundle).not.toContain(`${sdk}/react-native`);
expect(clientBundle).toContain("Settings");
expect(serverBundle).not.toContain("Settings");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
expect(serverBundle).not.toContain("Invalid plugin RPC method");
},
);
});
describe("plugin contribution targets", () => {
it("keeps client contributions out of the server bundle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`export default function contribute(plugin) {
plugin.addTimelineTransformer({
id: "timeline-card",
query: { itemType: "tool_call" },
transform() { return { items: [] }; },
});
plugin.addTimelineRenderer({
kind: "timeline-card",
version: 1,
schema: { safeParse(value) { return { success: true, data: value }; } },
Component() { return null; },
});
plugin.addClientSlashCommand({
name: "review",
description: "Review changes",
argumentHint: "[scope]",
context: "agent",
onSubmit() {},
});
plugin.addClientSide((client) => {
return client.addComposerPill({
id: "composer-card",
title: "Composer card",
workspaceId: "workspace-a",
agentId: "agent-a",
Component() { return null; },
onPress() {},
});
});
return () => undefined;
}
`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("timeline-card");
expect(clientBundle).toContain("composer-card");
expect(clientBundle).toContain("Review changes");
expect(serverBundle).not.toContain("timeline-card");
expect(serverBundle).not.toContain("composer-card");
expect(serverBundle).not.toContain("Review changes");
});
});
describe("plugin client runtime syntax", () => {
it("uses the automatic JSX runtime without a React import", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.tsx");
await writeFile(
entryPath,
async function createSplitPlugin(): Promise<{
directory: string;
client: string;
server: string;
}> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
await Promise.all([
mkdir(path.join(directory, "client")),
mkdir(path.join(directory, "server")),
mkdir(path.join(directory, "shared")),
]);
const client = path.join(directory, "index.client.tsx");
const server = path.join(directory, "index.server.ts");
await Promise.all([
writeFile(
path.join(directory, "shared", "labels.ts"),
`export const clientLabel = "Client contribution";
export const serverLabel = "Server contribution";`,
),
writeFile(
path.join(directory, "client", "surface.tsx"),
`import { Text } from "react-native";
function Surface() {
return <Text>Automatic JSX</Text>;
}
export default function contribute(plugin) {
plugin.addSurface("automatic-jsx", Surface);
import { clientLabel } from "../shared/labels";
export function Surface() { return <Text>{clientLabel}</Text>; }`,
),
writeFile(
path.join(directory, "server", "handler.ts"),
`import { serverLabel } from "../shared/labels";
export function handler() { return { label: serverLabel }; }`,
),
writeFile(
client,
`import { Surface } from "./client/surface";
export default function contribute(client) {
client.addSurface("main", Surface);
return () => undefined;
}`,
),
writeFile(
server,
`import { handler } from "./server/handler";
export default function contribute(server) {
server.handle({ name: "probe" }, handler);
return () => undefined;
}`,
),
]);
return { directory, client, server };
}
`,
);
const { clientBundle } = await compilePlugin(entryPath);
describe("plugin runtime entries", () => {
it("builds each runtime from its own entry and shares neutral modules", async () => {
const entries = await createSplitPlugin();
const { clientBundle, serverBundle } = await compilePlugin(entries);
expect(clientBundle).toContain("Client contribution");
expect(clientBundle).not.toContain("Server contribution");
expect(serverBundle).toContain("Server contribution");
expect(serverBundle).not.toContain("Client contribution");
});
it("uses the automatic JSX runtime without a React import", async () => {
const entries = await createSplitPlugin();
const { clientBundle } = await compilePlugin(entries);
expect(clientBundle).toContain("react/jsx-runtime");
expect(clientBundle).not.toContain("React.createElement");
});
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.tsx");
const entries = await createSplitPlugin();
await writeFile(
entryPath,
`import type { PluginContext } from "@getpaseo/plugin";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("probe", () => {
const refresh = async () => "ready";
return refresh;
});
plugin.handle({ name: "probe" } as never, async () => "ready");
entries.client,
`export default async function contribute() {
await Promise.resolve();
return () => undefined;
}
`,
}`,
);
const { clientBundle } = await compilePlugin(entries);
expect(clientBundle).not.toContain("async function contribute");
expect(clientBundle).toContain("__async");
});
it("rejects node imports from the client entry", async () => {
const entries = await createSplitPlugin();
await writeFile(
entries.client,
`import { readFile } from "node:fs";
export default function contribute() { void readFile; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
"Node module cannot be imported into the plugin client bundle: node:fs",
);
});
it("rejects server directory imports from the client bundle", async () => {
const entries = await createSplitPlugin();
await writeFile(
entries.client,
`import { handler } from "./server/handler";
export default function contribute() { void handler; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
});
it("rejects client directory imports from the server bundle", async () => {
const entries = await createSplitPlugin();
await writeFile(
entries.server,
`import { Surface } from "./client/surface";
export default function contribute() { void Surface; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
"client-only module cannot be imported into the plugin server bundle",
);
});
it("rejects modules imported directly from the plugin root", async () => {
const entries = await createSplitPlugin();
const helper = path.join(entries.directory, "helper.ts");
await writeFile(helper, "export const value = 1;");
await writeFile(
entries.server,
`import { value } from "./helper";
export default function contribute() { void value; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(entries.directory, "helper")}`,
);
});
it("rejects relative imports that escape the plugin root", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-parent-"));
temporaryDirectories.push(parent);
const pluginDirectory = path.join(parent, "plugin");
const server = path.join(pluginDirectory, "index.server.ts");
await mkdir(pluginDirectory);
await Promise.all([
writeFile(path.join(parent, "secret.ts"), `export const secret = "outside";`),
writeFile(
server,
`import { secret } from "../secret";
export default function contribute() { void secret; return () => undefined; }`,
),
]);
await expect(compilePlugin({ client: null, server })).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "secret")}`,
);
});
it("rejects absolute imports from outside the plugin root", async () => {
const entries = await createSplitPlugin();
const outsideDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-outside-"));
temporaryDirectories.push(outsideDirectory);
const outside = path.join(outsideDirectory, "secret.ts");
await writeFile(outside, `export const secret = "outside";`);
await writeFile(
entries.client,
`import { secret } from ${JSON.stringify(outside)};
export default function contribute() { void secret; return () => undefined; }`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).not.toContain("async () =>");
expect(clientBundle).toContain("__async");
expect(serverBundle).toContain("async () =>");
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${outside}`,
);
});
it("rejects plugin-authored relative imports that escape into node_modules", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-node-modules-parent-"));
temporaryDirectories.push(parent);
const pluginDirectory = path.join(parent, "plugin");
const server = path.join(pluginDirectory, "index.server.ts");
const outside = path.join(parent, "node_modules", "secret.ts");
await Promise.all([mkdir(pluginDirectory), mkdir(path.dirname(outside))]);
await Promise.all([
writeFile(outside, `export const secret = "outside";`),
writeFile(
server,
`import { secret } from "../node_modules/secret";
export default function contribute() { void secret; return () => undefined; }`,
),
]);
await expect(compilePlugin({ client: null, server })).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "node_modules", "secret")}`,
);
});
it("keeps nested modules owned by their top-level runtime directory", async () => {
const entries = await createSplitPlugin();
const nestedClient = path.join(entries.directory, "client", "feature", "server");
const nestedServer = path.join(entries.directory, "server", "feature", "client");
const nestedShared = path.join(entries.directory, "shared", "feature");
await Promise.all([
mkdir(nestedClient, { recursive: true }),
mkdir(nestedServer, { recursive: true }),
mkdir(nestedShared, { recursive: true }),
]);
await Promise.all([
writeFile(path.join(nestedShared, "value.ts"), `export const value = "shared nested";`),
writeFile(
path.join(nestedClient, "value.ts"),
`export { value } from "../../../shared/feature/value";`,
),
writeFile(
path.join(nestedServer, "value.ts"),
`export { value } from "../../../shared/feature/value";`,
),
writeFile(
entries.client,
`import { value } from "./client/feature/server/value";
export default function contribute() { void value; return () => undefined; }`,
),
writeFile(
entries.server,
`import { value } from "./server/feature/client/value";
export default function contribute() { void value; return () => undefined; }`,
),
]);
const { clientBundle, serverBundle } = await compilePlugin(entries);
expect(clientBundle).toContain("shared nested");
expect(serverBundle).toContain("shared nested");
});
it("does not classify dependency directories as plugin runtime boundaries", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
await mkdir(path.join(dependency, "client"), { recursive: true });
await Promise.all([
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(
path.join(dependency, "index.js"),
`const { label } = require("./client/label");
module.exports = { label };`,
),
writeFile(
path.join(dependency, "client", "label.js"),
`module.exports = { label: "dependency client directory" };`,
),
writeFile(
entries.server,
`import { label } from "fixture-dependency";
export default function contribute() {
void label;
return () => undefined;
}`,
),
]);
const { serverBundle } = await compilePlugin(entries);
expect(serverBundle).toContain("dependency client directory");
});
it("rejects dependency-relative imports that escape into the opposite runtime", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
await mkdir(dependency, { recursive: true });
await Promise.all([
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(
path.join(dependency, "index.js"),
`module.exports = require("../../server/handler");`,
),
writeFile(
entries.client,
`import handler from "fixture-dependency";
export default function contribute() { void handler; return () => undefined; }`,
),
]);
await expect(compilePlugin(entries)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
});
it("classifies symlinks by their canonical target", async () => {
const entries = await createSplitPlugin();
const link = path.join(entries.directory, "client", "handler.ts");
await symlink(path.join(entries.directory, "server", "handler.ts"), link);
await writeFile(
entries.client,
`import { handler } from "./client/handler";
export default function contribute() { void handler; return () => undefined; }`,
);
await expect(compilePlugin(entries)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
});
it("classifies bare package imports by their canonical target", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
await mkdir(dependency, { recursive: true });
await Promise.all([
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
symlink(
path.join(entries.directory, "server", "handler.ts"),
path.join(dependency, "index.js"),
),
writeFile(
entries.client,
`import { handler } from "fixture-dependency";
export default function contribute() { void handler; return () => undefined; }`,
),
]);
await expect(compilePlugin(entries)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
});
it("allows linked dependencies to resolve within their own package root", async () => {
const entries = await createSplitPlugin();
const linkedPackage = await mkdtemp(path.join(tmpdir(), "paseo-plugin-linked-dependency-"));
temporaryDirectories.push(linkedPackage);
await mkdir(path.join(entries.directory, "node_modules"));
await Promise.all([
writeFile(
path.join(linkedPackage, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(path.join(linkedPackage, "index.js"), `module.exports = require("./value");`),
writeFile(path.join(linkedPackage, "value.js"), `module.exports = "linked dependency";`),
symlink(
linkedPackage,
path.join(entries.directory, "node_modules", "fixture-dependency"),
process.platform === "win32" ? "junction" : "dir",
),
writeFile(
entries.server,
`import value from "fixture-dependency";
export default function contribute() { void value; return () => undefined; }`,
),
]);
const { serverBundle } = await compilePlugin(entries);
expect(serverBundle).toContain("linked dependency");
});
it("does not treat the plugin package manifest as a linked dependency root", async () => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules", "fixture-dependency");
const secret = path.join(entries.directory, "secret.ts");
await mkdir(dependency, { recursive: true });
await Promise.all([
writeFile(
path.join(entries.directory, "package.json"),
JSON.stringify({ name: "fixture-dependency" }),
),
writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(secret, `export const secret = "plugin root";`),
symlink(secret, path.join(dependency, "index.js")),
writeFile(
entries.client,
`import { secret } from "fixture-dependency";
export default function contribute() { void secret; return () => undefined; }`,
),
]);
await expect(compilePlugin(entries)).rejects.toThrow(
`Plugin modules belong in client/, server/, or shared/: ${secret}`,
);
});
it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => {
const entries = await createSplitPlugin();
const linkedPackage = await mkdtemp(path.join(tmpdir(), "paseo-plugin-linked-boundary-"));
temporaryDirectories.push(linkedPackage);
await mkdir(path.join(entries.directory, "node_modules"));
await Promise.all([
writeFile(
path.join(linkedPackage, "package.json"),
JSON.stringify({ name: "fixture-dependency", main: "index.js" }),
),
writeFile(path.join(linkedPackage, "index.js"), `module.exports = "linked dependency";`),
writeFile(path.join(linkedPackage, "secret.ts"), `export const secret = "linked secret";`),
symlink(
linkedPackage,
path.join(entries.directory, "node_modules", "fixture-dependency"),
process.platform === "win32" ? "junction" : "dir",
),
symlink(
path.join(linkedPackage, "secret.ts"),
path.join(entries.directory, "server", "linked-secret.ts"),
),
writeFile(
entries.client,
`import value from "fixture-dependency";
import { secret } from "./server/linked-secret";
export default function contribute() { void value; void secret; return () => undefined; }`,
),
]);
await expect(compilePlugin(entries)).rejects.toThrow(
"server-only module cannot be imported into the plugin client bundle",
);
});
it("builds a single runtime when the other entry is absent", async () => {
const entries = await createSplitPlugin();
const { clientBundle, serverBundle } = await compilePlugin({
client: entries.client,
server: null,
});
expect(clientBundle).toContain("Client contribution");
expect(serverBundle).toBeNull();
});
});
+156 -200
View File
@@ -1,9 +1,7 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { parse } from "@babel/parser";
import type { Plugin } from "esbuild";
import type { OnResolveResult, Plugin } from "esbuild";
import {
PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
PLUGIN_SDK_SPECIFIERS,
@@ -71,196 +69,140 @@ function loadEsbuild(): typeof import("esbuild") {
type PluginBuildTarget = "client" | "server";
interface SourceRange {
start: number;
end: number;
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set([
"addSurface",
"addSidebarItem",
"addWorkspacePanel",
"addCommandCenterItem",
"addClientSlashCommand",
"addClientSide",
"addAttachmentSource",
"addTheme",
"addTimelineTransformer",
"addTimelineRenderer",
]),
};
function registrationName(statement: unknown, contextName: string): string | null {
if (!statement || typeof statement !== "object") return null;
const expression = Reflect.get(statement, "expression");
if (
!expression ||
typeof expression !== "object" ||
Reflect.get(expression, "type") !== "CallExpression"
) {
return null;
}
const callee = Reflect.get(expression, "callee");
if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") {
return null;
}
const object = Reflect.get(callee, "object");
const property = Reflect.get(callee, "property");
if (
!object ||
typeof object !== "object" ||
Reflect.get(object, "type") !== "Identifier" ||
Reflect.get(object, "name") !== contextName ||
!property ||
typeof property !== "object" ||
Reflect.get(property, "type") !== "Identifier"
) {
return null;
}
return String(Reflect.get(property, "name"));
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } {
const defaultExports = programBody.filter(
(statement) =>
statement !== null &&
typeof statement === "object" &&
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
);
if (defaultExports.length !== 1) {
throw new Error("Plugin entry point must have exactly one default export function");
}
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
const declarationType =
declaration !== null && typeof declaration === "object"
? Reflect.get(declaration, "type")
: null;
if (
declarationType !== "FunctionDeclaration" &&
declarationType !== "FunctionExpression" &&
declarationType !== "ArrowFunctionExpression"
) {
throw new Error("Plugin default export must be a function receiving its context");
}
const parameters = Reflect.get(declaration, "params");
const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null;
if (
parameter === null ||
typeof parameter !== "object" ||
Reflect.get(parameter, "type") !== "Identifier"
) {
throw new Error("Plugin default export must receive one named context parameter");
}
const body = Reflect.get(declaration, "body");
if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") {
throw new Error("Plugin default export must have a block body");
}
return { body, contextName: String(Reflect.get(parameter, "name")) };
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function collectRemovedRegistrationRanges(
node: unknown,
contextName: string,
removedNames: ReadonlySet<string>,
ranges: SourceRange[],
): void {
if (node === null || typeof node !== "object") return;
if (Reflect.get(node, "type") === "ExpressionStatement") {
const name = registrationName(node, contextName);
if (name && removedNames.has(name)) {
const start = Reflect.get(node, "start");
const end = Reflect.get(node, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(
`Could not locate plugin context ${name} registration in plugin entry point`,
);
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
ranges.push({ start, end });
return;
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value)
collectRemovedRegistrationRanges(child, contextName, removedNames, ranges);
} else if (value !== null && typeof value === "object") {
collectRemovedRegistrationRanges(value, contextName, removedNames, ranges);
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleTarget(specifier: string): PluginBuildTarget | null {
if (/\.client(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "client";
if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(specifier)) return "server";
return null;
}
function collectOppositeTargetImportRanges(
programBody: unknown[],
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget,
ranges: SourceRange[],
): void {
for (const statement of programBody) {
if (
statement === null ||
typeof statement !== "object" ||
Reflect.get(statement, "type") !== "ImportDeclaration"
) {
continue;
}
const source = Reflect.get(statement, "source");
const specifier =
source !== null && typeof source === "object" ? Reflect.get(source, "value") : null;
if (typeof specifier !== "string") continue;
const importedTarget = moduleTarget(specifier);
if (importedTarget === null || importedTarget === target) continue;
const start = Reflect.get(statement, "start");
const end = Reflect.get(statement, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate ${importedTarget}-only import in plugin entry point`);
}
ranges.push({ start, end });
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
}
function filterEntrypoint(source: string, target: PluginBuildTarget): string {
const ast = parse(source, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const pluginFunction = defaultPluginFunction(ast.program.body);
const ranges: SourceRange[] = [];
collectRemovedRegistrationRanges(
pluginFunction.body,
pluginFunction.contextName,
REGISTRATIONS_REMOVED_BY_TARGET[target],
ranges,
);
collectOppositeTargetImportRanges(ast.program.body, target, ranges);
let output = source;
for (const range of ranges.toSorted((left, right) => right.start - left.start)) {
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
}
return output;
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget,
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (!containsPath(pluginDirectory, lexicalPath)) return null;
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget): Plugin {
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
buildContext.onResolve({ filter: /\.(?:client|server)(?:\.[cm]?[jt]sx?)?$/ }, (args) => {
const importedTarget = moduleTarget(args.path);
if (importedTarget === null || importedTarget === target) return null;
return {
errors: [
{
text: `${importedTarget}-only module cannot be imported into the plugin ${target} bundle: ${args.path}`,
},
],
};
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const lexicalError = lexicalBoundaryError(
args.path,
args.resolveDir,
pluginDirectory,
target,
);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer: args.importer,
namespace: args.namespace,
resolveDir: args.resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
const resolvedPath = resolution.path;
const importedTarget = directoryTarget(resolvedPath, pluginDirectory);
if (importedTarget === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, args.importer) && containsPath(root, resolvedPath),
)
) {
return null;
}
if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) {
const dependencyRoot = findDependencyRoot(resolvedPath, args.path, pluginDirectory);
if (dependencyRoot) {
linkedDependencyRoots.add(dependencyRoot);
return null;
}
}
return moduleBoundaryError(importedTarget, target, resolvedPath);
}
return moduleBoundaryError(importedTarget, target, resolvedPath);
});
},
};
@@ -284,19 +226,16 @@ function exactSpecifierFilter(specifiers: readonly string[]): RegExp {
return new RegExp(`^(${alternatives.join("|")})$`);
}
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
const filter =
target === "server"
? exactSpecifierFilter([
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
])
: /^node:/;
function createUnusedPlatformModulePlugin(): Plugin {
const filter = exactSpecifierFilter([
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
]);
return {
name: `paseo-plugin-${target}-unused-platform-modules`,
name: "paseo-plugin-server-unused-platform-modules",
setup(buildContext) {
buildContext.onResolve({ filter }, (args) => ({
path: args.path,
@@ -311,17 +250,26 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
};
}
function createClientNodeImportPlugin(): Plugin {
return {
name: "paseo-plugin-client-node-imports",
setup(buildContext) {
buildContext.onResolve({ filter: /^node:/ }, (args) => ({
errors: [
{
text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`,
},
],
}));
},
};
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
const source = await readFile(entryPath, "utf8");
const filteredSource = filterEntrypoint(source, target);
const pluginDirectory = path.dirname(entryPath);
const result = await build({
stdin: {
contents: filteredSource,
loader: "tsx",
resolveDir: path.dirname(entryPath),
sourcefile: entryPath,
},
entryPoints: [entryPath],
bundle: true,
format: "cjs",
jsx: target === "client" ? "automatic" : undefined,
@@ -341,7 +289,12 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
"zod",
]
: [...PLUGIN_SDK_SPECIFIERS, "zod"],
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
plugins: [
createRuntimeBoundaryPlugin(target, pluginDirectory),
...(target === "client"
? [createClientNodeImportPlugin()]
: [createUnusedPlatformModulePlugin()]),
],
logLevel: "silent",
treeShaking: true,
write: false,
@@ -351,13 +304,16 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPath: string): Promise<{
clientBundle: string;
serverBundle: string;
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
compileTarget(entryPath, "client"),
compileTarget(entryPath, "server"),
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
}
@@ -16,7 +16,7 @@ async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
roots.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }));
await writeFile(path.join(directory, "index.tsx"), source);
await writeFile(path.join(directory, "index.server.ts"), source);
return directory;
}
@@ -244,7 +244,7 @@ describe("PluginService", () => {
"choose another ID with --id",
);
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!");
await expect(service.reloadPlugin("work-plugin")).rejects.toThrow();
expect(service.catalog()).toEqual([]);
expect(service.listPlugins()).toEqual([
@@ -252,7 +252,7 @@ describe("PluginService", () => {
]);
await writeFile(
path.join(directory, "index.tsx"),
path.join(directory, "index.server.ts"),
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" });
@@ -271,7 +271,7 @@ describe("PluginService", () => {
JSON.stringify({ id: "local-monorepo" }),
);
await writeFile(
path.join(pluginDirectory, "index.ts"),
path.join(pluginDirectory, "index.server.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
const service = createService(home);
@@ -296,7 +296,7 @@ describe("PluginService", () => {
JSON.stringify({ id: "git-update" }),
);
await writeFile(
path.join(repository, "index.ts"),
path.join(repository, "index.server.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
await runGitCommand(["add", "-A"], { cwd: repository });
@@ -323,7 +323,7 @@ describe("PluginService", () => {
}),
);
await writeFile(
path.join(repository, "index.ts"),
path.join(repository, "index.server.ts"),
'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }',
);
await runGitCommand(["add", "-A"], { cwd: repository });
@@ -367,7 +367,7 @@ describe("PluginService", () => {
],
}),
);
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
@@ -442,7 +442,7 @@ describe("PluginService", () => {
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "failed-update" }),
);
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
@@ -488,7 +488,7 @@ describe("PluginService", () => {
]);
await writeFile(
path.join(repository, "index.ts"),
path.join(repository, "index.server.ts"),
"export default () => () => { new Date(); };\n",
);
await runGitCommand(["add", "-A"], { cwd: repository });
@@ -683,7 +683,7 @@ export default function contribute(plugin: unknown) {
const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};");
await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({}));
const missingEntry = await createPlugin("missing-entry", "export default () => () => {};");
await rm(path.join(missingEntry, "index.tsx"));
await rm(path.join(missingEntry, "index.server.ts"));
const startupFailure = await createPlugin(
"startup-failure",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
@@ -693,7 +693,7 @@ export default function contribute(plugin: unknown) {
await expect(service.installDirectory({ path: invalid })).rejects.toThrow();
await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow(
"Plugin entry point is missing",
"Plugin entry points are missing",
);
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded",
@@ -702,7 +702,7 @@ export default function contribute(plugin: unknown) {
expect.objectContaining({
id: "missing-entry",
status: "failed",
error: expect.stringContaining("Plugin entry point is missing"),
error: expect.stringContaining("Plugin entry points are missing"),
}),
expect.objectContaining({
id: "startup-failure",
@@ -22,7 +22,7 @@ async function createRepository(): Promise<string> {
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "managed-example" }),
);
await writeFile(path.join(repository, "index.ts"), "export default () => () => {};\n");
await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n");
await commitAll(repository, "initial");
return repository;
}
@@ -60,7 +60,7 @@ describe("managed Git plugin sources", () => {
const initial = candidate.record.commit;
await writeFile(
path.join(repository, "index.ts"),
path.join(repository, "index.server.ts"),
"export default () => () => { new Date(); };\n",
);
const latest = await commitAll(repository, "update");
@@ -77,7 +77,9 @@ describe("managed Git plugin sources", () => {
if (!prepared.candidate) throw new Error("Expected an update candidate");
const updated = await sources.place("managed-example", prepared.candidate);
sources.commit("managed-example", updated.record);
expect(await readFile(path.join(updated.directory, "index.ts"), "utf8")).toContain("new Date");
expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain(
"new Date",
);
expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest);
await runGitCommand(["tag", "v1", initial], { cwd: repository });
@@ -21,8 +21,8 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy
JSON.stringify({ id: "paseo-api" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
`import { defineRpc, type PluginContext } from "@getpaseo/plugin";
path.join(pluginDirectory, "index.server.ts"),
`import { defineRpc, type PluginServerContext } from "@getpaseo/plugin";
import { z } from "zod";
const create = defineRpc({
@@ -43,8 +43,8 @@ const append = defineRpc({
output: z.object({ seq: z.number(), epoch: z.string() }),
});
export default function contribute(plugin: PluginContext) {
plugin.handle(create, async ({ path }, { paseo }) => {
export default function contribute(server: PluginServerContext) {
server.handle(create, async ({ path }, { paseo }) => {
const workspace = await paseo.workspaces.create({
source: { kind: "directory", path },
title: "Plugin workspace",
@@ -55,11 +55,11 @@ export default function contribute(plugin: PluginContext) {
});
return { workspaceId: workspace.id, agentId: agent.id };
});
plugin.handle(list, async (_input, { paseo }) => {
server.handle(list, async (_input, { paseo }) => {
const result = await paseo.agents.list({ page: { limit: 100 } });
return { agentIds: result.entries.map((entry) => entry.agent.id) };
});
plugin.handle(append, ({ agentId, status }, { paseo }) =>
server.handle(append, ({ agentId, status }, { paseo }) =>
paseo.agents.ref(agentId).timeline.append({
type: "plugin",
id: "review-1",
@@ -144,9 +144,9 @@ test("daemon config reload enables and disables configured plugins without resta
JSON.stringify({ id: "reloadable-plugin" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
`export default function contribute(plugin: unknown) {
void plugin;
path.join(pluginDirectory, "index.server.ts"),
`export default function contribute(server: unknown) {
void server;
return () => undefined;
}`,
);
@@ -1,15 +1,15 @@
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import {
defineAttachmentSource,
defineRpc,
type PluginHandlerContext,
type PluginRpcContract,
} from "@getpaseo/plugin/server";
import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin";
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
import {
isPluginClientOnlySdkSpecifier,
isPluginSdkSpecifier,
isPluginServerTypesSdkSpecifier,
} from "./plugin-sdk-specifiers.js";
import { createPluginClientId } from "./plugin-session-identity.js";
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
@@ -75,6 +75,7 @@ function runtimeRequire(name: string): unknown {
if (isPluginClientOnlySdkSpecifier(name)) {
throw new Error(`${name} is available only in plugin client code`);
}
if (isPluginServerTypesSdkSpecifier(name)) return {};
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
return nodeRequire(name);
}
@@ -10,11 +10,15 @@ export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
"@paseo/plugin/react-native",
] as const;
export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [
"@getpaseo/plugin/server",
"@paseo/plugin/server",
] as const;
export const PLUGIN_SDK_SPECIFIERS = [
"@getpaseo/plugin",
"@getpaseo/plugin/server",
"@paseo/plugin",
"@paseo/plugin/server",
...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS,
...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS,
] as const;
@@ -25,3 +29,7 @@ export function isPluginSdkSpecifier(name: string): boolean {
export function isPluginClientOnlySdkSpecifier(name: string): boolean {
return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name);
}
export function isPluginServerTypesSdkSpecifier(name: string): boolean {
return (PLUGIN_SERVER_TYPES_SDK_SPECIFIERS as readonly string[]).includes(name);
}
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import path from "node:path";
@@ -14,7 +14,7 @@ async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8");
await writeFile(path.join(directory, "index.tsx"), source, "utf8");
await writeFile(path.join(directory, "index.server.ts"), source, "utf8");
return directory;
}
@@ -494,20 +494,57 @@ export default function contribute(plugin: unknown) {
await runtime.stopAll();
});
it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => {
const directory = await createPlugin(
"hello",
`import React from "react";
import { platform } from "node:os";
import { Text } from "react-native";
import { z } from "zod";
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
it("rejects an index.ts plugin with the migration path", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "legacy" })),
writeFile(path.join(directory, "index.ts"), "export default function contribute() {}"),
]);
const runtime = createTestRuntime();
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string(), platform: z.string() }),
});
await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow(
"Plugin entry split is required",
);
await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow(
"https://paseo.sh/docs/plugins/migration",
);
});
it("loads a client-only plugin without spawning a subprocess", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "theme" })),
writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client: any) {
client.addTheme({ id: "theme", name: "Theme", appearance: "dark", colors: {} });
return () => undefined;
}`,
),
]);
const spawnChild = vi.fn();
const runtime = createTestRuntime({ spawnChild });
await runtime.startPlugin("theme", directory);
expect(spawnChild).not.toHaveBeenCalled();
expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme");
await runtime.stopAll();
});
it("loads separate entries, exposes the client bundle, and invokes the server RPC", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await mkdir(path.join(directory, "shared"));
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "hello" }));
await writeFile(
path.join(directory, "index.client.tsx"),
`import React from "react";
import { Text } from "react-native";
import { defineAttachmentSource } from "@getpaseo/plugin";
import { greetRpc } from "./shared/greet";
const attachments = defineAttachmentSource({
id: "issues",
@@ -526,16 +563,34 @@ function ReviewPanel() {
return <Text>Workspace review panel</Text>;
}
export default function contribute(plugin: any) {
plugin.handle(greetRpc, async (input: { name: string }) => ({
export default function contribute(client: any) {
client.addSurface("main", HelloSurface);
client.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
client.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel });
client.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} });
client.addAttachmentSource(attachments);
return () => undefined;
}`,
);
await writeFile(
path.join(directory, "shared", "greet.ts"),
`import { z } from "zod";
import { defineRpc } from "@getpaseo/plugin";
export const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string(), platform: z.string() }),
});`,
);
await writeFile(
path.join(directory, "index.server.ts"),
`import { platform } from "node:os";
import { greetRpc } from "./shared/greet";
export default function contribute(server: any) {
server.handle(greetRpc, async (input: { name: string }) => ({
message: "Hello, " + input.name,
platform: platform(),
}));
plugin.addSurface("main", HelloSurface);
plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel });
plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} });
plugin.addAttachmentSource(attachments);
return () => undefined;
}`,
);
@@ -566,7 +621,7 @@ export default function contribute(plugin: any) {
const directory = await createPlugin(
"legacy-sdk",
`import { z } from "zod";
import { defineRpc } from "@paseo/plugin/server";
import { defineRpc } from "@paseo/plugin";
const pingRpc = defineRpc({
name: "ping",
@@ -591,6 +646,11 @@ export default function contribute(plugin: any) {
it("keeps client and server modules in their target runtime", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
mkdir(path.join(directory, "client")),
mkdir(path.join(directory, "server")),
mkdir(path.join(directory, "shared")),
]);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
@@ -598,21 +658,28 @@ export default function contribute(plugin: any) {
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { Surface } from "./surface.client";
import { inspectRpc } from "./inspect.shared";
import { inspectHost } from "./inspect.server";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspectRpc, inspectHost);
plugin.addSurface("main", Surface);
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
import { Surface } from "./client/surface";
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Surface);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
import { inspectRpc } from "./shared/inspect";
import { inspectHost } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
server.handle(inspectRpc, inspectHost);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "client", "surface.tsx"),
`import React from "react";
import { StyleSheet, Text } from "react-native";
@@ -624,8 +691,8 @@ export function Surface() {
"utf8",
),
writeFile(
path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@getpaseo/plugin/server";
path.join(directory, "shared", "inspect.ts"),
`import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const inspectRpc = defineRpc({
@@ -636,10 +703,10 @@ export const inspectRpc = defineRpc({
"utf8",
),
writeFile(
path.join(directory, "inspect.server.ts"),
path.join(directory, "server", "inspect.ts"),
`import { platform } from "node:os";
import type { z } from "zod";
import { inspectRpc } from "./inspect.shared";
import { inspectRpc } from "../shared/inspect";
export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
return { platform: platform() };
@@ -663,6 +730,10 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
it("rejects server imports from client-only modules", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
mkdir(path.join(directory, "client")),
mkdir(path.join(directory, "server")),
]);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
@@ -670,24 +741,24 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { Surface } from "./surface.client";
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin";
import { Surface } from "./client/surface";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Surface);
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Surface);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
`import { readSecret } from "./secret.server";
path.join(directory, "client", "surface.tsx"),
`import { readSecret } from "../server/secret";
export function Surface() { return readSecret(); }`,
"utf8",
),
writeFile(
path.join(directory, "secret.server.ts"),
path.join(directory, "server", "secret.ts"),
`export function readSecret() { return null; }`,
"utf8",
),
@@ -703,6 +774,11 @@ export function Surface() { return readSecret(); }`,
it("rejects client imports from server-only modules", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await Promise.all([
mkdir(path.join(directory, "client")),
mkdir(path.join(directory, "server")),
mkdir(path.join(directory, "shared")),
]);
await Promise.all([
writeFile(
path.join(directory, "paseo-plugin.json"),
@@ -710,19 +786,19 @@ export function Surface() { return readSecret(); }`,
"utf8",
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { inspect } from "./inspect.server";
import { inspectRpc } from "./inspect.shared";
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin";
import { inspect } from "./server/inspect";
import { inspectRpc } from "./shared/inspect";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspectRpc, inspect);
export default function contribute(server: PluginServerContext) {
server.handle(inspectRpc, inspect);
return () => undefined;
}`,
"utf8",
),
writeFile(
path.join(directory, "inspect.shared.ts"),
path.join(directory, "shared", "inspect.ts"),
`import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const inspectRpc = defineRpc({
@@ -733,13 +809,13 @@ export const inspectRpc = defineRpc({
"utf8",
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import { Surface } from "./surface.client";
path.join(directory, "server", "inspect.ts"),
`import { Surface } from "../client/surface";
export function inspect() { void Surface; return {}; }`,
"utf8",
),
writeFile(
path.join(directory, "surface.client.tsx"),
path.join(directory, "client", "surface.tsx"),
`export function Surface() { return null; }`,
"utf8",
),
+65 -26
View File
@@ -10,9 +10,8 @@ import { readPluginManifest } from "./manifest.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { PluginSessionSocket } from "./session-socket.js";
const ENTRY_FILENAME = "index.ts";
// COMPAT(plugin-index-tsx): added in v0.4, remove after 2027-02-17
const LEGACY_ENTRY_FILENAME = "index.tsx";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
@@ -44,11 +43,11 @@ interface LoadedPlugin {
id: string;
clientBundle: string;
methods: ReadonlySet<string>;
child: PluginChild;
outputCapture: PluginOutputCapture;
child: PluginChild | null;
outputCapture: PluginOutputCapture | null;
pending: Map<string, PendingInvocation>;
sessionSocket: PluginSessionSocket;
sessionClosed: Promise<void>;
sessionSocket: PluginSessionSocket | null;
sessionClosed: Promise<void> | null;
}
interface PluginRuntimeDependencies {
@@ -187,13 +186,33 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
});
}
async function resolveEntryPath(directory: string): Promise<string> {
for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) {
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
throw new Error(`Plugin entry point is missing: ${path.join(directory, ENTRY_FILENAME)}`);
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"Plugin entry split is required: migrate index.ts to index.client.tsx and/or index.server.ts. See https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export class PluginRuntime {
@@ -247,8 +266,8 @@ export class PluginRuntime {
async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const entryPath = await resolveEntryPath(directory);
await compilePlugin(entryPath);
const entryPaths = await resolveEntryPaths(directory);
await compilePlugin(entryPaths);
}
async stopPluginById(pluginId: string): Promise<boolean> {
@@ -286,6 +305,8 @@ export class PluginRuntime {
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
if (!loaded.methods.has(method))
throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`);
const child = loaded.child;
if (!child) throw new Error(`Plugin ${pluginId} has no server entry`);
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -293,7 +314,7 @@ export class PluginRuntime {
reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => {
void send(child, { type: "invoke", requestId, method, input }).catch((error) => {
clearTimeout(timeout);
loaded.pending.delete(requestId);
reject(error);
@@ -316,8 +337,21 @@ export class PluginRuntime {
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const entryPath = await resolveEntryPath(directory);
const bundles = await compilePlugin(entryPath);
const entryPaths = await resolveEntryPaths(directory);
const bundles = await compilePlugin(entryPaths);
const serverBundle = bundles.serverBundle;
if (!serverBundle) {
return {
id: pluginId,
clientBundle: bundles.clientBundle ?? "",
methods: new Set(),
child: null,
outputCapture: null,
pending: new Map(),
sessionSocket: null,
sessionClosed: null,
};
}
const sessionHost = this.sessionHost;
if (!sessionHost) throw new Error("Plugin Paseo session host is not attached");
const child = this.spawnChild();
@@ -375,7 +409,7 @@ export class PluginRuntime {
type: "initialize",
pluginId,
appVersion: this.daemonVersion,
bundle: bundles.serverBundle,
bundle: serverBundle,
}).catch(fail);
});
} catch (error) {
@@ -386,7 +420,7 @@ export class PluginRuntime {
}
loaded = {
id: pluginId,
clientBundle: bundles.clientBundle,
clientBundle: bundles.clientBundle ?? "",
methods: new Set(methods),
child,
outputCapture,
@@ -409,7 +443,7 @@ export class PluginRuntime {
}
private async handleChildClose(loaded: LoadedPlugin): Promise<void> {
loaded.sessionSocket.peerClosed();
loaded.sessionSocket?.peerClosed();
const wasPublished = this.plugins.get(loaded.id) === loaded;
if (wasPublished) {
this.plugins.delete(loaded.id);
@@ -421,23 +455,28 @@ export class PluginRuntime {
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin");
if (loaded.child.killed) {
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
const { child, sessionSocket, sessionClosed } = loaded;
if (!child || !sessionSocket || !sessionClosed) {
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
return;
}
if (child.killed) {
sessionSocket.peerClosed();
await sessionClosed;
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
return;
}
const closed = new Promise<void>((resolve) =>
loaded.child.on("close", () => {
child.on("close", () => {
resolve();
}),
);
if (loaded.child.connected) {
await send(loaded.child, { type: "shutdown" }).catch(() => undefined);
if (child.connected) {
await send(child, { type: "shutdown" }).catch(() => undefined);
}
await closed;
loaded.sessionSocket.peerClosed();
await loaded.sessionClosed;
sessionSocket.peerClosed();
await sessionClosed;
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
}
@@ -893,11 +893,14 @@ describe("WorkspaceGitService checkout observation", () => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
});
watcher.records
.find((record) => record.directory === GIT_DIR)
?.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
});
const [repoWatcher] = getWatcherRecordsForDirectory(watcher, GIT_DIR);
if (!repoWatcher) throw new Error("Repository watcher was not registered");
repoWatcher.callback(null, [
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
]);
releaseFetch.resolve();
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
+2 -1
View File
@@ -3,7 +3,8 @@
This example adds **Catppuccin Mocha** to Settings → Appearance. Paseo ships Catppuccin as a
syntax-highlight theme; this contributes it as an app theme.
A theme is data, so the whole plugin is one `addTheme` call in `index.ts` — no client file, no RPC.
A theme is client data, so the whole plugin is one `addTheme` call in `index.client.ts` and has no
server entry or subprocess.
Register it in `$PASEO_HOME/config.json`:
@@ -1,7 +1,7 @@
import type { PluginContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin";
export default function contribute(plugin: PluginContext) {
plugin.addTheme({
export default function contribute(client: PluginClientContext) {
client.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
@@ -2,3 +2,4 @@
This plugin replaces reasoning rows from the first streaming delta and renders the paced text
inline. Install the directory as `inline-thinking`, then run an agent that streams reasoning.
`index.client.tsx` wires the transformer and renderer to the component in `client/thinking.tsx`.
@@ -1,8 +1,8 @@
import type { PluginContext } from "@getpaseo/plugin";
import { InlineThinking, inlineThinkingSchema } from "./thinking.client";
import type { PluginClientContext } from "@getpaseo/plugin";
import { InlineThinking, inlineThinkingSchema } from "./client/thinking";
export default function contribute(plugin: PluginContext) {
plugin.addTimelineTransformer({
export default function contribute(client: PluginClientContext) {
client.addTimelineTransformer({
id: "inline-thinking",
query: { itemType: "reasoning" },
transform: ({ item, phase }) => ({
@@ -16,7 +16,7 @@ export default function contribute(plugin: PluginContext) {
],
}),
});
plugin.addTimelineRenderer({
client.addTimelineRenderer({
kind: "inline-thinking",
version: 1,
schema: inlineThinkingSchema,
+3 -3
View File
@@ -27,7 +27,7 @@ Restart the development daemon after changing its environment or plugin configur
The split entry point demonstrates the complete attachment-source pattern:
- define the validated search RPC and attachment metadata in `issues.shared.ts`;
- handle it in the daemon subprocess from `issues.server.ts`;
- wire both contributions from `index.ts`;
- define the validated search RPC and attachment metadata in `shared/issues.ts`;
- handle it in the daemon subprocess from `server/issues.ts` and `index.server.ts`;
- register the attachment source from `index.client.ts`;
- keep credentials and vendor API calls out of the client bundle.
+7
View File
@@ -0,0 +1,7 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import { issueAttachments } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
client.addAttachmentSource(issueAttachments);
return () => {};
}
+8
View File
@@ -0,0 +1,8 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { searchIssues } from "./server/issues";
import { searchIssuesRpc } from "./shared/issues";
export default function contribute(server: PluginServerContext) {
server.handle(searchIssuesRpc, searchIssues);
return () => {};
}
-9
View File
@@ -1,9 +0,0 @@
import type { PluginContext } from "@getpaseo/plugin";
import { searchIssues } from "./issues.server";
import { issueAttachments, searchIssuesRpc } from "./issues.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssuesRpc, searchIssues);
plugin.addAttachmentSource(issueAttachments);
return () => {};
}
-11
View File
@@ -1,11 +0,0 @@
import type { PluginAttachmentSearchPayload } from "@getpaseo/plugin/server";
import type { output as ZodOutput } from "zod";
import { searchIssuesRpc } from "./issues.shared";
import { createLinearIssueSearch } from "./linear";
export async function searchIssues({
query,
}: ZodOutput<typeof searchIssuesRpc.input>): Promise<PluginAttachmentSearchPayload> {
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
return linear.search(query);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import { createLinearIssueSearch } from "./linear";
import { createLinearIssueSearch } from "./server/linear";
const issue = {
id: "issue-uuid",
+10
View File
@@ -0,0 +1,10 @@
import type { PluginAttachmentSearchPayload, RpcInput } from "@getpaseo/plugin";
import { searchIssuesRpc } from "../shared/issues";
import { createLinearIssueSearch } from "./linear";
export async function searchIssues({
query,
}: RpcInput<typeof searchIssuesRpc>): Promise<PluginAttachmentSearchPayload> {
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
return linear.search(query);
}
@@ -1,4 +1,4 @@
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server";
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
const SearchPayloadSchema = z.object({
@@ -10,7 +10,7 @@ import {
} from "@getpaseo/plugin";
import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { incrementRpc } from "./increment.shared";
import { incrementRpc } from "../shared/increment";
export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
@@ -1,9 +0,0 @@
import type { output as ZodOutput } from "zod";
import { incrementRpc } from "./increment.shared";
export function increment(input: ZodOutput<typeof incrementRpc.input>) {
return {
value: input.value + 1,
handledBy: "plugin subprocess",
};
}
@@ -0,0 +1,23 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import { contributeClient, ExamplePanel } from "./client/main";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
});
client.addCommandCenterItem({
id: "open-counter",
title: "Open plugin counter",
icon: "Blocks",
context: "workspace",
onSelect({ openPanel }) {
openPanel("counter");
},
});
return contributeClient(client);
}
@@ -0,0 +1,8 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { increment } from "./server/increment";
import { incrementRpc } from "./shared/increment";
export default function contribute(server: PluginServerContext) {
server.handle(incrementRpc, increment);
return () => {};
}
-27
View File
@@ -1,27 +0,0 @@
import type { PluginContext } from "@getpaseo/plugin";
import { contributeClient, ExamplePanel } from "./main.client";
import { increment } from "./increment.server";
import { incrementRpc } from "./increment.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(incrementRpc, increment);
plugin.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
});
plugin.addCommandCenterItem({
id: "open-counter",
title: "Open plugin counter",
icon: "Blocks",
context: "workspace",
onSelect({ openPanel }) {
openPanel("counter");
},
});
plugin.addClientSide(contributeClient);
return () => {};
}
@@ -0,0 +1,9 @@
import type { RpcInput } from "@getpaseo/plugin";
import { incrementRpc } from "../shared/increment";
export function increment(input: RpcInput<typeof incrementRpc>) {
return {
value: input.value + 1,
handledBy: "plugin subprocess",
};
}
@@ -1,4 +1,4 @@
import { defineRpc } from "@getpaseo/plugin/server";
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const incrementRpc = defineRpc({
+3
View File
@@ -12,3 +12,6 @@ calls return `undefined`, leaving Paseo's original timeline entry unchanged.
The transformer is a pure client contribution. It receives each source item and its streaming phase
while Paseo builds the render model, then returns plain plugin item objects. The renderer validates
`data` before Paseo mounts the component.
`index.client.tsx` wires the transformer from `shared/pi-tasks.ts` and renderer from
`client/pi-tasks.tsx`. The plugin has no server entry or subprocess.
@@ -2,7 +2,7 @@ import type { PluginTimelineItemProps } from "@getpaseo/plugin";
import { useMemo } from "react";
import { Text, View } from "react-native";
import type { z } from "zod";
import { piTaskListSchema } from "./pi-tasks";
import { piTaskListSchema } from "../shared/pi-tasks";
type TaskListData = z.output<typeof piTaskListSchema>;
@@ -0,0 +1,18 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import { PiTaskList } from "./client/pi-tasks";
import { piTaskListSchema, transformPiTodoToolCall } from "./shared/pi-tasks";
export default function contribute(client: PluginClientContext) {
client.addTimelineTransformer({
id: "pi-tasks",
query: { itemType: "tool_call" },
transform: transformPiTodoToolCall,
});
client.addTimelineRenderer({
kind: "pi-task-list",
version: 1,
schema: piTaskListSchema,
Component: PiTaskList,
});
return () => {};
}
-18
View File
@@ -1,18 +0,0 @@
import type { PluginContext } from "@getpaseo/plugin";
import { PiTaskList } from "./pi-tasks.client";
import { piTaskListSchema, transformPiTodoToolCall } from "./pi-tasks";
export default function contribute(plugin: PluginContext) {
plugin.addTimelineTransformer({
id: "pi-tasks",
query: { itemType: "tool_call" },
transform: transformPiTodoToolCall,
});
plugin.addTimelineRenderer({
kind: "pi-task-list",
version: 1,
schema: piTaskListSchema,
Component: PiTaskList,
});
return () => {};
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { transformPiTodoToolCall } from "./pi-tasks";
import { transformPiTodoToolCall } from "./shared/pi-tasks";
function completedTodo(output: unknown) {
return {
+163 -97
View File
@@ -9,24 +9,22 @@ category: Plugins
# Plugin quickstart
> **Experimental:** The plugin API is still evolving, so expect breaking changes and updates to
> your plugins as Paseo evolves.
> your plugins as Paseo evolves. See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins)
> for planned contribution surfaces.
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
surfaces and their current status.
A plugin is a TypeScript project installed into one Paseo daemon. It can add
[surfaces and sidebar items](/docs/plugins/reference#surfaces-and-sidebar-items),
[workspace panels](/docs/plugins/reference#workspace-panels),
[Command Center items](/docs/plugins/reference#command-center-items),
[slash commands](/docs/plugins/reference#slash-commands),
[composer pills](/docs/plugins/reference#composer-pills),
[timeline items](/docs/plugins/reference#timeline-items),
[themes](/docs/plugins/reference#contribute-a-theme),
[attachment sources](/docs/plugins/reference#add-a-composer-attachment-source), and
[daemon-side RPCs](/docs/plugins/reference#add-plugin-specific-backend-behavior). Client
contributions run on every Paseo client connected to that daemon, including mobile.
Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Server code and Git preparation commands run unsandboxed with the daemon user's access on the daemon host; client contributions run inside Paseo. Dependencies and future updates are part of that decision. With `--host`, commands run on the remote daemon host.
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`.
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
## Create a plugin
@@ -38,14 +36,99 @@ cd /absolute/path/to/workspace-plugin
npm install
```
`init` creates a strict TypeScript project. It does not run the package manager. `npm install`
installs development dependencies for local typechecking and tests. Paseo supplies the plugin SDK,
React, React Native, TanStack Query, and Zod at runtime; plugins do not need a `build` hook for these
modules. `index.ts` registers contributions; client UI lives in `*.client.tsx` files.
`init` writes a strict TypeScript project and does not run the package manager. `npm install` adds
development dependencies for typechecking and tests only; Paseo supplies the plugin SDK, React,
React Native, TanStack Query, and Zod at runtime.
Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes.
The scaffold is a working plugin: a sidebar surface with a button that asks the daemon for a
greeting through an RPC.
Replace `main.client.tsx` with:
```text
workspace-plugin/
paseo-plugin.json # { "id": "workspace-plugin" }
index.client.tsx # runs in the Paseo app
index.server.ts # runs in a daemon subprocess
client/greeting.tsx # the surface component
client/web.ts # the only file allowed to touch browser APIs
server/greeting.ts # the RPC handler
shared/greeting.ts # the RPC contract, imported by both
package.json
tsconfig.json
```
Each entry default-exports one function that registers contributions and returns a cleanup
function. `index.client.tsx` registers the surface and the sidebar item that opens it:
```tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { GreetingSurface } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
client.addSurface("greeting", GreetingSurface);
client.addSidebarItem({
id: "greeting",
title: "Greeting",
icon: "MessageCircle",
surface: "greeting",
});
return () => {};
}
```
`index.server.ts` registers the handler for the contract in `shared/greeting.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greetingRpc, createGreeting);
return () => {};
}
```
The directory is the boundary. Code under `client/` compiles only into the app bundle, code under
`server/` only into the daemon bundle, and `shared/` into both. Importing across that line, adding
a code file at the root, or importing a `node:` module from client code is a compile error. A
plugin with no daemon-side work can omit `index.server.ts`; a plugin with no UI can omit
`index.client.tsx`.
Client code runs on phones as well as in browsers. The project typechecks without the DOM library,
so `document` and `window` are errors outside `client/web.ts`, which shows how to gate a browser
API behind `Platform.OS` with a native fallback. See
[Cross-platform rules](/docs/plugins/reference#cross-platform-rules) before writing UI.
## Install and try it
Plugins are trusted, unsandboxed code: server code and Git preparation commands run with the daemon
user's access on the daemon machine, and client code runs inside the Paseo app. Installing a plugin
means you trust that codebase, its dependencies, and its future updates.
Turn on **Enable plugins** under **Settings → Plugins** on the daemon you are installing into. It is
the global switch for every plugin on that daemon. It is also the root `pluginsEnabled` field in the
daemon's `config.json`; after editing the file, apply it with `paseo reload --json`. An automated
tool must read the current value and get your explicit permission before turning it on.
Then typecheck and install:
```bash
npm run typecheck
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
`paseo plugin ls` should report the plugin as `running`. Open Paseo, choose **Greeting** in the
sidebar, and press **Create greeting**. The message comes back from the daemon subprocess through
the RPC.
If the sidebar item is missing, check that **Enable plugins** is on, the plugin is `running`, and
the client is viewing the host you installed into. `paseo plugin logs workspace-plugin` shows the
daemon-side output, including load errors.
## Add a workspace panel
A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`:
```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin";
@@ -82,60 +165,67 @@ export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspac
}
```
Replace `index.ts` with:
`useWorkspace` reads the fields the panel renders from the app's cached state, without an RPC and
without re-rendering when unrelated fields change. Every `Text` takes its color from
`theme.colors`, and `layout.compact` drives spacing, so the panel works in every Paseo theme and on
phones. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the token list.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { WorkspaceOverview } from "./main.client";
Register the panel and a Command Center item that opens it by adding to `index.client.tsx`:
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: WorkspaceOverview,
});
plugin.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
return () => {};
}
```tsx
import { WorkspaceOverview } from "./client/overview";
// Inside contribute(client), after the existing registrations:
client.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
locations: ["workspace", "explorer"],
Component: WorkspaceOverview,
});
client.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
```
The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the required tokens.
`icon` is a [Lucide](https://lucide.dev/icons/) icon name.
## Check and install it
## Edit and reload
Source changes take effect only when you reload the plugin:
```bash
npm run typecheck
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
paseo plugin reload workspace-plugin
```
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. 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.
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 error in `paseo plugin ls`; fix the source and reload
again.
To install a plugin published through GitHub or another Git host:
Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open
workspace overview**. The panel opens as a workspace tab.
## Install a published plugin
Plugins published in a Git repository install by shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/workspace
paseo plugin add owner/repository --ref main
```
Append `:relative/path` to the source when the plugin lives below the repository root.
An omitted `--ref` tracks the default branch. Explicit branches track updates; tags and commits are
pinned. Check and apply updates with:
Append `:relative/path` when the plugin lives below the repository root. Without `--ref`, the
default branch is tracked; a branch tracks updates, while a tag or commit stays pinned.
```bash
paseo plugin status
@@ -143,60 +233,36 @@ paseo plugin update workspace-plugin
paseo plugin update --all
```
Most plugins should omit `build`. Paseo compiles TypeScript and TSX and supplies its runtime modules.
Declare preparation only when the staged checkout must install another dependency, generate source,
or perform another required build step:
Paseo compiles TypeScript itself, so most plugins need no build step. A repository that must
install a dependency Paseo does not provide, or generate files, declares
[`build` commands](/docs/plugins/reference#cli-reference) in its manifest.
```json
{
"id": "workspace-plugin",
"build": [
["npm", "ci"],
["npm", "run", "build"]
]
}
```
## Read backend logs
Each `build` entry is a non-empty argv array, executed directly without a shell from the staged
plugin directory. Paseo never chooses a package manager or infers commands from lockfiles. On
install and update it resolves the exact commit, runs these commands, then validates, compiles, and
activates the candidate. A failed command discards the candidate and keeps the installed/running
version. The daemon log records the exact argv and output; `--host` runs them on the remote daemon
host.
## Edit and reload
Source changes are explicit:
```bash
npm run typecheck
paseo plugin reload workspace-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:
Daemon-side handlers and cleanup can use normal Node logging:
```ts
console.log("Refreshing issues");
console.error("Issue refresh failed", error);
```
Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI:
Read the recent output from **Settings → Plugins → Logs** or the CLI:
```bash
paseo plugin logs workspace-plugin
paseo plugin logs workspace-plugin --json
```
The log tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and
load failures. It survives reloads and crashes. 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.
The tail includes `[paseo]` loading, ready, stopping, and stopped entries, plus compilation and load
failures, and it survives reloads and crashes. Client-side output stays in the app. See
[Debug backend output](/docs/plugins/reference#debug-backend-output) for retention and what not to
log.
## Next
- [Plugin reference](/docs/plugins/reference), add daemon behavior, use the Paseo SDK, contribute themes and attachments, and manage lifecycle.
- [TypeScript SDK](/docs/sdk), the workspace, agent, provider, and config API exposed inside plugins.
- [Plugin reference](/docs/plugins/reference): every contribution type, its fields, the runtime
modules, hosts, and the CLI.
- [Migrate a plugin to runtime entries](/docs/plugins/migration): move a plugin written against the
single `index.ts` entry, step by step.
- [TypeScript SDK](/docs/sdk): the workspace, agent, provider, and config API available as `paseo`
in client and server code.
+233
View File
@@ -0,0 +1,233 @@
---
title: Migrate a plugin to runtime entries
description: Mechanical migration from a mixed plugin entry to explicit client and server entries.
nav: Migration
order: 47
category: Plugins
---
# Migrate a plugin to runtime entries
Give this page to a coding agent with the plugin directory as its working directory. Execute the
steps in order. Do not keep a compatibility entry.
## 1. Classify the existing code
Start from the old shape:
```text
my-plugin/
paseo-plugin.json
package.json
tsconfig.json
index.ts
greeting.client.tsx
greeting.server.ts
greeting.shared.ts
```
The finished shape is:
```text
my-plugin/
paseo-plugin.json
package.json
tsconfig.json
index.client.tsx
index.server.ts
client/greeting.tsx
server/greeting.ts
shared/greeting.ts
```
Create only the entries the plugin needs. At least one is required. Components and client callbacks
need the client entry. RPC handlers and Node APIs need the server entry.
## 2. Rename files and directories
Apply these rules exactly:
1. Replace the mixed root entry with `index.client.tsx`, `index.server.ts`, or both.
2. Move every `name.client.ts` or `name.client.tsx` to `client/name.ts` or `client/name.tsx`.
3. Move every `name.server.ts` or `name.server.tsx` to `server/name.ts` or `server/name.tsx`.
4. Move every `name.shared.ts` or `name.shared.tsx` to `shared/name.ts` or `shared/name.tsx`.
5. Preserve nested feature directories under the matching runtime directory.
6. Update relative imports after every move.
7. Keep `paseo-plugin.json`, `package.json`, and `tsconfig.json` at the root.
8. Delete the old root entry. Paseo does not load it.
The directories are the compiler boundaries. A file beneath `client/` compiles only into the app
bundle, a file beneath `server/` only into the daemon bundle, and `shared/` into both. Filename
suffixes such as `*.client.tsx` no longer mean anything, and a code module left at the plugin root
is a compile error.
## 3. Move every registration
Use this table as the complete registration checklist.
| Old registration and location | New registration and location |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` |
| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry.
Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side
types such as `PluginHandlerContext`. Every client `add*` now returns an idempotent removal
function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding
registrations after the entry cleanup runs.
## 4. Separate imports
The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports
only `server/`, `shared/`, and server-safe packages. A `node:` import in the client entry or anything
reachable from it is a compile error. Never import a component into the server entry merely to wire
its registration; that registration belongs in the client entry.
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `Plugin entry split is required` | The directory still has only the old root entry. Create a runtime entry, move registrations, then delete the old file. |
| `Plugin entry points are missing: expected index.client.ts or index.client.tsx and/or index.server.ts or index.server.tsx` | No supported entry exists. Add at least one exact filename. |
| `server-only module cannot be imported into the plugin client bundle: <file>` | A client import reaches `server/`. Move the call behind an RPC and import its contract from `shared/`. |
| `client-only module cannot be imported into the plugin server bundle: <file>` | A server import reaches `client/`. Move that registration and import to the client entry. |
| `Plugin modules belong in client/, server/, or shared/: <file>` | A code module is still at the plugin root. Move it into the matching directory and fix its imports. |
| `Node module cannot be imported into the plugin client bundle: node:<name> imported by <file>` | Client code imports a Node API. Move the operation to `server/`, expose an RPC in `shared/`, and call it from the client. |
| TypeScript reports that `PluginContext`, `addClientSide`, or `addClientSlashCommand` does not exist | Replace the old context types and registrations using the table above. |
## 6. Worked example: `plugin-examples/local-plugin`
Only the entry files and import paths change. Component and handler bodies move without edits.
Before:
```text
local-plugin/
index.ts
main.client.tsx
increment.server.ts
increment.shared.ts
```
```ts
// index.ts
import type { PluginContext } from "@getpaseo/plugin";
import { contributeClient, ExamplePanel } from "./main.client";
import { increment } from "./increment.server";
import { incrementRpc } from "./increment.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(incrementRpc, increment);
plugin.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
});
plugin.addCommandCenterItem({
id: "open-counter",
title: "Open plugin counter",
icon: "Blocks",
context: "workspace",
onSelect({ openPanel }) {
openPanel("counter");
},
});
plugin.addClientSide(contributeClient);
return () => {};
}
```
After:
```text
local-plugin/
index.client.tsx
index.server.ts
client/main.tsx # was main.client.tsx
server/increment.ts # was increment.server.ts
shared/increment.ts # was increment.shared.ts
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { contributeClient, ExamplePanel } from "./client/main";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
});
client.addCommandCenterItem({
id: "open-counter",
title: "Open plugin counter",
icon: "Blocks",
context: "workspace",
onSelect({ openPanel }) {
openPanel("counter");
},
});
return contributeClient(client);
}
```
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { increment } from "./server/increment";
import { incrementRpc } from "./shared/increment";
export default function contribute(server: PluginServerContext) {
server.handle(incrementRpc, increment);
return () => {};
}
```
Import path changes inside the moved files:
```diff
// client/main.tsx
-import { incrementRpc } from "./increment.shared";
+import { incrementRpc } from "../shared/increment";
// server/increment.ts
-import { incrementRpc } from "./increment.shared";
+import { incrementRpc } from "../shared/increment";
```
`contributeClient` already took a `PluginClientContext` and returned cleanup, so the client entry
calls it directly and returns its cleanup. A plugin whose `addClientSide` callback also registered
pills or subscriptions keeps that code; only the wrapper goes away.
## 7. Verify the migration
Run:
```bash
npm run typecheck
paseo plugin reload <plugin-id>
paseo plugin ls
```
Require `running` with no error. Exercise every contribution. For plugins with RPCs, call the client
action and verify the server result. For client-only plugins, confirm the contribution loads without
a server process. Call any stored registration remover twice and verify the second call is a no-op.
+192 -128
View File
@@ -8,14 +8,16 @@ category: Plugins
# Plugin reference
Migrating an existing plugin? Follow the standalone [runtime-entry migration guide](/docs/plugins/migration).
Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute:
- React Native surfaces and sidebar items to Paseo clients;
- workspace and agent panels opened as workspace tabs;
- global, workspace, and agent actions in the Command Center;
- client slash commands in the message composer;
- slash commands in the message composer;
- transformed and daemon-pushed agent timeline rows;
- dark themes in Settings → Appearance;
- light and dark themes in Settings → Appearance;
- schema-validated RPC handlers running beside the daemon;
- normal Paseo operations through the TypeScript SDK;
- searchable external resources in the message composer.
@@ -29,8 +31,11 @@ Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Ba
```text
my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
index.client.tsx
index.server.ts
client/greeting.tsx
server/greeting.ts
shared/greeting.ts
package.json
tsconfig.json
```
@@ -41,70 +46,114 @@ The required root manifest is `paseo-plugin.json`. It contains the default plugi
{ "id": "my-plugin" }
```
The entry point is `index.ts` at the plugin root. Plugin, surface, sidebar-item, workspace-panel,
Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase
letters, numbers, or hyphens. Client slash-command names follow the same rule.
| Entry | Runtime | Receives | Required |
| ------------------ | --------------------- | --------------------- | ----------------------------------------------------------------- |
| `index.client.tsx` | Paseo app, per client | `PluginClientContext` | When the plugin has any UI, callback, theme, or attachment source |
| `index.server.ts` | Daemon subprocess | `PluginServerContext` | When the plugin handles RPCs |
At least one entry is required; both accept `.ts` or `.tsx`. A directory that still has only the
old `index.ts` fails to load and points at the [migration guide](/docs/plugins/migration).
Plugin, surface, sidebar-item, workspace-panel, Command Center item, attachment-source, and
slash-command IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
dependencies for local typechecking and tests. Paseo supplies their runtime instances. Consumers do
not install them when adding the plugin.
Add runtime-specific files as the plugin grows:
Every other module lives in one of three directories. Nesting inside them is fine; a module at the
plugin root is a compile error.
```text
my-plugin/
action.shared.ts
action.server.ts
panel.client.tsx
```
| Suffix | Use it for |
| -------------- | -------------------------------------------------------------------- |
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. |
| Directory | Compiled into | Use it for |
| --------- | ------------------ | -------------------------------------------------------------------- |
| `client/` | App bundle only | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `server/` | Daemon bundle only | Node APIs, local resources, credentials, and RPC handlers. |
| `shared/` | Both | Zod RPC contracts and plain values imported by both runtimes. |
## Runtime modules
Paseo builds separate client and server bundles from `index.ts`. It rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code.
Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle,
from `server/` into the app bundle, or of a `node:` module anywhere in the app bundle is a compile
error. Keep `shared/` free of Node and React Native runtime code.
### Client runtime
Paseo provides these modules to client code:
| Module | Use it for |
| ------------------------------- | ------------------------------------- |
| `@getpaseo/plugin` | Contribution contracts and data hooks |
| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks |
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
| Module | Use it for |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks |
| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks |
| `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "<name>" is not available in plugin client code`.
Do not import `lucide-react-native`, `react-native-svg`, or DOM libraries. Set contribution `icon` fields to a [Lucide icon name](https://lucide.dev/icons/); Paseo validates the name and renders the icon.
Client components are React Native components rendered by Paseo. Web clients render them through React Native Web. Browser globals such as `localStorage` and `location` exist only when `layout.platform === "web"`; iOS and Android have no equivalent. Gate any use on that field.
### Cross-platform rules
There is no plugin storage API. Browser storage does not persist settings across Paseo clients. There is also no general host navigation API: plugin code cannot open native Paseo routes. Command Center callbacks can only open surfaces and panels registered by the same plugin.
Client code runs on iOS, Android, and in browsers through React Native Web. A component that works
in your browser and crashes on a phone is the most common plugin bug. The rules:
| Do | Do not |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `View`, `Text`, `Pressable`, `ScrollView`, `TextInput` from `react-native` | `<div>`, `<span>`, `<button>`, or any HTML element |
| `style` objects built from `theme.colors` and `layout.compact` | `className`, CSS strings, or hardcoded colors |
| `onPress` | `onClick`, `onMouseEnter`, or other DOM handlers |
| `Linking`, `Clipboard`-style React Native APIs | `window`, `document`, `localStorage`, `navigator`, `location` in components |
The scaffold's `tsconfig.json` omits the DOM library, so `document` and `window` are type errors
everywhere by default. The one place browser APIs are allowed is `client/web.ts`. It declares the
narrow shape of each global it uses, gates every export on `Platform.OS`, and gives native the
alternative:
`client/web.ts`:
```ts
import { Linking, Platform } from "react-native";
// This plugin typechecks without the DOM library. Declare only what this module uses.
declare const window: { open(url: string, target: string, features: string): unknown };
export async function openExternal(url: string): Promise<void> {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
await Linking.openURL(url);
}
```
Do not add `/// <reference lib="dom" />` or `"DOM"` to `lib`; either one turns DOM types back on
for the whole project and hides the next mistake. Components import `openExternal` and never touch
`window` themselves. `layout.platform` on surface and panel props carries the same value as
`Platform.OS` for rendering decisions.
There is no plugin storage API. Browser storage does not persist settings across Paseo clients.
There is also no general host navigation API: plugin code cannot open native Paseo routes. Command
Center callbacks can only open surfaces and panels registered by the same plugin.
### Server runtime
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work in `*.server.ts` files.
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess.
## Entry point and cleanup
`index.ts` wires contributions together and default-exports one contribution function. It must return cleanup, even when it has nothing to clean:
Each present entry default-exports one contribution function and returns cleanup. Client entries
receive `PluginClientContext`; server entries receive `PluginServerContext`. Every client `add*`
returns an idempotent remover. The entry cleanup runs before Paseo removes remaining registrations.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { Main } from "./main.client";
import type { PluginClientContext } from "@getpaseo/plugin";
import { Main } from "./client/main";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Main);
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Main);
return () => {};
}
```
@@ -115,7 +164,7 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre
Register a component, then point a sidebar item at its surface ID:
`main.client.tsx`:
`client/main.tsx`:
```tsx
import type { PluginSurfaceProps } from "@getpaseo/plugin";
@@ -144,15 +193,15 @@ export function Main({ theme, host, layout }: PluginSurfaceProps) {
}
```
`index.ts`:
`index.client.tsx`:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { Main } from "./main.client";
import type { PluginClientContext } from "@getpaseo/plugin";
import { Main } from "./client/main";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Main);
plugin.addSidebarItem({
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Main);
client.addSidebarItem({
id: "main",
title: "My plugin",
icon: "Blocks",
@@ -175,7 +224,7 @@ Paseo owns the route, header, close action, host picker, error boundary, and que
## Host UI
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in `*.client.tsx` files. This example
Import Paseo-owned UI from `@getpaseo/plugin/react-native` in client code. This example
opens a controlled modal, renders a host icon, and confirms the action with a toast:
```tsx
@@ -280,7 +329,7 @@ registrations are client contributions. Paseo applies the transformer while buil
model, including every live streaming update.
```tsx
import type { PluginContext, PluginTimelineItemProps } from "@getpaseo/plugin";
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin";
import { Text } from "react-native";
import { z } from "zod";
@@ -290,8 +339,8 @@ function Card({ item, theme }: PluginTimelineItemProps<z.output<typeof schema>>)
return <Text style={{ color: theme.colors.foreground }}>{item.data.label}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addTimelineTransformer({
export default function contribute(client: PluginClientContext) {
client.addTimelineTransformer({
id: "command-card",
query: { itemType: "tool_call" },
transform({ item, phase }) {
@@ -307,7 +356,7 @@ export default function contribute(plugin: PluginContext) {
};
},
});
plugin.addTimelineRenderer({
client.addTimelineRenderer({
kind: "command-card",
version: 1,
schema,
@@ -391,14 +440,14 @@ Workspace and agent panels receive the same `theme`, `layout`, and optional `nav
## Contribute a theme
`addTheme` adds a light or dark theme to Settings → Appearance, listed under the built-ins by its `name`. A
theme is data, so it needs no client file:
`addTheme` adds a light or dark theme to Settings → Appearance, listed under the built-ins by its
`name`. A theme is data, so it needs no component file:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin";
export default function contribute(plugin: PluginContext) {
plugin.addTheme({
export default function contribute(client: PluginClientContext) {
client.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
@@ -439,15 +488,14 @@ Only one contributed theme is active at a time. Selecting one persists the choic
later disabled or removed, Paseo falls back to the default theme rather than leaving the app
unpainted.
Themes need a host that supports them. A daemon released before `addTheme` compiles the call into
the plugin's backend bundle, where it does not exist, and the plugin fails to start with
`plugin.addTheme is not a function`. Update the host.
Themes need a host that supports them. A client released before `addTheme` cannot evaluate that client entry and reports
`client.addTheme is not a function`. Update the client.
## Workspace panels
Register one panel for workspace or agent context:
`review.client.tsx`:
`client/review.tsx`:
```tsx
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin";
@@ -478,14 +526,14 @@ export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgent
}
```
`index.ts`:
`index.client.tsx`:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { ReviewPanel } from "./review.client";
import type { PluginClientContext } from "@getpaseo/plugin";
import { ReviewPanel } from "./client/review";
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
@@ -562,7 +610,7 @@ Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linu
Register an action and open a panel from the callback:
```tsx
import { defineRpc } from "@getpaseo/plugin/server";
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
const refreshReview = defineRpc({
@@ -571,7 +619,7 @@ const refreshReview = defineRpc({
output: z.object({ refreshed: z.boolean() }),
});
plugin.addCommandCenterItem({
client.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
@@ -612,13 +660,13 @@ Every callback receives:
An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work.
## Client slash commands
## Slash commands
Register a command that runs entirely in the Paseo client when the user submits it from the message
composer:
Register a command that runs in the Paseo client when the user submits `/name args` from the
message composer. The text is never sent to the agent:
```ts
plugin.addClientSlashCommand({
client.addSlashCommand({
name: "review",
description: "Run the review bot",
argumentHint: "[scope]",
@@ -639,9 +687,9 @@ plugin.addClientSlashCommand({
| `onSubmit` | Yes | Client callback for the matching context. |
`onSubmit` receives the matching Command Center callback context plus `args`. For `/review src`,
`args` is `"src"`; Paseo trims only the remainder's leading and trailing whitespace. Paseo owns the
autocomplete row, input clearing, and error toast. A handled command is never sent to the agent.
The compiler removes this registration from the plugin's server bundle.
`args` is `"src"`; Paseo trims only the remainder's leading and trailing whitespace and leaves
parsing to the plugin. Paseo owns the autocomplete row, input clearing, and the error toast. It
does not wait for `onSubmit` or show a pending state; use a composer pill or panel for that.
Precedence is built-in client commands, plugin commands, then provider commands. A lower-precedence
collision is omitted. Built-in aliases also reserve their names. The first plugin in stable catalog
@@ -649,18 +697,8 @@ order wins a collision between plugins. Commands do not run while the composer h
## Composer pills
Register a headless client entrypoint from `index.ts`:
```ts
import { contributeClient } from "./review.client";
export default function contribute(plugin: PluginContext) {
plugin.addClientSide(contributeClient);
return () => {};
}
```
The client entrypoint owns pill creation and removal:
The client entry owns pill creation and removal. This can live directly in `index.client.tsx` or in
a function it imports from `client/`:
```tsx
import {
@@ -683,7 +721,7 @@ function ReviewPill({ theme, agentId }: PluginComposerPillProps) {
);
}
export function contributeClient(client: PluginClientContext) {
export default function contribute(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
@@ -722,10 +760,10 @@ export function contributeClient(client: PluginClientContext) {
| `Component` | Yes | React Native component rendering the pill's icon and text. |
| `onPress` | Yes | Client-side callback. |
`addClientSide` runs once per plugin installation in each connected app. Its context exposes
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and `addComposerPill`.
The client entry runs once per plugin installation in each connected app. Its context exposes
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and every client registration.
`addComposerPill` returns an idempotent removal function. Paseo also removes every outstanding pill
when the client entrypoint, plugin installation, or host connection is torn down.
when the plugin installation or host connection is torn down.
Paseo owns the pressable, shared pill chrome, pending state, error reporting, and track-bar
placement. The component receives `theme`, `host`, `layout`, `workspaceId`, and `agentId`. Read
@@ -776,10 +814,10 @@ Use plugin RPC only for work that is not a normal Paseo operation: reading a ven
Define one contract with Zod, handle it in the subprocess, and call it from the surface:
`greeting.shared.ts`:
`shared/greeting.ts`:
```ts
import { defineRpc } from "@getpaseo/plugin/server";
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const greeting = defineRpc({
@@ -789,11 +827,11 @@ export const greeting = defineRpc({
});
```
`greeting.client.tsx`:
`client/greeting.tsx`:
```tsx
import { useRpc } from "@getpaseo/plugin";
import { greeting } from "./greeting.shared";
import { greeting } from "../shared/greeting";
export function GreetingButton() {
const createGreeting = useRpc(greeting);
@@ -802,28 +840,38 @@ export function GreetingButton() {
}
```
`greeting.server.ts`:
`server/greeting.ts`:
```ts
import type { output as ZodOutput } from "zod";
import { greeting } from "./greeting.shared";
import type { RpcInput } from "@getpaseo/plugin";
import { greeting } from "../shared/greeting";
export function createGreeting({ name }: ZodOutput<typeof greeting.input>) {
export function createGreeting({ name }: RpcInput<typeof greeting>) {
return { message: `Hello, ${name}` };
}
```
`index.ts`:
`index.client.tsx`:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { GreetingButton } from "./greeting.client";
import { createGreeting } from "./greeting.server";
import { greeting } from "./greeting.shared";
import type { PluginClientContext } from "@getpaseo/plugin";
import { GreetingButton } from "./client/greeting";
export default function contribute(plugin: PluginContext) {
plugin.handle(greeting, createGreeting);
plugin.addSurface("main", GreetingButton);
export default function contribute(client: PluginClientContext) {
client.addSurface("main", GreetingButton);
return () => {};
}
```
`index.server.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greeting } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greeting, createGreeting);
return () => {};
}
```
@@ -873,10 +921,10 @@ daemon log persists it.
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.
`issues.shared.ts`:
`shared/issues.ts`:
```ts
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server";
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const searchIssues = defineRpc({
@@ -907,27 +955,38 @@ export const issues = defineAttachmentSource({
});
```
`issues.server.ts`:
`server/issues.ts`:
```ts
import type { output as ZodOutput } from "zod";
import { searchIssues } from "./issues.shared";
import type { RpcInput } from "@getpaseo/plugin";
import { searchIssues } from "../shared/issues";
export function search({ query }: ZodOutput<typeof searchIssues.input>) {
export function search({ query }: RpcInput<typeof searchIssues>) {
return searchAcmeIssues(query);
}
```
`index.ts`:
`index.client.tsx`:
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { search } from "./issues.server";
import { issues, searchIssues } from "./issues.shared";
import type { PluginClientContext } from "@getpaseo/plugin";
import { issues } from "./shared/issues";
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, search);
plugin.addAttachmentSource(issues);
export default function contribute(client: PluginClientContext) {
client.addAttachmentSource(issues);
return () => {};
}
```
`index.server.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";
export default function contribute(server: PluginServerContext) {
server.handle(searchIssues, search);
return () => {};
}
```
@@ -1001,17 +1060,22 @@ Run `npm run typecheck` before install or reload. Never edit the daemon config d
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.
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. |
| Symptom | Check |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin entry split is required` | The directory has only an `index.ts` entry. Follow the [migration guide](/docs/plugins/migration). |
| `Plugin entry points are missing` | Neither `index.client.tsx` nor `index.server.ts` exists with that exact name. |
| `server-only module cannot be imported into the plugin client bundle` | Client code imports `server/` or a `*.server.*` file. Move the work behind an RPC and import its contract from `shared/`. |
| `client-only module cannot be imported into the plugin server bundle` | Server code imports `client/` or a `*.client.*` file. Register that contribution from `index.client.tsx` instead. |
| `Node module cannot be imported into the plugin client bundle` | Client code imports `node:*`. Move the operation to `server/` and call it through an RPC. |
| 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. |
+147 -62
View File
@@ -29,13 +29,13 @@ Pick the contribution that matches the request. Each row names the registration,
| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addClientSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands |
| Composer pill | `addClientSide` + `client.addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Client slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `addAttachmentSource` + `plugin.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `plugin.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
## Create the project
@@ -53,10 +53,13 @@ The generated project contains:
```text
my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
package.json
tsconfig.json
index.client.tsx
index.server.ts
client/greeting.tsx
server/greeting.ts
shared/greeting.ts
```
The manifest supplies the default install ID:
@@ -65,28 +68,48 @@ The manifest supplies the default install ID:
{ "id": "my-plugin" }
```
Keep `index.ts` to contribution wiring. Runtime code lives behind filename boundaries, and the compiler strips client registrations from the server bundle and server registrations from the client bundle:
Each runtime has its own optional entry. A plugin must have at least one. Both entries accept
`.ts` or `.tsx`; use `.tsx` when an entry imports components.
| Suffix | Owns |
| -------------- | --------------------------------------------------------------- |
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, callbacks |
| `*.server.ts` | Node APIs, filesystem and process access, credentials, handlers |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes |
| Path | Runtime |
| -------------------------------- | ----------------- |
| `index.client.tsx` and `client/` | App |
| `index.server.ts` and `server/` | Daemon subprocess |
| `shared/` | Both |
Importing a `*.server` module from a client module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls belong in `*.client.tsx`.
Do not put any other code modules in the plugin root.
Default-export one contribution function. It must return cleanup, even when there is nothing to clean:
A client import of `server/`, a server import of `client/`, and every `node:` import reachable from
client code is a compile error. A relative import to another code file in the plugin root is also a
compile error; move it into `client/`, `server/`, or `shared/`. Shared modules contain Zod contracts
and plain values; they do not import Node or React Native runtime APIs.
Default-export one contribution function from each entry and return cleanup:
```tsx
import type { PluginContext } from "@getpaseo/plugin";
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
export default function contribute(plugin: PluginContext) {
// Register contributions here.
export default function contribute(client: PluginClientContext) {
// Register components and client callbacks here.
return () => {};
}
```
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.
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
export default function contribute(server: PluginServerContext) {
// Register daemon-side RPC handlers here.
return () => {};
}
```
Cleanup can be async. Use it for timers, watchers, sockets, subscriptions, and other resources
created by plugin code. Every client `add*` method returns an idempotent remover. Paseo calls the
entry cleanup first, removes registrations that remain, rejects pending RPCs, closes the plugin
session, and stops the subprocess when the plugin stops.
## Add a workspace panel
@@ -95,7 +118,11 @@ mobile, and Paseo has multiple themes. Every `Text` must take its color from `th
Use `layout.compact` for padding and stacking. Unstyled text is black and fails in dark themes.
```tsx
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin";
import {
type PluginClientContext,
type PluginWorkspacePanelProps,
useWorkspace,
} from "@getpaseo/plugin";
import { useMemo } from "react";
import { Text, View } from "react-native";
@@ -120,15 +147,15 @@ function Overview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
);
}
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
Component: Overview,
});
plugin.addCommandCenterItem({
client.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
@@ -151,7 +178,7 @@ the active workspace or agent. Command callbacks receive the selected host's `pa
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`.
```tsx
import type { PluginContext, PluginSurfaceProps } from "@getpaseo/plugin";
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin";
import { useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -186,9 +213,9 @@ function Counter({ theme, layout }: PluginSurfaceProps) {
);
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Counter);
plugin.addSidebarItem({
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Counter);
client.addSidebarItem({
id: "main",
title: "Counter",
icon: "ListPlus",
@@ -200,13 +227,33 @@ export default function contribute(plugin: PluginContext) {
Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. 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`, `@getpaseo/plugin`, `@getpaseo/plugin/react-native`, and `@getpaseo/plugin/server`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX.
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/react-native`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX. Importing a `node:` module from client code is a compile error.
| Module | Use it for |
| ------------------------------- | ------------------------------------------------------------------------ |
| `@getpaseo/plugin` | contribution contracts, `usePaseo`, `useRpc`, `useWorkspace`, `useAgent` |
| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, handler types |
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, `usePaseo`, `useRpc`, and data hooks |
| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` |
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
## Works on mobile
Before reporting a plugin done:
- Use React Native primitives only: `View`, `Text`, `Pressable`, `ScrollView`, and `TextInput`.
- Do not use HTML elements, `className`, CSS strings, or `onClick`.
- Do not put `"DOM"` in `tsconfig.json` or use `/// <reference lib="dom" />`. Put DOM globals only in
`client/web.ts`, declare only what that module uses, gate every export on `Platform.OS === "web"`,
and provide the native alternative or a no-op.
- Take colors from `theme.colors`.
- Check the compact layout.
Run this audit on `client/`:
```bash
rg -n "document\.|window\.|localStorage|navigator\.|<[a-z]+[ >]|className=|onClick=" client/
```
A hit outside `client/web.ts` is a bug.
## Choose the correct API
@@ -247,12 +294,12 @@ The API covers workspaces, agents, providers, and daemon config. It omits connec
### Add daemon-side behavior
Define one Zod contract, register its subprocess handler, and call it with `useRpc()`:
Define one Zod contract in `shared/`, register its subprocess handler in `index.server.ts`, and
call it from client code with `useRpc()`:
```tsx
import type { PluginContext } from "@getpaseo/plugin";
import { useRpc } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
```ts
// shared/greeting.ts
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
const greeting = defineRpc({
@@ -260,21 +307,40 @@ const greeting = defineRpc({
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
```
```ts
// server/greeting.ts
import type { RpcInput } from "@getpaseo/plugin";
import { greeting } from "../shared/greeting";
export async function createGreeting({ name }: RpcInput<typeof greeting>) {
return { message: `Hello, ${name}!` };
}
```
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greeting } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greeting, createGreeting);
return () => {};
}
```
```tsx
// client/greeting.tsx
import { useRpc } from "@getpaseo/plugin";
import { greeting } from "../shared/greeting";
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 () => {};
}
```
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.
@@ -301,11 +367,12 @@ daemon log. Never log credentials or other secrets.
## Add a composer attachment source
Define a search RPC and register a declarative source:
Define a search RPC and declarative source in `shared/`, handle it on the server, and register it on
the client:
```tsx
import type { PluginContext } from "@getpaseo/plugin";
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin/server";
```ts
// shared/issues.ts
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
const searchIssues = defineRpc({
@@ -334,10 +401,26 @@ const issues = defineAttachmentSource({
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
```
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { searchIssues } from "./shared/issues";
export default function contribute(server: PluginServerContext) {
server.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
return () => {};
}
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { issues } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
client.addAttachmentSource(issues);
return () => {};
}
```
@@ -349,7 +432,7 @@ Return complete text snapshots. Paseo owns the composer menu, picker, pills, dra
A slash command runs plugin code in the app when the user submits `/name args`. Nothing is sent to the agent. `args` is the raw text after the command name, trimmed; parse it in the plugin.
```ts
plugin.addClientSlashCommand({
client.addSlashCommand({
name: "review",
description: "Run the review bot",
argumentHint: "[scope]",
@@ -361,11 +444,11 @@ plugin.addClientSlashCommand({
});
```
The callback receives the same context as the matching Command Center item plus `args`. Paseo owns the autocomplete row, input clearing, and the error toast; put pending UI in a pill or panel. Precedence is built-in client commands, then plugin commands, then provider commands; a lower-precedence collision is dropped. Commands do not run while the composer has attachments. Server-side slash commands do not exist yet; the name is qualified so they can.
The callback receives the same context as the matching Command Center item plus `args`. Paseo owns the autocomplete row, input clearing, and the error toast; put pending UI in a pill or panel. Precedence is built-in client commands, then plugin commands, then provider commands; a lower-precedence collision is dropped. Commands do not run while the composer has attachments. Server-side slash commands do not exist.
## Add a composer pill
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Register a headless client entrypoint with `addClientSide`, then add and remove pills from that lifecycle. `addComposerPill` exists only on `PluginClientContext`.
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Add and remove pills from the client entry lifecycle. `addComposerPill` exists on `PluginClientContext`.
```tsx
export function contributeClient(client: PluginClientContext) {
@@ -395,14 +478,14 @@ export function contributeClient(client: PluginClientContext) {
}
```
Wire it with `plugin.addClientSide(contributeClient)`. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
## Transform and render timeline items
Timeline transformers and renderers are client contributions. A transformer selects one built-in `AgentTimelineItem.type`, inspects the item, and returns zero or more versioned plugin items. `undefined` keeps the source item, `items` replaces it, `[]` removes it. A renderer draws one `kind` and `version` after validating `data` with its Zod schema.
```ts
plugin.addTimelineTransformer({
client.addTimelineTransformer({
id: "inline-thinking",
query: { itemType: "reasoning" },
transform: ({ item, phase }) => ({
@@ -411,7 +494,7 @@ plugin.addTimelineTransformer({
],
}),
});
plugin.addTimelineRenderer({
client.addTimelineRenderer({
kind: "inline-thinking",
version: 1,
schema: z.object({ text: z.string(), phase: z.enum(["streaming", "complete"]) }),
@@ -426,7 +509,7 @@ Transformers run while the render model is built, on fetched history and on ever
A server handler can push a plugin-owned row into any agent timeline. The same renderer registration draws it.
```ts
plugin.handle(publishReview, async ({ agentId, verdict }, { paseo }) => {
server.handle(publishReview, async ({ agentId, verdict }, { paseo }) => {
await paseo.agents.ref(agentId).timeline.append({
type: "plugin",
id: "review",
@@ -445,7 +528,7 @@ The daemon stamps `pluginId` from the plugin session, so only plugin code can ca
`addTheme` takes a small light or dark palette; Paseo expands it into the full token set. Every color is a hex string.
```ts
plugin.addTheme({
client.addTheme({
id: "mocha",
name: "Catppuccin Mocha",
appearance: "dark",
@@ -462,7 +545,7 @@ plugin.addTheme({
});
```
It appears under Settings → Appearance. A daemon that predates `addTheme` fails to start the plugin with `plugin.addTheme is not a function`; update the host. See `plugin-examples/catppuccin`.
It appears under Settings → Appearance. A client that predates `addTheme` cannot evaluate the entry and reports `client.addTheme is not a function`; update the client. See `plugin-examples/catppuccin`.
## Hosts and trust
@@ -515,6 +598,8 @@ Use `--host <url>` when managing a daemon other than the CLI default. A Git sour
Do not restart the daemon to load source changes. Restarting it can kill the agent performing the work.
For an old mixed entry, follow the standalone [runtime-entry migration guide](https://paseo.sh/docs/plugins/migration.md) mechanically.
## Verify the outcome
After a change: