From 3cc87494377415ad4bca76423e451168cb51f7a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 23:45:19 +0200 Subject: [PATCH] feat(plugins): split plugin code by runtime Keep the entry point focused on contribution wiring so client UI and daemon handlers can use their own runtime APIs safely. Surface compilation failures and lifecycle transitions in the retained plugin log tail. --- docs/plugins.md | 119 +++------ .../cli/src/commands/plugin/scaffold.test.ts | 71 ++++-- packages/cli/src/commands/plugin/scaffold.ts | 20 +- .../server/src/server/plugins/compiler.ts | 78 +++++- .../src/server/plugins/runtime.posix.test.ts | 233 +++++++++++++++++- packages/server/src/server/plugins/runtime.ts | 36 ++- plugin-examples/linear/README.md | 8 +- plugin-examples/linear/index.ts | 9 + plugin-examples/linear/index.tsx | 46 ---- plugin-examples/linear/issues.server.ts | 11 + plugin-examples/linear/issues.shared.ts | 31 +++ .../local-plugin/increment.server.ts | 9 + .../local-plugin/increment.shared.ts | 8 + plugin-examples/local-plugin/index.ts | 25 ++ .../{index.tsx => main.client.tsx} | 43 +--- public-docs/plugins/index.md | 31 ++- public-docs/plugins/reference.md | 144 ++++++++--- 17 files changed, 657 insertions(+), 265 deletions(-) create mode 100644 plugin-examples/linear/index.ts delete mode 100644 plugin-examples/linear/index.tsx create mode 100644 plugin-examples/linear/issues.server.ts create mode 100644 plugin-examples/linear/issues.shared.ts create mode 100644 plugin-examples/local-plugin/increment.server.ts create mode 100644 plugin-examples/local-plugin/increment.shared.ts create mode 100644 plugin-examples/local-plugin/index.ts rename plugin-examples/local-plugin/{index.tsx => main.client.tsx} (67%) diff --git a/docs/plugins.md b/docs/plugins.md index dbd2f2b3d..17a2e8c80 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,7 +1,7 @@ # Local plugins Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, -and composer attachment sources from one `index.tsx`. Paseo executes the server contribution in a +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. Plugin code is trusted code; this first slice does not sandbox it. @@ -45,13 +45,14 @@ The directory contains an identity-only manifest, one entry point, and local typ ```text my-plugin/ paseo-plugin.json - index.tsx + index.ts + main.client.tsx paseo-plugin.d.ts package.json tsconfig.json ``` -Paseo compiles TSX when loading the plugin, so these packages are development dependencies only. +Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only. The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes. @@ -75,68 +76,41 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its configuration, never its source directory. The global `pluginsEnabled` switch remains available. -Server contributions can write to stdout and stderr with normal Node logging. Inspect the recent -in-memory tail from the host plugin settings or with `paseo plugin logs `. Reload, disable, and -process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the -tail, but structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, -so do not log credentials or tokens. +Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]` +entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are +recorded as stderr entries before a subprocess exists. Inspect the recent in-memory +tail from the host plugin settings or with `paseo plugin logs `. Reload, disable, and process +failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but +structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not +log credentials or tokens. ## Contribute behavior and UI -Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler -removes UI registrations from the server bundle and RPC registrations from the client bundle before -resolving dependencies. +Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime +code lives behind filename boundaries: -```tsx -import { Text } from "react-native"; -import { useQuery } from "@tanstack/react-query"; -import { z } from "zod"; -import { defineRpc, type PluginContext, usePaseo, useRpc } from "@paseo/plugin"; +| 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. | -const greetRpc = defineRpc({ - name: "greet", - input: z.object({ name: z.string() }), - output: z.object({ message: z.string() }), -}); +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. -function Greeting() { - const paseo = usePaseo(); - const greet = useRpc(greetRpc); - const query = useQuery({ - queryKey: ["greeting", "Paseo"], - queryFn: () => greet({ name: "Paseo" }), - }); - const createWorkspace = () => - paseo.workspaces - .create({ - source: { - kind: "worktree", - cwd: "/absolute/path/to/project", - action: "checkout", - checkoutSource: { kind: "change_request", forge: "github", number: 42 }, - }, - }) - .then((workspace) => - workspace.agents.create({ - config: { provider: "codex/gpt-5" }, - prompt: "Review PR #42", - }), - ); - return void createWorkspace()}>{query.data?.message}; -} +```ts +import type { PluginContext } from "@paseo/plugin"; +import { Greeting } from "./greeting.client"; +import { createGreeting } from "./greeting.server"; +import { greetRpc } from "./greeting.shared"; export default function contribute(plugin: PluginContext) { - plugin.handle(greetRpc, async ({ name }, { paseo }) => { - const { config } = await paseo.config.get(); - return { message: `${name}: ${config.pluginsEnabled ? "enabled" : "disabled"}` }; - }); + plugin.handle(greetRpc, createGreeting); plugin.addSurface("main", Greeting); - plugin.addSidebarItem({ - id: "main", - title: "Greeting", - icon: "MessageCircle", - surface: "main", - }); + plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" }); return () => {}; } ``` @@ -192,36 +166,13 @@ Register a declarative attachment source backed by a plugin RPC. Paseo owns the search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots; credentials and vendor API calls stay in the daemon handler. -```tsx -const searchIssues = defineRpc({ - name: "issues.search", - input: z.object({ query: z.string() }), - output: z.object({ - items: z.array( - z.object({ - id: z.string(), - identifier: z.string(), - title: z.string(), - subtitle: z.string().optional(), - url: z.string().url(), - text: z.string(), - resourceType: z.string(), - }), - ), - }), -}); - -const issues = defineAttachmentSource({ - id: "issues", - title: "Acme issue", - icon: "CircleDot", - pickerTitle: "Attach Acme issue", - searchPlaceholder: "Search by identifier or title", - search: searchIssues, -}); +```ts +import type { PluginContext } from "@paseo/plugin"; +import { search } from "./issues.server"; +import { issues, searchIssues } from "./issues.shared"; export default function contribute(plugin: PluginContext) { - plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); + plugin.handle(searchIssues, search); plugin.addAttachmentSource(issues); return () => {}; } diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 2cc466c7f..4e94ea32d 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -12,7 +12,7 @@ afterEach(async () => { }); describe("plugin scaffold", () => { - it("creates a standalone strict TSX project that typechecks", async () => { + it("creates a standalone split-runtime project that typechecks", async () => { const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); directories.push(parent); const directory = path.join(parent, "hello-plugin"); @@ -34,6 +34,12 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({ id: "hello-plugin", }); + await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain( + 'from "./main.client"', + ); + await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain( + "Hello from my plugin", + ); }); it("typechecks client and server Paseo API access", async () => { @@ -41,27 +47,45 @@ describe("plugin scaffold", () => { directories.push(parent); const directory = path.join(parent, "paseo-api-plugin"); await scaffoldPluginDirectory(directory); - await writeFile( - path.join(directory, "index.tsx"), - `import React from "react"; -import { Text } from "react-native"; + await Promise.all([ + writeFile( + path.join(directory, "inspect.shared.ts"), + `import { defineRpc } from "@paseo/plugin"; import { z } from "zod"; -import { - defineRpc, - type PluginContext, - type PluginAgentPanelProps, - usePaseo, - useAgent, - useWorkspace, -} from "@paseo/plugin"; -const inspect = defineRpc({ +export const inspect = defineRpc({ name: "inspect", input: z.object({}), output: z.object({ configured: z.boolean() }), }); +`, + ), + writeFile( + path.join(directory, "inspect.server.ts"), + `import type { PluginHandlerContext } from "@paseo/plugin"; +import type { output as ZodOutput } from "zod"; +import { inspect } from "./inspect.shared"; -function Surface() { +export async function inspectConfig( + _input: ZodOutput, + { paseo }: PluginHandlerContext, +) { + return { configured: Boolean((await paseo.config.get()).config) }; +} +`, + ), + writeFile( + path.join(directory, "main.client.tsx"), + `import React from "react"; +import { Text } from "react-native"; +import { + type PluginAgentPanelProps, + useAgent, + usePaseo, + useWorkspace, +} from "@paseo/plugin"; + +export function Surface() { const paseo = usePaseo(); const createWorkspace = () => paseo.workspaces.create({ source: { kind: "directory", path: "/repo" }, @@ -70,7 +94,7 @@ function Surface() { return Paseo API; } -function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { +export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => { // @ts-expect-error Plugin snapshots are readonly. workspace.name = "mutated"; @@ -83,11 +107,17 @@ function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { }); return {workspaceName}: {agentTitle}; } +`, + ), + writeFile( + path.join(directory, "index.ts"), + `import type { PluginContext } from "@paseo/plugin"; +import { AgentPanel, Surface } from "./main.client"; +import { inspectConfig } from "./inspect.server"; +import { inspect } from "./inspect.shared"; export default function contribute(plugin: PluginContext) { - plugin.handle(inspect, async (_input, { paseo }) => ({ - configured: Boolean((await paseo.config.get()).config), - })); + plugin.handle(inspect, inspectConfig); plugin.addSurface("main", Surface); plugin.addWorkspacePanel({ id: "review", @@ -110,7 +140,8 @@ export default function contribute(plugin: PluginContext) { return () => {}; } `, - ); + ), + ]); const configPath = path.join(directory, "tsconfig.json"); const loaded = ts.readConfigFile(configPath, ts.sys.readFile); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index f7166c88c..ffef026ff 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -206,16 +206,11 @@ const TSCONFIG = { esModuleInterop: true, allowSyntheticDefaultImports: true, }, - include: ["index.tsx", "paseo-plugin.d.ts"], + include: ["**/*.ts", "**/*.tsx"], }; const ENTRY = `import type { PluginContext } from "@paseo/plugin"; -import React from "react"; -import { Text } from "react-native"; - -function MainSurface() { - return Hello from my plugin; -} +import { MainSurface } from "./main.client"; export default function contribute(plugin: PluginContext) { plugin.addSurface("main", MainSurface); @@ -223,6 +218,14 @@ export default function contribute(plugin: PluginContext) { } `; +const CLIENT_SURFACE = `import React from "react"; +import { Text } from "react-native"; + +export function MainSurface() { + return Hello from my plugin; +} +`; + export interface PluginScaffold { id: string; directory: string; @@ -260,7 +263,8 @@ export async function scaffoldPluginDirectory( ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["paseo-plugin.d.ts", SDK_DECLARATIONS], - ["index.tsx", ENTRY], + ["index.ts", ENTRY], + ["main.client.tsx", CLIENT_SURFACE], ]); await Promise.all( [...files].map(([filename, contents]) => diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index bd2bb0f56..86ac22ecf 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -5,6 +5,11 @@ import { build, type Plugin } from "esbuild"; type PluginBuildTarget = "client" | "server"; +interface SourceRange { + start: number; + end: number; +} + const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { client: new Set(["handle"]), server: new Set([ @@ -54,7 +59,7 @@ function defaultPluginFunction(programBody: unknown[]): { body: object; contextN Reflect.get(statement, "type") === "ExportDefaultDeclaration", ); if (defaultExports.length !== 1) { - throw new Error("Plugin index.tsx must have exactly one default export function"); + throw new Error("Plugin entry point must have exactly one default export function"); } const declaration = Reflect.get(defaultExports[0] as object, "declaration"); const declarationType = @@ -88,7 +93,7 @@ function collectRemovedRegistrationRanges( node: unknown, contextName: string, removedNames: ReadonlySet, - ranges: Array<{ start: number; end: number }>, + ranges: SourceRange[], ): void { if (node === null || typeof node !== "object") return; if (Reflect.get(node, "type") === "ExpressionStatement") { @@ -97,7 +102,9 @@ function collectRemovedRegistrationRanges( 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 index.tsx`); + throw new Error( + `Could not locate plugin context ${name} registration in plugin entry point`, + ); } ranges.push({ start, end }); return; @@ -113,27 +120,81 @@ function collectRemovedRegistrationRanges( } } -function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string { +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[], + 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 }); + } +} + +function filterEntrypoint(source: string, target: PluginBuildTarget): string { const ast = parse(source, { sourceType: "module", plugins: ["typescript", "jsx"], }); const pluginFunction = defaultPluginFunction(ast.program.body); - const ranges: Array<{ start: number; end: number }> = []; + 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.toReversed()) { + 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 createRuntimeBoundaryPlugin(target: PluginBuildTarget): Plugin { + 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}`, + }, + ], + }; + }); + }, + }; +} + function wrapCommonJsBundle(code: string): string { return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; } @@ -168,7 +229,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { const source = await readFile(entryPath, "utf8"); - const filteredSource = removeOppositeTargetRegistrations(source, target); + const filteredSource = filterEntrypoint(source, target); const result = await build({ stdin: { contents: filteredSource, @@ -191,7 +252,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "zod", ] : ["@paseo/plugin", "zod"], - plugins: [createUnusedPlatformModulePlugin(target)], + plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)], + logLevel: "silent", treeShaking: true, write: false, }); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index afafa1f28..1492bec8d 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -147,6 +147,27 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it("records host-owned plugin lifecycle events", async () => { + const directory = await createPlugin( + "lifecycle", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild("lifecycle", []); + const runtime = createTestRuntime({ spawnChild: () => child }); + + await runtime.startPlugin("lifecycle", directory); + await runtime.stopPluginById("lifecycle"); + + expect( + runtime.getLogs("lifecycle").map(({ stream, message }) => ({ stream, message })), + ).toEqual([ + { stream: "stdout", message: "[paseo] Loading plugin" }, + { stream: "stdout", message: "[paseo] Plugin ready" }, + { stream: "stdout", message: "[paseo] Stopping plugin" }, + { stream: "stdout", message: "[paseo] Plugin stopped" }, + ]); + }); + it("frames stdout and stderr, normalizes CRLF, and flushes final fragments once", async () => { const directory = await createPlugin( "output", @@ -162,13 +183,20 @@ describe("PluginRuntime", () => { child.stdout.write("final stdout"); await runtime.stopPluginById("output"); - expect(runtime.getLogs("output").map(({ stream, message }) => ({ stream, message }))).toEqual([ + const logs = runtime.getLogs("output"); + expect( + logs + .filter((entry) => !entry.message.startsWith("[paseo]")) + .map(({ stream, message }) => ({ stream, message })), + ).toEqual([ { stream: "stdout", message: "first" }, { stream: "stderr", message: "problem" }, { stream: "stderr", message: "final stderr" }, { stream: "stdout", message: "final stdout" }, ]); - expect(runtime.getLogs("output").map((entry) => entry.sequence)).toEqual([1, 2, 3, 4]); + expect(logs.map((entry) => entry.sequence)).toEqual( + Array.from({ length: logs.length }, (_, index) => index + 1), + ); }); it("writes plugin output through the daemon logger with structured identity fields", async () => { @@ -187,14 +215,14 @@ describe("PluginRuntime", () => { child.stderr.write("connection failed\n"); - expect(records.find((record) => record.msg === "Plugin output")).toMatchObject({ + expect(records.find((record) => record.message === "connection failed")).toMatchObject({ module: "plugins", pluginId: "tagged-output", - sequence: 1, + sequence: 3, stream: "stderr", message: "connection failed", }); - expect(records.find((record) => record.msg === "Plugin output")?.timestamp).toEqual( + expect(records.find((record) => record.message === "connection failed")?.timestamp).toEqual( expect.any(String), ); await runtime.stopAll(); @@ -221,8 +249,14 @@ describe("PluginRuntime", () => { child.stderr.write("y".repeat(20 * 1024)); await runtime.stopPluginById("noisy"); const logs = runtime.getLogs("noisy"); - expect(logs).toHaveLength(16); - expect(logs.at(-1)).toMatchObject({ stream: "stderr", message: "y".repeat(16 * 1024) }); + expect(logs.length).toBeLessThanOrEqual(500); + expect( + logs.reduce((bytes, entry) => bytes + Buffer.byteLength(entry.message), 0), + ).toBeLessThanOrEqual(256 * 1024); + expect(logs.every((entry) => Buffer.byteLength(entry.message) <= 16 * 1024)).toBe(true); + expect(logs).toContainEqual( + expect.objectContaining({ stream: "stderr", message: "y".repeat(16 * 1024) }), + ); }); it("captures output emitted during initialization and cleanup across reloads", async () => { @@ -263,6 +297,27 @@ describe("PluginRuntime", () => { ); }); + it("retains compilation failures in the plugin stderr tail", async () => { + const directory = await createPlugin("broken-compile", `export default function contribute( {`); + const runtime = createTestRuntime(); + + await expect(runtime.startPlugin("broken-compile", directory)).rejects.toThrow(); + + expect( + runtime.getLogs("broken-compile").map(({ stream, message }) => ({ stream, message })), + ).toEqual([ + { + stream: "stdout", + message: "[paseo] Loading plugin", + }, + { + stream: "stderr", + message: expect.stringContaining("Plugin failed to load:"), + }, + ]); + await runtime.stopAll(); + }); + it("waits for the old subprocess to exit before starting its replacement", async () => { const directory = await createPlugin( "reloadable", @@ -505,6 +560,170 @@ export default function contribute(plugin: any) { await runtime.stopAll(); }); + 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([ + writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "split-runtime" }), + "utf8", + ), + writeFile( + path.join(directory, "index.ts"), + `import type { PluginContext } from "@paseo/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); + return () => undefined; +}`, + "utf8", + ), + writeFile( + path.join(directory, "surface.client.tsx"), + `import React from "react"; +import { StyleSheet, Text } from "react-native"; + +const styles = StyleSheet.create({ label: { fontWeight: "600" } }); + +export function Surface() { + return Client-only surface; +}`, + "utf8", + ), + writeFile( + path.join(directory, "inspect.shared.ts"), + `import { defineRpc } from "@paseo/plugin"; +import { z } from "zod"; + +export const inspectRpc = defineRpc({ + name: "inspect", + input: z.object({}), + output: z.object({ platform: z.string() }), +});`, + "utf8", + ), + writeFile( + path.join(directory, "inspect.server.ts"), + `import { platform } from "node:os"; +import type { z } from "zod"; +import { inspectRpc } from "./inspect.shared"; + +export function inspectHost(_input: z.input) { + return { platform: platform() }; +}`, + "utf8", + ), + ]); + const runtime = createTestRuntime(); + + await runtime.startPlugin("split-runtime", directory); + + const plugin = runtime.catalog()[0]; + expect(plugin?.clientBundle).toContain("Client-only surface"); + expect(plugin?.clientBundle).not.toContain("node:os"); + await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({ + platform: expect.any(String), + }); + await runtime.stopAll(); + }); + + it("rejects server imports from client-only modules", 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: "cross-runtime-import" }), + "utf8", + ), + writeFile( + path.join(directory, "index.ts"), + `import type { PluginContext } from "@paseo/plugin"; +import { Surface } from "./surface.client"; + +export default function contribute(plugin: PluginContext) { + plugin.addSurface("main", Surface); + return () => undefined; +}`, + "utf8", + ), + writeFile( + path.join(directory, "surface.client.tsx"), + `import { readSecret } from "./secret.server"; +export function Surface() { return readSecret(); }`, + "utf8", + ), + writeFile( + path.join(directory, "secret.server.ts"), + `export function readSecret() { return null; }`, + "utf8", + ), + ]); + const runtime = createTestRuntime(); + + await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow( + "server-only module cannot be imported into the plugin client bundle", + ); + await runtime.stopAll(); + }); + + it("rejects client imports from server-only modules", 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: "cross-runtime-import" }), + "utf8", + ), + writeFile( + path.join(directory, "index.ts"), + `import type { PluginContext } from "@paseo/plugin"; +import { inspect } from "./inspect.server"; +import { inspectRpc } from "./inspect.shared"; + +export default function contribute(plugin: PluginContext) { + plugin.handle(inspectRpc, inspect); + return () => undefined; +}`, + "utf8", + ), + writeFile( + path.join(directory, "inspect.shared.ts"), + `import { defineRpc } from "@paseo/plugin"; +import { z } from "zod"; +export const inspectRpc = defineRpc({ + name: "inspect", + input: z.object({}), + output: z.object({}), +});`, + "utf8", + ), + writeFile( + path.join(directory, "inspect.server.ts"), + `import { Surface } from "./surface.client"; +export function inspect() { void Surface; return {}; }`, + "utf8", + ), + writeFile( + path.join(directory, "surface.client.tsx"), + `export function Surface() { return null; }`, + "utf8", + ), + ]); + const runtime = createTestRuntime(); + + await expect(runtime.startPlugin("cross-runtime-import", directory)).rejects.toThrow( + "client-only module cannot be imported into the plugin server bundle", + ); + await runtime.stopAll(); + }); + it("rejects a handler result that does not match its RPC output schema", async () => { const directory = await createPlugin( "invalid-output", diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 0cabc9cdd..f786dbed5 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -10,7 +10,9 @@ import { readPluginManifest } from "./manifest.js"; import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; -const ENTRY_FILENAME = "index.tsx"; +const ENTRY_FILENAME = "index.ts"; +// COMPAT(plugin-index-tsx): added in v0.4, remove after 2027-02-17 +const LEGACY_ENTRY_FILENAME = "index.tsx"; const REQUEST_TIMEOUT_MS = 30_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; @@ -169,6 +171,10 @@ function terminatePluginChild(child: PluginChild): void { if (!child.killed) child.kill(); } +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function send(child: PluginChild, message: PluginProcessRequest): Promise { return new Promise((resolve, reject) => { child.send(message, (error) => { @@ -181,9 +187,13 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise }); } -async function requireRegularFile(filePath: string, label: string): Promise { - const info = await stat(filePath).catch(() => null); - if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`); +async function resolveEntryPath(directory: string): Promise { + for (const filename of [ENTRY_FILENAME, LEGACY_ENTRY_FILENAME]) { + 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)}`); } export class PluginRuntime { @@ -217,12 +227,17 @@ export class PluginRuntime { canPublish: () => boolean = () => true, ): Promise { if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); - const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath); + this.appendLog(pluginId, "stdout", "[paseo] Loading plugin"); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => { + this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`); + throw error; + }); if (!canPublish()) { await this.stopPlugin(loaded); throw new Error(`Plugin start cancelled: ${pluginId}`); } this.plugins.set(pluginId, loaded); + this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } async stopPluginById(pluginId: string): Promise { @@ -290,8 +305,7 @@ export class PluginRuntime { ): Promise { const directory = path.resolve(configuredPath); await readPluginManifest(directory); - const entryPath = path.join(directory, ENTRY_FILENAME); - await requireRegularFile(entryPath, "Plugin entry point"); + const entryPath = await resolveEntryPath(directory); const bundles = await compilePlugin(entryPath); const sessionHost = this.sessionHost; if (!sessionHost) throw new Error("Plugin Paseo session host is not attached"); @@ -394,9 +408,11 @@ export class PluginRuntime { } private async stopPlugin(loaded: LoadedPlugin): Promise { + this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin"); if (loaded.child.killed) { loaded.sessionSocket.peerClosed(); await loaded.sessionClosed; + this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); return; } const closed = new Promise((resolve) => @@ -410,6 +426,7 @@ export class PluginRuntime { await closed; loaded.sessionSocket.peerClosed(); await loaded.sessionClosed; + this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); } private rejectPending(loaded: LoadedPlugin, message: string): void { @@ -421,6 +438,7 @@ export class PluginRuntime { } private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void { + const boundedMessage = Buffer.from(message).subarray(0, MAX_LOG_LINE_BYTES).toString("utf8"); let tail = this.logTails.get(pluginId); if (!tail) { tail = { entries: [], bytes: 0, nextSequence: 1 }; @@ -430,10 +448,10 @@ export class PluginRuntime { sequence: tail.nextSequence++, timestamp: new Date().toISOString(), stream, - message, + message: boundedMessage, }; tail.entries.push(entry); - tail.bytes += Buffer.byteLength(message); + tail.bytes += Buffer.byteLength(boundedMessage); while (tail.entries.length > MAX_LOG_ENTRIES || tail.bytes > MAX_LOG_BYTES) { const removed = tail.entries.shift(); if (!removed) break; diff --git a/plugin-examples/linear/README.md b/plugin-examples/linear/README.md index a6480f50c..dad49a913 100644 --- a/plugin-examples/linear/README.md +++ b/plugin-examples/linear/README.md @@ -25,9 +25,9 @@ Register the extension in `$PASEO_HOME/config.json`: Restart the development daemon after changing its environment or plugin configuration. -The entry point demonstrates the complete attachment-source pattern: +The split entry point demonstrates the complete attachment-source pattern: -- define one validated search RPC; -- handle it in the daemon subprocess; -- register a declarative attachment source for the client; +- 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`; - keep credentials and vendor API calls out of the client bundle. diff --git a/plugin-examples/linear/index.ts b/plugin-examples/linear/index.ts new file mode 100644 index 000000000..f1c57bff8 --- /dev/null +++ b/plugin-examples/linear/index.ts @@ -0,0 +1,9 @@ +import type { PluginContext } from "@paseo/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 () => {}; +} diff --git a/plugin-examples/linear/index.tsx b/plugin-examples/linear/index.tsx deleted file mode 100644 index dcb8ab89e..000000000 --- a/plugin-examples/linear/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from "zod"; -import { - defineAttachmentSource, - defineRpc, - type PluginAttachmentSearchPayload, - type PluginContext, -} from "@paseo/plugin"; -import { createLinearIssueSearch } from "./linear"; - -const SearchPayloadSchema = z.object({ - items: z.array( - z.object({ - id: z.string(), - identifier: z.string(), - title: z.string(), - subtitle: z.string().optional(), - url: z.string().url(), - text: z.string(), - resourceType: z.string(), - }), - ), -}); - -const searchIssues = defineRpc({ - name: "issues.search", - input: z.object({ query: z.string() }), - output: SearchPayloadSchema, -}); - -const issueAttachments = defineAttachmentSource({ - id: "issues", - title: "Linear issue", - icon: "CircleDot", - pickerTitle: "Attach Linear issue", - searchPlaceholder: "Search by identifier or title", - search: searchIssues, -}); - -export default function contribute(plugin: PluginContext) { - plugin.handle(searchIssues, async ({ query }): Promise => { - const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); - return linear.search(query); - }); - plugin.addAttachmentSource(issueAttachments); - return () => {}; -} diff --git a/plugin-examples/linear/issues.server.ts b/plugin-examples/linear/issues.server.ts new file mode 100644 index 000000000..97ccb2f94 --- /dev/null +++ b/plugin-examples/linear/issues.server.ts @@ -0,0 +1,11 @@ +import type { PluginAttachmentSearchPayload } from "@paseo/plugin"; +import type { output as ZodOutput } from "zod"; +import { searchIssuesRpc } from "./issues.shared"; +import { createLinearIssueSearch } from "./linear"; + +export async function searchIssues({ + query, +}: ZodOutput): Promise { + const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); + return linear.search(query); +} diff --git a/plugin-examples/linear/issues.shared.ts b/plugin-examples/linear/issues.shared.ts new file mode 100644 index 000000000..b20d21c58 --- /dev/null +++ b/plugin-examples/linear/issues.shared.ts @@ -0,0 +1,31 @@ +import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; +import { z } from "zod"; + +const SearchPayloadSchema = z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + text: z.string(), + resourceType: z.string(), + }), + ), +}); + +export const searchIssuesRpc = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: SearchPayloadSchema, +}); + +export const issueAttachments = defineAttachmentSource({ + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: searchIssuesRpc, +}); diff --git a/plugin-examples/local-plugin/increment.server.ts b/plugin-examples/local-plugin/increment.server.ts new file mode 100644 index 000000000..996cb4c37 --- /dev/null +++ b/plugin-examples/local-plugin/increment.server.ts @@ -0,0 +1,9 @@ +import type { output as ZodOutput } from "zod"; +import { incrementRpc } from "./increment.shared"; + +export function increment(input: ZodOutput) { + return { + value: input.value + 1, + handledBy: "plugin subprocess", + }; +} diff --git a/plugin-examples/local-plugin/increment.shared.ts b/plugin-examples/local-plugin/increment.shared.ts new file mode 100644 index 000000000..2b3f940af --- /dev/null +++ b/plugin-examples/local-plugin/increment.shared.ts @@ -0,0 +1,8 @@ +import { defineRpc } from "@paseo/plugin"; +import { z } from "zod"; + +export const incrementRpc = defineRpc({ + name: "increment", + input: z.object({ value: z.number() }), + output: z.object({ value: z.number(), handledBy: z.string() }), +}); diff --git a/plugin-examples/local-plugin/index.ts b/plugin-examples/local-plugin/index.ts new file mode 100644 index 000000000..dc19d8959 --- /dev/null +++ b/plugin-examples/local-plugin/index.ts @@ -0,0 +1,25 @@ +import type { PluginContext } from "@paseo/plugin"; +import { 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", + Component: ExamplePanel, + }); + plugin.addCommandCenterItem({ + id: "open-counter", + title: "Open plugin counter", + icon: "Blocks", + context: "workspace", + onSelect({ openPanel }) { + openPanel("counter"); + }, + }); + return () => {}; +} diff --git a/plugin-examples/local-plugin/index.tsx b/plugin-examples/local-plugin/main.client.tsx similarity index 67% rename from plugin-examples/local-plugin/index.tsx rename to plugin-examples/local-plugin/main.client.tsx index 345aac172..5be6a9e6d 100644 --- a/plugin-examples/local-plugin/index.tsx +++ b/plugin-examples/local-plugin/main.client.tsx @@ -1,20 +1,9 @@ +import { useMutation } from "@tanstack/react-query"; +import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin"; import React, { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; -import { useMutation } from "@tanstack/react-query"; import { z } from "zod"; -import { - defineRpc, - type PluginContext, - type PluginWorkspacePanelProps, - useWorkspace, - useRpc, -} from "@paseo/plugin"; - -const incrementRpc = defineRpc({ - name: "increment", - input: z.object({ value: z.number() }), - output: z.object({ value: z.number(), handledBy: z.string() }), -}); +import { incrementRpc } from "./increment.shared"; const ExampleThemeSchema = z.object({ colors: z.object({ @@ -27,7 +16,7 @@ const ExampleThemeSchema = z.object({ }), }); -function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) { +export function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) { const { colors } = ExampleThemeSchema.parse(theme); const workspace = useWorkspace(workspaceId, ({ name }) => ({ name })); const callIncrement = useRpc(incrementRpc); @@ -67,27 +56,3 @@ function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) { ); } - -export default function contribute(plugin: PluginContext) { - plugin.handle(incrementRpc, async (input) => ({ - value: input.value + 1, - handledBy: "plugin subprocess", - })); - plugin.addWorkspacePanel({ - id: "counter", - title: "Plugin counter", - icon: "Blocks", - context: "workspace", - Component: ExamplePanel, - }); - plugin.addCommandCenterItem({ - id: "open-counter", - title: "Open plugin counter", - icon: "Blocks", - context: "workspace", - onSelect({ openPanel }) { - openPanel("counter"); - }, - }); - return () => {}; -} diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index 3c5898a74..ed0992407 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -32,27 +32,39 @@ cd /absolute/path/to/workspace-plugin npm install ``` -`init` creates a strict TSX project. It does not run the package manager. +`init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. -Replace `index.tsx` with: +Replace `main.client.tsx` with: ```tsx -import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; -import { Text, View } from "react-native"; +import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; +import { StyleSheet, Text, View } from "react-native"; -function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) { +export function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) { const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({ name, directory, })); return ( - - {workspace?.name} + + {workspace?.name} {workspace?.directory} ); } +const styles = StyleSheet.create({ + screen: { flex: 1, padding: 24, gap: 8 }, + title: { fontSize: 24 }, +}); +``` + +Replace `index.ts` with: + +```ts +import type { PluginContext } from "@paseo/plugin"; +import { WorkspaceOverview } from "./main.client"; + export default function contribute(plugin: PluginContext) { plugin.addWorkspacePanel({ id: "overview", @@ -74,7 +86,7 @@ export default function contribute(plugin: PluginContext) { } ``` -The icon is a [Lucide](https://lucide.dev/icons/) icon name. The panel uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. +The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs such as `StyleSheet.create`; Paseo excludes them from the daemon bundle. The panel works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. ## Check and install it @@ -113,7 +125,8 @@ paseo plugin logs workspace-plugin paseo plugin logs workspace-plugin --json ``` -The log tail survives reloads and crashes, so inspect it when a plugin fails to start or an RPC +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. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 9cf194b76..1a8c2b9e5 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -26,7 +26,8 @@ Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Ba ```text my-plugin/ paseo-plugin.json - index.tsx + index.ts + main.client.tsx paseo-plugin.d.ts package.json tsconfig.json @@ -42,15 +43,33 @@ Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachm The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes. +Add runtime-specific files as the plugin grows: + +```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. | + +Paseo 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. + ## Entry point and cleanup -`index.tsx` default-exports one contribution function. It must return cleanup, even when it has nothing to clean: +`index.ts` wires contributions together and default-exports one contribution function. It must return cleanup, even when it has nothing to clean: -```tsx +```ts import type { PluginContext } from "@paseo/plugin"; +import { Main } from "./main.client"; export default function contribute(plugin: PluginContext) { - // Register contributions here. + plugin.addSurface("main", Main); return () => {}; } ``` @@ -61,13 +80,22 @@ 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`: + ```tsx -import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin"; +import type { PluginSurfaceProps } from "@paseo/plugin"; import { Text } from "react-native"; -function Main({ host, layout }: PluginSurfaceProps) { +export function Main({ host, layout }: PluginSurfaceProps) { return {`${host.label} · ${layout.platform}`}; } +``` + +`index.ts`: + +```ts +import type { PluginContext } from "@paseo/plugin"; +import { Main } from "./main.client"; export default function contribute(plugin: PluginContext) { plugin.addSurface("main", Main); @@ -97,20 +125,24 @@ Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, Register one panel for workspace or agent context: +`review.client.tsx`: + ```tsx -import { - type PluginContext, - type PluginAgentPanelProps, - useAgent, - useWorkspace, -} from "@paseo/plugin"; +import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin"; import { Text } from "react-native"; -function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); return {`${workspaceName} · ${agent?.title ?? agent?.id}`}; } +``` + +`index.ts`: + +```ts +import type { PluginContext } from "@paseo/plugin"; +import { ReviewPanel } from "./review.client"; export default function contribute(plugin: PluginContext) { plugin.addWorkspacePanel({ @@ -279,27 +311,53 @@ 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: -```tsx -import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; +`greeting.shared.ts`: + +```ts +import { defineRpc } from "@paseo/plugin"; import { z } from "zod"; -const greeting = defineRpc({ +export const greeting = defineRpc({ name: "greeting.create", input: z.object({ name: z.string() }), output: z.object({ message: z.string() }), }); +``` -function GreetingButton() { +`greeting.client.tsx`: + +```tsx +import { useRpc } from "@paseo/plugin"; +import { greeting } from "./greeting.shared"; + +export function GreetingButton() { const createGreeting = useRpc(greeting); // Call createGreeting({ name: "Ada" }) from an event or query. return null; } +``` + +`greeting.server.ts`: + +```ts +import type { output as ZodOutput } from "zod"; +import { greeting } from "./greeting.shared"; + +export function createGreeting({ name }: ZodOutput) { + return { message: `Hello, ${name}` }; +} +``` + +`index.ts`: + +```ts +import type { PluginContext } from "@paseo/plugin"; +import { GreetingButton } from "./greeting.client"; +import { createGreeting } from "./greeting.server"; +import { greeting } from "./greeting.shared"; 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.handle(greeting, createGreeting); plugin.addSurface("main", GreetingButton); return () => {}; } @@ -318,8 +376,11 @@ console.log("Refreshing issues"); console.error("Issue refresh failed", error); ``` -Paseo captures output emitted during initialization, RPC handlers, cleanup, and process failure. -Protocol traffic uses a separate channel, so `console.log()` cannot corrupt plugin RPCs. +Paseo adds `[paseo]` entries when the plugin starts loading, becomes ready, starts stopping, and has +stopped. It records compilation and load failures as stderr entries, including failures that happen +before the plugin subprocess starts. Paseo also captures output emitted during initialization, RPC +handlers, cleanup, and process failure. Protocol traffic uses a separate channel, so `console.log()` +cannot corrupt plugin RPCs. Open **Settings → Plugins → Logs** for the plugin, or inspect the same recent tail from the daemon CLI: @@ -335,9 +396,9 @@ the command again for newer entries. Each entry includes its timestamp, stdout o sequence, and message. Paseo retains up to 500 entries and 256 KiB per plugin in memory. Individual lines are capped at -16 KiB. Reload, disable, initialization failure, and process failure retain the tail. Removing the -plugin clears it, and a daemon restart starts a new tail. Structured copies are also written to the -daemon log at `$PASEO_HOME/daemon.log`. +16 KiB. Reload, disable, compilation failure, initialization failure, and process failure retain the +tail. Removing the plugin clears it, and a daemon restart starts a new tail. Structured copies are +also written to the daemon log at `$PASEO_HOME/daemon.log`. Only daemon-side output is captured. Logs from client surfaces remain in the app runtime. Do not log credentials, access tokens, or other secrets: connected users can read the retained tail, and the @@ -347,11 +408,13 @@ 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. -```tsx -import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin"; +`issues.shared.ts`: + +```ts +import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; import { z } from "zod"; -const searchIssues = defineRpc({ +export const searchIssues = defineRpc({ name: "issues.search", input: z.object({ query: z.string() }), output: z.object({ @@ -369,7 +432,7 @@ const searchIssues = defineRpc({ }), }); -const issues = defineAttachmentSource({ +export const issues = defineAttachmentSource({ id: "issues", title: "Acme issue", icon: "CircleDot", @@ -377,9 +440,28 @@ const issues = defineAttachmentSource({ searchPlaceholder: "Search by identifier or title", search: searchIssues, }); +``` + +`issues.server.ts`: + +```ts +import type { output as ZodOutput } from "zod"; +import { searchIssues } from "./issues.shared"; + +export function search({ query }: ZodOutput) { + return searchAcmeIssues(query); +} +``` + +`index.ts`: + +```ts +import type { PluginContext } from "@paseo/plugin"; +import { search } from "./issues.server"; +import { issues, searchIssues } from "./issues.shared"; export default function contribute(plugin: PluginContext) { - plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); + plugin.handle(searchIssues, search); plugin.addAttachmentSource(issues); return () => {}; }