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 () => {}; }