From 1f5b6143d603ebecc678daf76d407938175be044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Lindemann?= Date: Mon, 7 Sep 2026 23:42:28 +0200 Subject: [PATCH] Enforce shared, client, and server plugin SDK boundaries (#4347) * fix(plugins): keep React out of the server plugin host * fix(plugins): align SDK guidance and tests with runtime boundaries * fix(plugin): loosen the react peer to ~19.1.0 The plugin SDK pins its react peer to exactly 19.1.0, and that pin is what a downstream `npm i -g @getpaseo/cli` resolves against. So every user ends up with react 19.1.0 in their global node_modules, where corporate dependency scanners flag it: the CVE-2025-55182 advisory range covers 19.1.0 even though the vulnerability itself lives in react-server-dom-webpack/-parcel/-turbopack, none of which the CLI installs. The user is then left hand-patching node_modules or uninstalling Paseo. Widening the peer to ~19.1.0 lets a downstream install pick the latest 19.1.x patch (19.1.9 today) while this repo stays on 19.1.0, which the root override still pins. Keeping the repo itself on 19.1.0 is deliberate. react-native 0.81.5 embeds a 19.1.0 Paper renderer that hard-throws "Incompatible React versions" on any exact mismatch, and it is reachable from Fabric builds too: RendererImplementation.js requires the Paper shim unconditionally in findNodeHandle, unstable_batchedUpdates, sendAccessibilityEvent and three other entry points, and react-native-gesture-handler re-exports findNodeHandle. Bumping the app's react is therefore not safe until react-native moves. The published CLI tree contains no react-native, so downstream installs are not subject to that constraint. Supersedes the earlier 19.1.5/19.1.9 version-bump approach on this branch, which would have broken the mobile app. * Enforce example import ownership including client types * Update Nix dependency hash for plugin peer metadata * Group plugin APIs under client and server entry trees * Resolve plugin type dependencies through declarations * Validate declarations for implicit plugin type imports * Preserve optional imports and validate declaration references * Refresh Nix hash for plugin declaration resolver * Canonicalize plugin paths consistently on Windows * Normalize plugin ownership paths across resolver boundaries --------- Co-authored-by: liujin0506 Co-authored-by: Mohamed Boudra --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/ci-paths.yml | 5 +- .github/workflows/ci.yml | 9 + CLAUDE.md | 2 + CONTRIBUTING.md | 2 + docs/plugins.md | 55 ++- docs/qa.md | 3 + nix/npm-deps.hash | 2 +- package-lock.json | 3 +- .../app/e2e/browser/plugin-host-ui.spec.ts | 4 +- .../browser/plugin-workspace-panels.spec.ts | 3 +- .../app/src/components/settings/index.tsx | 2 +- packages/app/src/plugins/actions.ts | 6 +- .../app/src/plugins/attachments/picker.tsx | 2 +- .../app/src/plugins/client-runtime.test.ts | 2 +- packages/app/src/plugins/client-runtime.ts | 2 +- .../app/src/plugins/client-state/source.ts | 2 +- .../command-center/contributions.test.ts | 2 +- .../plugins/command-center/contributions.ts | 2 +- .../app/src/plugins/composer-pills/index.tsx | 5 +- .../app/src/plugins/composer-pills/store.ts | 2 +- packages/app/src/plugins/evaluate.test.ts | 43 +- packages/app/src/plugins/evaluate.ts | 41 +- packages/app/src/plugins/host-navigation.ts | 2 +- packages/app/src/plugins/icons.ts | 2 +- packages/app/src/plugins/navigation.ts | 2 +- .../app/src/plugins/react-native/modal.tsx | 8 +- .../app/src/plugins/react-native/toast.ts | 2 +- packages/app/src/plugins/runtime-boundary.tsx | 2 +- packages/app/src/plugins/settings/index.tsx | 2 +- .../app/src/plugins/settings/use-settings.ts | 6 +- .../app/src/plugins/surface-runtime.test.tsx | 4 +- packages/app/src/plugins/surface-screen.tsx | 3 +- packages/app/src/plugins/timeline/view.tsx | 5 +- packages/app/src/plugins/types.ts | 8 +- .../src/plugins/workspace-panels/locations.ts | 5 +- .../src/plugins/workspace-panels/panel.tsx | 6 +- .../plugins/workspace-panels/resolution.ts | 2 +- .../cli/src/commands/plugin/scaffold.test.ts | 16 +- packages/cli/src/commands/plugin/scaffold.ts | 8 +- packages/plugin/package.json | 51 +- packages/plugin/src/attachments.ts | 7 + packages/plugin/src/boundaries.test.ts | 123 +++++ .../plugin/src/{ => client}/client-state.tsx | 2 +- packages/plugin/src/client/contracts.ts | 246 ++++++++++ packages/plugin/src/{ => client}/host.test.ts | 2 +- packages/plugin/src/{ => client}/host.ts | 7 +- packages/plugin/src/client/index.ts | 37 ++ .../plugin/src/{ => client}/paseo-context.tsx | 0 .../plugin/src/{ => client}/react-native.ts | 0 .../plugin/src/{ => client}/rpc-context.tsx | 2 +- .../{ => client}/runtime-context-bridge.tsx | 0 .../plugin/src/{ => client}/shallow.test.ts | 0 packages/plugin/src/{ => client}/shallow.ts | 0 packages/plugin/src/{ => client}/ui.ts | 0 .../{ => client}/use-sync-external-store.d.ts | 0 packages/plugin/src/contracts.ts | 245 +--------- packages/plugin/src/index.ts | 87 +--- packages/plugin/src/migration-doc.test.ts | 2 +- packages/plugin/src/server.test.ts | 57 --- .../{ => server}/acp-internal/connection.ts | 0 packages/plugin/src/{ => server}/acp.test.ts | 0 packages/plugin/src/{ => server}/acp.ts | 0 packages/plugin/src/server/contracts.ts | 26 ++ .../plugin/src/{server.ts => server/index.ts} | 15 +- packages/plugin/src/{ => server}/lifecycle.ts | 0 packages/plugin/src/{ => server}/provider.ts | 0 packages/plugin/src/settings.ts | 16 +- packages/server/package.json | 2 +- .../src/server/agent/plugin-provider.test.ts | 2 +- .../src/server/agent/plugin-provider.ts | 2 +- .../agent/provider-snapshot-manager.test.ts | 2 +- .../server/agent/provider-snapshot-manager.ts | 2 +- .../src/server/plugins/compiler-imports.ts | 126 +++++ .../src/server/plugins/compiler.test.ts | 442 ++++++++++++++++-- .../server/src/server/plugins/compiler.ts | 284 +++++++---- packages/server/src/server/plugins/index.ts | 2 +- .../plugins/plugin-paseo-api.e2e.test.ts | 3 +- .../server/plugins/plugin-process-protocol.ts | 4 +- .../src/server/plugins/plugin-process.test.ts | 39 ++ .../src/server/plugins/plugin-process.ts | 38 +- .../server/plugins/plugin-sdk-specifiers.ts | 30 +- .../src/server/plugins/runtime.posix.test.ts | 112 +++-- packages/server/src/server/plugins/runtime.ts | 2 +- .../src/server/plugins/settings.e2e.test.ts | 2 +- .../src/server/plugins/settings/index.ts | 2 +- .../agent-configuration/index.server.ts | 2 +- plugin-examples/catppuccin/index.client.ts | 2 +- .../inline-thinking/client/thinking.tsx | 4 +- .../inline-thinking/index.client.tsx | 2 +- .../lifecycle-actions/index.server.ts | 2 +- .../lifecycle-logger/index.server.ts | 2 +- plugin-examples/linear/index.client.ts | 2 +- plugin-examples/linear/index.server.ts | 2 +- plugin-examples/local-plugin/client/main.tsx | 4 +- plugin-examples/local-plugin/index.client.tsx | 2 +- plugin-examples/local-plugin/index.server.ts | 2 +- plugin-examples/modal-ui/client/examples.tsx | 4 +- plugin-examples/modal-ui/index.client.tsx | 2 +- .../provider-acp-transformer/index.server.ts | 4 +- .../server/vendor-edit.ts | 2 +- .../client/provider-result.tsx | 2 +- .../provider-direct/index.client.tsx | 2 +- .../provider-direct/index.server.ts | 2 +- .../provider-direct/server/provider.test.ts | 2 +- .../provider-direct/server/provider.ts | 2 +- .../settings/client/display-settings.tsx | 5 +- plugin-examples/settings/index.client.tsx | 2 +- plugin-examples/settings/index.server.ts | 2 +- .../timeline-items/client/pi-tasks.tsx | 2 +- .../client/transform-pi-tasks.ts | 72 +++ .../timeline-items/index.client.tsx | 5 +- .../timeline-items/pi-tasks.test.ts | 2 +- .../timeline-items/shared/pi-tasks.ts | 70 --- public-docs/plugins/v0.8/index.md | 6 +- public-docs/plugins/v0.8/migration.md | 29 +- public-docs/plugins/v0.8/providers.md | 12 +- public-docs/plugins/v0.8/reference.md | 98 ++-- scripts/ci-workflow.test.mjs | 13 +- skills/paseo-plugin/SKILL.md | 33 +- 120 files changed, 1774 insertions(+), 929 deletions(-) create mode 100644 packages/plugin/src/boundaries.test.ts rename packages/plugin/src/{ => client}/client-state.tsx (98%) create mode 100644 packages/plugin/src/client/contracts.ts rename packages/plugin/src/{ => client}/host.test.ts (99%) rename packages/plugin/src/{ => client}/host.ts (75%) create mode 100644 packages/plugin/src/client/index.ts rename packages/plugin/src/{ => client}/paseo-context.tsx (100%) rename packages/plugin/src/{ => client}/react-native.ts (100%) rename packages/plugin/src/{ => client}/rpc-context.tsx (94%) rename packages/plugin/src/{ => client}/runtime-context-bridge.tsx (100%) rename packages/plugin/src/{ => client}/shallow.test.ts (100%) rename packages/plugin/src/{ => client}/shallow.ts (100%) rename packages/plugin/src/{ => client}/ui.ts (100%) rename packages/plugin/src/{ => client}/use-sync-external-store.d.ts (100%) delete mode 100644 packages/plugin/src/server.test.ts rename packages/plugin/src/{ => server}/acp-internal/connection.ts (100%) rename packages/plugin/src/{ => server}/acp.test.ts (100%) rename packages/plugin/src/{ => server}/acp.ts (100%) create mode 100644 packages/plugin/src/server/contracts.ts rename packages/plugin/src/{server.ts => server/index.ts} (66%) rename packages/plugin/src/{ => server}/lifecycle.ts (100%) rename packages/plugin/src/{ => server}/provider.ts (100%) create mode 100644 packages/server/src/server/plugins/compiler-imports.ts create mode 100644 packages/server/src/server/plugins/plugin-process.test.ts create mode 100644 plugin-examples/timeline-items/client/transform-pi-tasks.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fbe37e918..3113545559 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this, ### Checklist +- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable) - [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 7da0b185cb..a009ba463d 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -50,6 +50,9 @@ app: - "packages/expo-two-way-audio/**" sdk: + - "packages/plugin/**" + - "plugin-examples/**" + - "public-docs/plugins/v0.8/**" - "packages/client/**" - "packages/highlight/**" - "packages/protocol/**" @@ -66,7 +69,7 @@ browser: - "packages/server/src/server/agent/plugin-provider.ts" - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" - "packages/server/src/executable-resolution/**" - - "packages/plugin/src/provider.ts" + - "packages/plugin/src/server/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c555a61432..3452f38ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,6 +298,15 @@ jobs: - name: Build client dependencies run: npm run build:client + - name: Build plugin SDK + run: npm run build:plugin + + - name: Run plugin SDK tests + run: npm run test --workspace=@getpaseo/plugin + + - name: Typecheck plugin examples + run: npm run typecheck --workspace=@getpaseo/plugin + - name: Run protocol tests run: npm run test --workspace=@getpaseo/protocol diff --git a/CLAUDE.md b/CLAUDE.md index b62a53d409..9ed4aba4d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,8 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## Critical rules +- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries. + - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eaaf5c0c7..476a317db4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,8 @@ Due to the volume and my limited bandwidth, I may not participate in all the dis ## Pull requests +For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries). + Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at. diff --git a/docs/plugins.md b/docs/plugins.md index 76214a6dca..bfec8e7328 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -158,7 +158,7 @@ Do not put any other code modules in the plugin root. Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import `PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from -`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon +`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface. Its controlled modal keeps presentation metadata on `` and body UI in ``. Body layout, sheet-aware scrolling, and clipboard actions follow the @@ -167,16 +167,37 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text` `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. See `public-docs/plugins/v0.8/reference.md`. -| Module | Use it for | -| ------------------------------- | ----------------------------------------------------------------------------------------- | -| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks | -| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks | -| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | -| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts | -| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks | +### SDK import boundaries -The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:` -import reachable from client code. Shared modules cannot import runtime-owned modules. A relative +Classify every SDK export before adding it. All client entry points and implementations live under +`client/`; all server entry points and implementations live under `server/`. The package root is shared code: plain data types, +Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural +dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts. + +| Entry | Owns | May depend on | +| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | +| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | +| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | +| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | +| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | +| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code | + +Server code imports shared helpers from the root and server capabilities from `/server`. Client +code imports shared helpers from the root and client capabilities from `/client`. Neither runtime +imports the other. Re-exports follow the same rule; tree shaking does not establish a boundary. +React, React Native, JSX runtimes, and client hooks must never be reachable from the root or any +server entry. Node and platform-specific code must never be reachable from the shared root. + +The SDK boundary checks and real plugin-subprocess tests enforce these rules. Every SDK change +must preserve them and update the public reference, migration guide, scaffold, and examples when +an author-facing import changes. The plugin compiler enforces the same runtime entry rules for +plugin-authored code. Keep the package export map and host-provided module maps consistent. + +The compiler rejects imports across runtime directories or SDK entries, React dependencies in +server code, and Node imports in client code (including bare names such as `fs`). Shared modules +cannot import runtime-owned modules. Forbidden imports fail compilation; never replace them with +empty module stubs. A relative import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`, or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React Native calls such as `StyleSheet.create` belong in `client/`. @@ -190,7 +211,7 @@ pattern. ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetRpc } from "./shared/greeting"; @@ -202,7 +223,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Greeting } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -279,8 +300,8 @@ Register a provider from `index.server.ts`. The provider connection is callback- of its sessions; plugin RPC is not part of the provider data path. ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { createProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { @@ -306,7 +327,7 @@ persistence. Providers re-read credentials, environment, global configuration, a `session.open`; there is no provider reload input. For an ACP command, register `runAcpProvider({ id, label, command })` from -`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the +`@getpaseo/plugin/server/acp`. Its transformer hooks cover narrow vendor differences; do not translate the whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct` and `plugin-examples/provider-acp-transformer`. @@ -438,7 +459,7 @@ credentials and vendor API calls stay in the daemon handler. ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { search } from "./server/issues"; import { searchIssues } from "./shared/issues"; @@ -450,7 +471,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { diff --git a/docs/qa.md b/docs/qa.md index 25176f80fe..8eb5eb5247 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -11,6 +11,9 @@ The bar is four questions: Every pull request needs evidence for the questions its change touches. Pull requests without evidence get closed. +For plugin changes, verify the [SDK import boundaries](plugins.md#sdk-import-boundaries) before +exercising contributions. Include the boundary tests and both runtime loaders in the evidence. + ## Evidence Evidence is something someone else can look at: diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index bbf576be8a..394e1f48ef 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zpE2CbXKy0D4tsYdeBo7JiNBNxSnB1JlnS3PtCmXKiw= +sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA= diff --git a/package-lock.json b/package-lock.json index 9fb68d487a..d6775eb308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34474,7 +34474,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -39016,6 +39015,7 @@ "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", + "typescript": "^5.9.3", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -39031,7 +39031,6 @@ "@vitest/ui": "^4.1.6", "cross-env": "^10.1.0", "tsx": "^4.6.0", - "typescript": "^5.2.2", "vitest": "^4.1.6" } }, diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 80ee02459d..bd53c59272 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -12,8 +12,8 @@ import { const PLUGIN_ID = "plugin-host-ui-e2e"; -const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; +const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Pressable, Text, View } from "react-native"; diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 4e7d7682df..662027133e 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -30,7 +30,8 @@ function isSettledWorkspaceUrl(url: URL): boolean { function pluginClientSource(input: { workspaceId: string; agentId: string }): string { return `import React, { useRef } from "react"; import { Pressable, Text, View } from "react-native"; -import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin"; +import { Icon } from "@getpaseo/plugin/client/react-native"; +import { useAgent, useWorkspace } from "@getpaseo/plugin/client"; import { recordComposerOpen } from "./shared/rpc"; function WorkspacePanel({ workspaceId, host, layout }) { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 371709f60f..7e8c0ea5e6 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -15,7 +15,7 @@ import type { SettingsSelectProps, SettingsInputProps, SettingsActionProps, -} from "@getpaseo/plugin/ui"; +} from "@getpaseo/plugin/client/ui"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 7f8678add2..9f2329df09 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,11 +1,11 @@ -import { callPluginRpc } from "@getpaseo/plugin/host"; +import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, PluginCommandCapabilities, PluginPanelLocation, PluginWorkspaceCommandContext, -} from "@getpaseo/plugin"; -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +} from "@getpaseo/plugin/client"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations"; import type { PluginSurfaceRuntime } from "./surface-runtime"; import type { InstalledPlugin } from "./types"; diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx index f25d5c4d20..a63c1eb4e0 100644 --- a/packages/app/src/plugins/attachments/picker.tsx +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin"; -import { searchPluginAttachments } from "@getpaseo/plugin/host"; +import { searchPluginAttachments } from "@getpaseo/plugin/client/host"; import type { LucideIcon } from "lucide-react-native"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMenuItem } from "@/composer/input/input"; diff --git a/packages/app/src/plugins/client-runtime.test.ts b/packages/app/src/plugins/client-runtime.test.ts index 34fe7d706e..d76a824e9d 100644 --- a/packages/app/src/plugins/client-runtime.test.ts +++ b/packages/app/src/plugins/client-runtime.test.ts @@ -1,6 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginComposerPillProps } from "@getpaseo/plugin"; +import type { PluginComposerPillProps } from "@getpaseo/plugin/client"; import { afterEach, describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; import { createPluginClientRuntime } from "./client-runtime"; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cef8bbb341..2a837ebcd7 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,5 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin"; +import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { createPluginAgentActionContext, createPluginCapabilities, diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index d7aaee597e..142c1f3575 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -1,4 +1,4 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { useSessionStore } from "@/stores/session-store"; import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 575dbdb99d..dc40b342b9 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -4,9 +4,9 @@ import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { defineRpc, type PluginAgentSnapshot, - type PluginCommandCenterItemContribution, type PluginWorkspaceSnapshot, } from "@getpaseo/plugin"; +import { type PluginCommandCenterItemContribution } from "@getpaseo/plugin/client"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import type { InstalledPlugin } from "../types"; diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index fbe67e034f..448b0068cb 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -1,4 +1,4 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import type { CommandCenterContribution } from "@/command-center/contributions"; import { getCommandCenterIcon } from "@/command-center/icon"; import { resolvePluginIcon } from "../icons"; diff --git a/packages/app/src/plugins/composer-pills/index.tsx b/packages/app/src/plugins/composer-pills/index.tsx index 12797bb08d..63f33cfad6 100644 --- a/packages/app/src/plugins/composer-pills/index.tsx +++ b/packages/app/src/plugins/composer-pills/index.tsx @@ -1,5 +1,6 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { PluginComposerPillProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { Platform, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; diff --git a/packages/app/src/plugins/composer-pills/store.ts b/packages/app/src/plugins/composer-pills/store.ts index ae273eb2b5..2f3629e4a4 100644 --- a/packages/app/src/plugins/composer-pills/store.ts +++ b/packages/app/src/plugins/composer-pills/store.ts @@ -1,4 +1,4 @@ -import type { PluginComposerPillContribution } from "@getpaseo/plugin"; +import type { PluginComposerPillContribution } from "@getpaseo/plugin/client"; import type { InstalledPlugin } from "../types"; const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1d042284aa..c66a35d5cb 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -441,11 +441,11 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("must return a cleanup function"); }); - it("provides the host Icon component through @getpaseo/plugin", () => { + it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon } = require("@getpaseo/plugin"); + const { Icon } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { plugin.addSurface("main", function Surface() { @@ -463,11 +463,11 @@ describe("evaluatePluginClientBundle", () => { expect(element).toMatchObject({ props: { size: 18, color: "#123456" } }); }); - it("provides Paseo UI through @getpaseo/plugin/react-native", () => { + it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native"); + const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") { @@ -483,6 +483,41 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps shared and client runtime exports separate", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { + const shared = require("@getpaseo/plugin"); + const client = require("@getpaseo/plugin/client"); + for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace"]) { + if (name in shared || typeof client[name] !== "function") throw new Error(name); + } + if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports"); + return { default() { return () => {}; } }; + })`, + ), + ).not.toThrow(); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + "@getpaseo/plugin/client/host", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/host", + "@paseo/plugin", + ])("rejects %s in the client loader", (specifier) => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { require("${specifier}"); return {}; })`, + ), + ).toThrow("not available in plugin client code"); + }); + it("resolves shared RPC helpers from @getpaseo/plugin", () => { const plugin = evaluatePluginClientBundle( "example", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e989d44966..bcad62dd40 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,6 +1,7 @@ import * as pluginUiRuntime from "./react-native/ui"; import { useSettings } from "./settings/use-settings"; -import { defineSettings } from "@getpaseo/plugin"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginClientRuntime from "@getpaseo/plugin/client"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. @@ -9,27 +10,23 @@ import * as ReactNative from "react-native"; import * as ReactQuery from "@tanstack/react-query"; import * as Zod from "zod"; import { - defineAttachmentSource, - defineRpc, type PluginAttachmentSourceContribution, - type PluginCommandCenterItemContribution, type PluginCleanup, + type PluginThemeContribution, +} from "@getpaseo/plugin"; +import { + type PluginCommandCenterItemContribution, type PluginClientContext, type PluginClientSlashCommandContribution, type PluginSidebarContribution, type PluginSurfaceProps, - type PluginThemeContribution, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, type PluginWorkspacePanelContribution, - usePaseo, - useAgent, - useWorkspace, - useRpc, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import type { EvaluatedPlugin } from "./types"; import type { ComponentType } from "react"; -import { Icon, resolvePluginIcon } from "./icons"; +import { resolvePluginIcon } from "./icons"; import { pluginReactNativeRuntime } from "./react-native/runtime"; import { parsePluginThemeContribution } from "./themes"; @@ -352,29 +349,15 @@ export function runPluginClientBundle( }, }; const runtimeRequire = (name: string): unknown => { - if (name === "@getpaseo/plugin/ui") return pluginUiRuntime; + if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; - if (name === "@getpaseo/plugin") { - return { - defineAttachmentSource, - defineRpc, - defineSettings, - useSettings, - Icon, - usePaseo, - useAgent, - useWorkspace, - useRpc, - }; - } - if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") { + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, useSettings }; + if (name === "@getpaseo/plugin/client/react-native") { return pluginReactNativeRuntime; } - if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") { - return {}; - } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; throw new Error(`Module "${name}" is not available in plugin client code`); diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts index 835cc41657..78fdef566d 100644 --- a/packages/app/src/plugins/host-navigation.ts +++ b/packages/app/src/plugins/host-navigation.ts @@ -1,4 +1,4 @@ -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToAgent } from "@/utils/navigate-to-agent"; diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index 1e3fdb4cbf..3c5ec581ce 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -1,6 +1,6 @@ import { createElement, type ReactElement } from "react"; import * as LucideIcons from "lucide-react-native"; -import type { PluginIconProps } from "@getpaseo/plugin"; +import type { PluginIconProps } from "@getpaseo/plugin/client"; import type { LucideIcon } from "lucide-react-native"; function findPluginIcon(name: string): LucideIcon | null { diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index e155384d52..17e277504c 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,6 +1,6 @@ import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; -import type { PluginPanelLocation } from "@getpaseo/plugin"; +import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildPluginSurfaceRoute } from "./routes"; diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index 2785531d55..b61cd925b1 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -1,6 +1,10 @@ import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native"; -import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host"; +import type { + ModalComponent, + ModalContentProps, + ModalProps, +} from "@getpaseo/plugin/client/react-native"; +import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host"; import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context"; diff --git a/packages/app/src/plugins/react-native/toast.ts b/packages/app/src/plugins/react-native/toast.ts index e144390bfa..fd4d503799 100644 --- a/packages/app/src/plugins/react-native/toast.ts +++ b/packages/app/src/plugins/react-native/toast.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native"; +import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native"; import { useToast as useAppToast } from "@/contexts/toast-api-context"; export function useToast(): ToastApi { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx index 7f2d62c054..eaed2e5d54 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/host"; +import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; import type { ReactNode } from "react"; import type { InstalledPlugin } from "./types"; import type { PluginSurfaceRuntime } from "./surface-runtime"; diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 175daa994d..dbf9dec18d 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Platform, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { router } from "expo-router"; -import type { PluginHostProps } from "@getpaseo/plugin"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; import { SettingsAction } from "@/components/settings"; import { Button } from "@/components/ui/button"; import { useIsCompactFormFactor } from "@/constants/layout"; diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts index 8c19a4f7f2..0cefc1878f 100644 --- a/packages/app/src/plugins/settings/use-settings.ts +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -1,7 +1,9 @@ +import type { SettingsState } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { useRpc } from "@getpaseo/plugin/client"; +import { type SettingsDefinition } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin"; import { useReplicaQuery } from "@/data/query"; import { z, type ZodType } from "zod"; diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx index ec3ad2d4d1..88d8f694ec 100644 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ b/packages/app/src/plugins/surface-runtime.test.tsx @@ -1,7 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PaseoApi } from "@getpaseo/client"; -import { PaseoApiProvider } from "@getpaseo/plugin/host"; -import { usePaseo } from "@getpaseo/plugin"; +import { PaseoApiProvider } from "@getpaseo/plugin/client/host"; +import { usePaseo } from "@getpaseo/plugin/client"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 849f357ba5..6139897c5b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,5 +1,6 @@ import { router, useLocalSearchParams } from "expo-router"; -import type { PluginSurfaceProps, PluginTheme } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import { ChevronDown, X } from "lucide-react-native"; import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; import { Platform, Pressable, Text, View } from "react-native"; diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 443172263b..6eba06c32b 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -1,5 +1,6 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginHostProps, PluginTheme, PluginTimelineItemProps } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { PluginHostProps, PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import React, { type ComponentType, useMemo } from "react"; import { Platform, Text } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9247523d29..651e5f7b28 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -2,19 +2,21 @@ import type { QueryClient } from "@tanstack/react-query"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { PluginAttachmentSourceContribution, + PluginCleanup, + PluginThemeContribution, +} from "@getpaseo/plugin"; +import type { PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, - PluginCleanup, PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, PluginSettingsScreenContribution, - PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, PluginPanelLocation, PluginWorkspacePanelContribution, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { locations: readonly PluginPanelLocation[]; diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 7248ccbe61..1c8cfbfb90 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -1,4 +1,7 @@ -import type { PluginPanelLocation, PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { + PluginPanelLocation, + PluginWorkspacePanelContribution, +} from "@getpaseo/plugin/client"; import type { PaneHost } from "@/panels/panel-manifest"; import { panelSupportsHost } from "@/panels/panel-manifest"; import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 2acca92bd2..bf652140f5 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,10 +1,10 @@ import type { PluginAgentPanelProps, PluginHostProps, - PluginTheme, PluginWorkspacePanelProps, -} from "@getpaseo/plugin"; -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; +} from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; import { useMemo } from "react"; import { Platform, Text, View } from "react-native"; diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index b2436c6c52..7a9355e72a 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,4 +1,4 @@ -import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index ff01d2730a..5757bdba87 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -141,16 +141,8 @@ export async function inspectConfig( path.join(directory, "client", "main.tsx"), `import React from "react"; import { Text } from "react-native"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; -import { - type PluginAgentPanelProps, - type PluginClientContext, - type PluginComposerPillProps, - type PluginSurfaceProps, - useAgent, - usePaseo, - useWorkspace, -} from "@getpaseo/plugin"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; +import { type PluginAgentPanelProps, type PluginClientContext, type PluginComposerPillProps, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client"; import { inspect } from "../shared/inspect"; export function Surface({ navigation }: PluginSurfaceProps) { @@ -200,7 +192,7 @@ export function contributeClient(client: PluginClientContext) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; @@ -230,7 +222,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectConfig } from "./server/inspect"; import { inspect } from "./shared/inspect"; diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index d525490f07..d73f713d89 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -20,7 +20,7 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin"; +const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingSurface } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -35,7 +35,7 @@ export default function contribute(client: PluginClientContext) { } `; -const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin"; +const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetingRpc } from "./shared/greeting"; @@ -63,8 +63,8 @@ export function createGreeting({ name }: RpcInput) { } `; -const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { useRpc } from "@getpaseo/plugin"; +const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { useRpc } from "@getpaseo/plugin/client"; import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; import { Pressable, Text, View } from "react-native"; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 52fb25aca0..58626b1e6a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,34 +14,39 @@ "default": "./dist/index.js" }, "./server": { - "types": "./dist/server.d.ts", - "source": "./src/server.ts", - "default": "./dist/server.js" + "types": "./dist/server/index.d.ts", + "source": "./src/server/index.ts", + "default": "./dist/server/index.js" }, - "./provider": { - "types": "./dist/provider.d.ts", - "source": "./src/provider.ts", - "default": "./dist/provider.js" + "./server/provider": { + "types": "./dist/server/provider.d.ts", + "source": "./src/server/provider.ts", + "default": "./dist/server/provider.js" }, - "./acp": { - "types": "./dist/acp.d.ts", - "source": "./src/acp.ts", - "default": "./dist/acp.js" + "./server/acp": { + "types": "./dist/server/acp.d.ts", + "source": "./src/server/acp.ts", + "default": "./dist/server/acp.js" }, - "./react-native": { - "types": "./dist/react-native.d.ts", - "source": "./src/react-native.ts", - "default": "./dist/react-native.js" + "./client/react-native": { + "types": "./dist/client/react-native.d.ts", + "source": "./src/client/react-native.ts", + "default": "./dist/client/react-native.js" }, - "./host": { - "types": "./dist/host.d.ts", - "source": "./src/host.ts", - "default": "./dist/host.js" + "./client/host": { + "types": "./dist/client/host.d.ts", + "source": "./src/client/host.ts", + "default": "./dist/client/host.js" }, - "./ui": { - "types": "./dist/ui.d.ts", - "source": "./src/ui.ts", - "default": "./dist/ui.js" + "./client/ui": { + "types": "./dist/client/ui.d.ts", + "source": "./src/client/ui.ts", + "default": "./dist/client/ui.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "source": "./src/client/index.ts", + "default": "./dist/client/index.js" } }, "publishConfig": { diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts index ee7c2b0888..27ca706dd7 100644 --- a/packages/plugin/src/attachments.ts +++ b/packages/plugin/src/attachments.ts @@ -1,4 +1,11 @@ import { z } from "zod"; +import type { PluginAttachmentSourceContribution } from "./contracts.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} export const PluginAttachmentItemSchema = z.object({ id: z.string(), diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts new file mode 100644 index 0000000000..8c2fa17737 --- /dev/null +++ b/packages/plugin/src/boundaries.test.ts @@ -0,0 +1,123 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); +const entries = { + ".": "shared", + "./server": "server", + "./server/provider": "server", + "./server/acp": "server", + "./client": "client", + "./client/host": "client", + "./client/react-native": "client", + "./client/ui": "client", +} as const; +const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/; +const serverModule = /^server\//; +const clientModule = /^client\//; + +function resolveLocal(importer: string, specifier: string): string { + const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, "")); + const result = [base + ".ts", base + ".tsx"].find(existsSync); + if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`); + return result; +} + +function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] { + const pending = [entry]; + const visited = new Set(); + const violations: string[] = []; + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + // Includes type imports, re-exports, import types, and literal dynamic imports. + const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles; + for (const { fileName: specifier } of imports) { + const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`; + if (specifier.startsWith(".")) { + const local = resolveLocal(file, specifier); + const module = path + .relative(sourceDirectory, local) + .replace(/\\/g, "/") + .replace(/\.tsx?$/, ".js"); + if (runtime !== "client" && clientModule.test(module)) violations.push(label); + if (runtime !== "server" && serverModule.test(module)) violations.push(label); + pending.push(local); + } else if (runtime === "shared") { + if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types") + violations.push(label); + } else if (runtime === "server") { + if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier)) + violations.push(label); + } else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) { + violations.push(label); + } + } + } + return violations; +} + +describe("plugin SDK import boundaries", () => { + it("classifies every published entry", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); + }); + + it.each(Object.entries(entries))( + "%s respects its %s boundary, including type dependencies", + (specifier, runtime) => { + const name = + specifier === "." + ? "index" + : specifier.slice(2) + + (specifier === "./client" || specifier === "./server" ? "/index" : ""); + expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + }, + ); +}); + +describe("plugin example import boundaries", () => { + const examples = path.resolve(sourceDirectory, "../../../plugin-examples"); + const files = readdirSync(examples, { recursive: true }).filter((file) => + /(? { + const [, directory] = path.relative(examples, file).split(path.sep); + if (directory?.startsWith("index.client.")) return "client"; + if (directory?.startsWith("index.server.")) return "server"; + return directory; + }; + + it.each(files)("%s respects runtime ownership, including type imports", (relative) => { + const file = path.join(examples, relative); + const runtime = owner(file); + expect(["shared", "client", "server"]).toContain(runtime); + const violations: string[] = []; + for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true) + .importedFiles) { + if (specifier.startsWith(".")) { + const imported = owner(resolveLocal(file, specifier)); + if (imported !== "shared" && imported !== runtime) violations.push(specifier); + } else if (specifier.startsWith("@getpaseo/plugin")) { + const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; + if ( + entry === "./client/host" || + !(entry in entries) || + (entries[entry] !== "shared" && entries[entry] !== runtime) + ) + violations.push(specifier); + } else if ( + (runtime !== "client" && uiDependency.test(specifier)) || + (runtime !== "server" && isBuiltin(specifier)) + ) { + violations.push(specifier); + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client-state.tsx b/packages/plugin/src/client/client-state.tsx similarity index 98% rename from packages/plugin/src/client-state.tsx rename to packages/plugin/src/client/client-state.tsx index 9797e8d965..b1c853ecec 100644 --- a/packages/plugin/src/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js"; +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts new file mode 100644 index 0000000000..16456ce4e1 --- /dev/null +++ b/packages/plugin/src/client/contracts.ts @@ -0,0 +1,246 @@ +import type { ComponentType } from "react"; +import type { PaseoApi } from "@getpaseo/client"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineTransformResult, + PluginCleanup, +} from "../contracts.js"; + +export interface PluginHostProps { + theme: PluginTheme; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +interface PluginNavigableHostProps extends PluginHostProps { + /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ + readonly navigation?: { + readonly openAgent: (input: { readonly agentId: string }) => void; + readonly openWorkspace: (input: { readonly workspaceId: string }) => void; + }; +} + +export interface PluginSurfaceProps extends PluginNavigableHostProps {} + +export interface PluginIconProps { + name: string; + size?: number; + color?: string; +} + +export type PluginPanelLocation = "workspace" | "explorer"; + +export interface PluginOpenPanelOptions { + location?: PluginPanelLocation; +} + +interface PluginWorkspacePanelBase { + id: string; + title: string; + icon: string; + locations?: readonly PluginPanelLocation[]; +} + +export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { + context: "workspace"; + workspaceId: string; +} + +export interface PluginAgentPanelProps extends PluginNavigableHostProps { + context: "agent"; + workspaceId: string; + agentId: string; +} + +export interface PluginComposerPillProps extends PluginHostProps { + workspaceId: string; + agentId: string; +} + +export interface PluginComposerPillContribution { + id: string; + title: string; + workspaceId: string; + agentId: string; + Component: ComponentType; + onPress(): void | Promise; +} + +export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { + workspaceId: string; + agentId?: string; +} + +export interface PluginClientContext extends PluginCommandCapabilities { + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addSurface(id: string, Component: ComponentType): PluginCleanup; + addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; + addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; + addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; + addTheme(contribution: PluginThemeContribution): PluginCleanup; + addTimelineTransformer( + contribution: PluginTimelineTransformerContribution, + ): PluginCleanup; + addTimelineRenderer( + contribution: PluginTimelineRendererContribution, + ): PluginCleanup; + openPanel(id: string, options: PluginClientOpenPanelOptions): void; +} + +export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; + +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { + context: "workspace"; + Component: ComponentType; + }) + | (PluginWorkspacePanelBase & { + context: "agent"; + Component: ComponentType; + }); + +export interface PluginSettingsScreenContribution { + id: string; + title: string; + icon: string; + Component: ComponentType; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export type PluginTimelineTransformerContribution< + ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], +> = ItemType extends AgentTimelineItem["type"] + ? { + id: string; + query: { + itemType: ItemType; + }; + transform(input: { + item: Extract; + phase: "streaming" | "complete"; + }): PluginTimelineTransformResult | undefined; + } + : never; + +export interface PluginTimelineItemProps extends PluginHostProps { + agentId: string; + item: { + type: "plugin"; + kind: string; + version: number; + data: Data; + }; + timestamp: Date; +} + +export interface PluginTimelineRendererContribution { + kind: string; + version: number; + schema: Schema; + Component: ComponentType>>; +} + +export interface PluginCommandCapabilities { + paseo: PaseoApi; + rpc( + contract: PluginRpcContract, + input: ZodInput, + ): Promise>; + openSurface(id: string): void; + openSettings(id: string): void; +} + +export interface PluginGlobalCommandContext extends PluginCommandCapabilities { + context: "global"; +} + +export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { + context: "workspace"; + workspace: PluginWorkspaceSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +export interface PluginAgentCommandContext extends PluginCommandCapabilities { + context: "agent"; + workspace: PluginWorkspaceSnapshot; + agent: PluginAgentSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +interface PluginCommandCenterItemBase { + id: string; + title: string; + icon: string; + keywords?: readonly string[]; +} + +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { + context: "global"; + onSelect(context: PluginGlobalCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "workspace"; + onSelect(context: PluginWorkspaceCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "agent"; + onSelect(context: PluginAgentCommandContext): void | Promise; + }); + +interface PluginClientSlashCommandBase { + name: string; + description: string; + argumentHint: string; +} + +export type PluginClientSlashCommandContribution = + | (PluginClientSlashCommandBase & { + context: "workspace"; + onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; + }) + | (PluginClientSlashCommandBase & { + context: "agent"; + onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; + }); + +export type SettingsState = ( + | { status: "loading" } + | { status: "error"; error: string } + | { status: "invalid"; error: string; revision: string } + | { status: "ready"; values: ZodOutput; revision: string } +) & { + saving: boolean; + saveError: string | null; + /** Save an entire document against the revision currently displayed. Never throws. */ + save(values: ZodOutput, revision: string): Promise; + reset(): Promise; + reload(): Promise; +}; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/client/host.test.ts similarity index 99% rename from packages/plugin/src/host.test.ts rename to packages/plugin/src/client/host.test.ts index 58aab65603..894fe832a5 100644 --- a/packages/plugin/src/host.test.ts +++ b/packages/plugin/src/client/host.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js"; +import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js"; import { callPluginRpc, searchPluginAttachments } from "./host.js"; const contract = defineRpc({ diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/client/host.ts similarity index 75% rename from packages/plugin/src/host.ts rename to packages/plugin/src/client/host.ts index 59e1d84e6d..6cd42863e5 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,14 +1,13 @@ -export { settingsRpc } from "./settings.js"; -import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, } from "./runtime-context-bridge.js"; -import type { PluginAttachmentSourceContribution } from "./contracts.js"; +import type { PluginAttachmentSourceContribution } from "../contracts.js"; import { PluginRpcProvider } from "./rpc-context.js"; import { PaseoApiProvider } from "./paseo-context.js"; -import { callPluginRpc } from "./rpc.js"; +import { callPluginRpc } from "../rpc.js"; export async function searchPluginAttachments( source: PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts new file mode 100644 index 0000000000..233502c66d --- /dev/null +++ b/packages/plugin/src/client/index.ts @@ -0,0 +1,37 @@ +export type { + PluginHostProps, + PluginSurfaceProps, + PluginIconProps, + PluginPanelLocation, + PluginOpenPanelOptions, + PluginWorkspacePanelProps, + PluginAgentPanelProps, + PluginComposerPillProps, + PluginComposerPillContribution, + PluginClientOpenPanelOptions, + PluginClientContext, + PluginClientContribution, + PluginWorkspacePanelContribution, + PluginSettingsScreenContribution, + PluginSurfaceContribution, + PluginSidebarContribution, + PluginTimelineTransformerContribution, + PluginTimelineItemProps, + PluginTimelineRendererContribution, + PluginCommandCapabilities, + PluginGlobalCommandContext, + PluginWorkspaceCommandContext, + PluginAgentCommandContext, + PluginCommandCenterItemContribution, + PluginClientSlashCommandContribution, + SettingsState, +} from "./contracts.js"; +export { usePaseo } from "./paseo-context.js"; +export { useAgent, useWorkspace } from "./client-state.js"; +export { useRpc } from "./rpc-context.js"; +import type { SettingsDefinition } from "../settings.js"; +import type { SettingsState } from "./contracts.js"; +import type { ZodType } from "zod"; +export declare function useSettings( + definition: SettingsDefinition, +): SettingsState; diff --git a/packages/plugin/src/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx similarity index 100% rename from packages/plugin/src/paseo-context.tsx rename to packages/plugin/src/client/paseo-context.tsx diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/client/react-native.ts similarity index 100% rename from packages/plugin/src/react-native.ts rename to packages/plugin/src/client/react-native.ts diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/client/rpc-context.tsx similarity index 94% rename from packages/plugin/src/rpc-context.tsx rename to packages/plugin/src/client/rpc-context.tsx index 8f7ba514f5..7cd9bb176a 100644 --- a/packages/plugin/src/rpc-context.tsx +++ b/packages/plugin/src/client/rpc-context.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import { callPluginRpc, type PluginRpcContract } from "./rpc.js"; +import { callPluginRpc, type PluginRpcContract } from "../rpc.js"; export interface PluginRpcContextValue { invoke(method: string, input: unknown): Promise; diff --git a/packages/plugin/src/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx similarity index 100% rename from packages/plugin/src/runtime-context-bridge.tsx rename to packages/plugin/src/client/runtime-context-bridge.tsx diff --git a/packages/plugin/src/shallow.test.ts b/packages/plugin/src/client/shallow.test.ts similarity index 100% rename from packages/plugin/src/shallow.test.ts rename to packages/plugin/src/client/shallow.test.ts diff --git a/packages/plugin/src/shallow.ts b/packages/plugin/src/client/shallow.ts similarity index 100% rename from packages/plugin/src/shallow.ts rename to packages/plugin/src/client/shallow.ts diff --git a/packages/plugin/src/ui.ts b/packages/plugin/src/client/ui.ts similarity index 100% rename from packages/plugin/src/ui.ts rename to packages/plugin/src/client/ui.ts diff --git a/packages/plugin/src/use-sync-external-store.d.ts b/packages/plugin/src/client/use-sync-external-store.d.ts similarity index 100% rename from packages/plugin/src/use-sync-external-store.d.ts rename to packages/plugin/src/client/use-sync-external-store.d.ts diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 3e06485f19..98df0758a6 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,10 +1,5 @@ -import type { PluginLifecycleRegistration } from "./lifecycle.js"; -import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; -import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginRpcContract } from "./rpc.js"; -import type { ProviderRegistration } from "./provider.js"; export interface PluginTheme { readonly colors: { @@ -22,34 +17,6 @@ export interface PluginTheme { }; } -export interface PluginHostProps { - theme: PluginTheme; - host: { - id: string; - label: string; - }; - layout: { - compact: boolean; - platform: "ios" | "android" | "web"; - }; -} - -interface PluginNavigableHostProps extends PluginHostProps { - /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ - readonly navigation?: { - readonly openAgent: (input: { readonly agentId: string }) => void; - readonly openWorkspace: (input: { readonly workspaceId: string }) => void; - }; -} - -export interface PluginSurfaceProps extends PluginNavigableHostProps {} - -export interface PluginIconProps { - name: string; - size?: number; - color?: string; -} - export interface PluginWorkspaceSnapshot { readonly id: string; readonly projectId: string; @@ -85,99 +52,6 @@ export interface PluginAgentSnapshot { readonly labels: Readonly>; } -export type PluginPanelLocation = "workspace" | "explorer"; - -export interface PluginOpenPanelOptions { - location?: PluginPanelLocation; -} - -interface PluginWorkspacePanelBase { - id: string; - title: string; - icon: string; - locations?: readonly PluginPanelLocation[]; -} - -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; -} - -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillProps extends PluginHostProps { - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillContribution { - id: string; - title: string; - workspaceId: string; - agentId: string; - Component: ComponentType; - onPress(): void | Promise; -} - -export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { - workspaceId: string; - agentId?: string; -} - -export interface PluginClientContext extends PluginCommandCapabilities { - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; - addSurface(id: string, Component: ComponentType): PluginCleanup; - addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; - addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; - addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; - addTheme(contribution: PluginThemeContribution): PluginCleanup; - addTimelineTransformer( - contribution: PluginTimelineTransformerContribution, - ): PluginCleanup; - addTimelineRenderer( - contribution: PluginTimelineRendererContribution, - ): PluginCleanup; - openPanel(id: string, options: PluginClientOpenPanelOptions): void; -} - -export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; - -export type PluginWorkspacePanelContribution = - | (PluginWorkspacePanelBase & { - context: "workspace"; - Component: ComponentType; - }) - | (PluginWorkspacePanelBase & { - context: "agent"; - Component: ComponentType; - }); - -export interface PluginSettingsScreenContribution { - id: string; - title: string; - icon: string; - Component: ComponentType; -} - -export interface PluginSurfaceContribution { - id: string; - Component: ComponentType; -} - -export interface PluginSidebarContribution { - id: string; - title: string; - icon: string; - surface: string; -} - export interface PluginThemeColors { background: string; foreground: string; @@ -219,121 +93,4 @@ export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; } -export type PluginTimelineTransformerContribution< - ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], -> = ItemType extends AgentTimelineItem["type"] - ? { - id: string; - query: { - itemType: ItemType; - }; - transform(input: { - item: Extract; - phase: "streaming" | "complete"; - }): PluginTimelineTransformResult | undefined; - } - : never; - -export interface PluginTimelineItemProps extends PluginHostProps { - agentId: string; - item: { - type: "plugin"; - kind: string; - version: number; - data: Data; - }; - timestamp: Date; -} - -export interface PluginTimelineRendererContribution { - kind: string; - version: number; - schema: Schema; - Component: ComponentType>>; -} - -export interface PluginCommandCapabilities { - paseo: PaseoApi; - rpc( - contract: PluginRpcContract, - input: ZodInput, - ): Promise>; - openSurface(id: string): void; - openSettings(id: string): void; -} - -export interface PluginGlobalCommandContext extends PluginCommandCapabilities { - context: "global"; -} - -export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { - context: "workspace"; - workspace: PluginWorkspaceSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -export interface PluginAgentCommandContext extends PluginCommandCapabilities { - context: "agent"; - workspace: PluginWorkspaceSnapshot; - agent: PluginAgentSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -interface PluginCommandCenterItemBase { - id: string; - title: string; - icon: string; - keywords?: readonly string[]; -} - -export type PluginCommandCenterItemContribution = - | (PluginCommandCenterItemBase & { - context: "global"; - onSelect(context: PluginGlobalCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "workspace"; - onSelect(context: PluginWorkspaceCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "agent"; - onSelect(context: PluginAgentCommandContext): void | Promise; - }); - -interface PluginClientSlashCommandBase { - name: string; - description: string; - argumentHint: string; -} - -export type PluginClientSlashCommandContribution = - | (PluginClientSlashCommandBase & { - context: "workspace"; - onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; - }) - | (PluginClientSlashCommandBase & { - context: "agent"; - onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; - }); - -export interface PluginHandlerContext { - paseo: PaseoApi; -} - -export interface PluginServerContext extends PluginLifecycleRegistration { - registerSettings( - definition: import("./settings.js").SettingsDefinition, - ): void; - handle( - contract: PluginRpcContract, - handler: ( - input: ZodOutput, - context: PluginHandlerContext, - ) => ZodInput | Promise>, - ): void; - registerProvider(provider: ProviderRegistration): void; -} - export type PluginCleanup = () => void | Promise; - -export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 272524de39..48deede303 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,79 +1,22 @@ -import type { SettingsDefinition, SettingsState } from "./settings.js"; -import type { ZodType } from "zod"; -export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js"; -export declare function useSettings( - definition: SettingsDefinition, -): SettingsState; - -import type { ComponentType } from "react"; -import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; - +// Shared SDK entry. Keep runtime-specific imports and re-exports on /client or /server. +export type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeColors, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineData, + PluginTimelineItem, + PluginTimelineTransformResult, + PluginCleanup, +} from "./contracts.js"; +export { defineSettings, settingsRpc, type SettingsDefinition } from "./settings.js"; export { + defineAttachmentSource, PluginAttachmentItemSchema, PluginAttachmentSearchPayloadSchema, type PluginAttachmentItem, type PluginAttachmentSearchPayload, } from "./attachments.js"; export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; - -export function defineAttachmentSource( - definition: Definition, -): Definition { - return definition; -} - -export declare const Icon: ComponentType; -export type { - PluginAttachmentSourceContribution, - PluginAgentCommandContext, - PluginAgentPanelProps, - PluginAgentSnapshot, - PluginCleanup, - PluginCommandCapabilities, - PluginCommandCenterItemContribution, - PluginClientContext, - PluginClientContribution, - PluginClientSlashCommandContribution, - PluginClientOpenPanelOptions, - PluginComposerPillContribution, - PluginComposerPillProps, - PluginServerContribution, - PluginServerContext, - PluginGlobalCommandContext, - PluginHandlerContext, - PluginHostProps, - PluginOpenPanelOptions, - PluginIconProps, - PluginPanelLocation, - PluginTheme, - PluginSidebarContribution, - PluginSurfaceContribution, - PluginSettingsScreenContribution, - PluginSurfaceProps, - PluginThemeColors, - PluginThemeContribution, - PluginTimelineData, - PluginTimelineItem, - PluginTimelineItemProps, - PluginTimelineRendererContribution, - PluginTimelineTransformerContribution, - PluginTimelineTransformResult, - PluginWorkspaceCommandContext, - PluginWorkspacePanelContribution, - PluginWorkspacePanelProps, - PluginWorkspaceSnapshot, -} from "./contracts.js"; -export { usePaseo } from "./paseo-context.js"; -export { useAgent, useWorkspace } from "./client-state.js"; -export { useRpc } from "./rpc-context.js"; - -export type { - PluginBeforeRequests, - PluginHookAgent, - PluginHookContext, - PluginHookWorkspace, - PluginLifecycleEvents, - PluginLifecycleRegistration, - PluginSessionOpenRequest, - PluginTurnOutcome, -} from "./lifecycle.js"; diff --git a/packages/plugin/src/migration-doc.test.ts b/packages/plugin/src/migration-doc.test.ts index 3673bf6616..e6b2b512b8 100644 --- a/packages/plugin/src/migration-doc.test.ts +++ b/packages/plugin/src/migration-doc.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; describe("plugin runtime-entry migration guide", () => { it("maps every client registration method", async () => { - const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8"); + const contracts = await readFile(new URL("./client/contracts.ts", import.meta.url), "utf8"); const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1]; if (!context) throw new Error("PluginClientContext was not found"); const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]); diff --git a/packages/plugin/src/server.test.ts b/packages/plugin/src/server.test.ts deleted file mode 100644 index 5117249ae8..0000000000 --- a/packages/plugin/src/server.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const srcDir = path.dirname(fileURLToPath(import.meta.url)); -const forbiddenSpecifiers = new Set([ - "react", - "react/jsx-runtime", - "react-native", - "use-sync-external-store", - "use-sync-external-store/shim/with-selector", -]); - -function localFile(fromFile: string, specifier: string): string { - const withoutJs = specifier.replace(/\.js$/, ""); - for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) { - const resolved = path.resolve(path.dirname(fromFile), candidate); - if (existsSync(resolved)) return resolved; - } - throw new Error(`Could not resolve ${specifier} from ${fromFile}`); -} - -function valueImportSpecifiers(source: string): string[] { - const specifiers: string[] = []; - const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g; - for (const match of source.matchAll(pattern)) { - if (match[1]) specifiers.push(match[1]); - } - return specifiers; -} - -function collectBareSpecifiers(entry: string): string[] { - const seen = new Set(); - const bare: string[] = []; - const queue = [entry]; - while (queue.length > 0) { - const file = queue.pop(); - if (!file || seen.has(file)) continue; - seen.add(file); - for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) { - if (specifier.startsWith(".")) { - queue.push(localFile(file, specifier)); - continue; - } - bare.push(specifier); - } - } - return bare; -} - -describe("@getpaseo/plugin/server", () => { - it("does not load react or the client hook graph", () => { - const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts")); - expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]); - }); -}); diff --git a/packages/plugin/src/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts similarity index 100% rename from packages/plugin/src/acp-internal/connection.ts rename to packages/plugin/src/server/acp-internal/connection.ts diff --git a/packages/plugin/src/acp.test.ts b/packages/plugin/src/server/acp.test.ts similarity index 100% rename from packages/plugin/src/acp.test.ts rename to packages/plugin/src/server/acp.test.ts diff --git a/packages/plugin/src/acp.ts b/packages/plugin/src/server/acp.ts similarity index 100% rename from packages/plugin/src/acp.ts rename to packages/plugin/src/server/acp.ts diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts new file mode 100644 index 0000000000..a296c28b1e --- /dev/null +++ b/packages/plugin/src/server/contracts.ts @@ -0,0 +1,26 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { PluginCleanup } from "../contracts.js"; +import type { ProviderRegistration } from "./provider.js"; +import type { PluginLifecycleRegistration } from "./lifecycle.js"; + +export interface PluginHandlerContext { + paseo: PaseoApi; +} + +export interface PluginServerContext extends PluginLifecycleRegistration { + registerSettings( + definition: import("../settings.js").SettingsDefinition, + ): void; + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + context: PluginHandlerContext, + ) => ZodInput | Promise>, + ): void; + registerProvider(provider: ProviderRegistration): void; +} + +export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server/index.ts similarity index 66% rename from packages/plugin/src/server.ts rename to packages/plugin/src/server/index.ts index 69c8cc6c4c..c673f25303 100644 --- a/packages/plugin/src/server.ts +++ b/packages/plugin/src/server/index.ts @@ -1,12 +1,15 @@ -export type { PluginHandlerContext } from "./contracts.js"; - export type { - PluginBeforeRequests, - PluginHookAgent, + PluginHandlerContext, + PluginServerContext, + PluginServerContribution, +} from "./contracts.js"; +export type { PluginHookContext, PluginHookWorkspace, - PluginLifecycleEvents, - PluginLifecycleRegistration, + PluginHookAgent, PluginSessionOpenRequest, PluginTurnOutcome, + PluginLifecycleEvents, + PluginBeforeRequests, + PluginLifecycleRegistration, } from "./lifecycle.js"; diff --git a/packages/plugin/src/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts similarity index 100% rename from packages/plugin/src/lifecycle.ts rename to packages/plugin/src/server/lifecycle.ts diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/server/provider.ts similarity index 100% rename from packages/plugin/src/provider.ts rename to packages/plugin/src/server/provider.ts diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts index dca51d8245..30e890b3ef 100644 --- a/packages/plugin/src/settings.ts +++ b/packages/plugin/src/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { output, ZodType } from "zod"; +import type { ZodType } from "zod"; import { defineRpc } from "./rpc.js"; export interface SettingsDefinition { @@ -49,17 +49,3 @@ export function settingsRpc(id: string) { }), }; } - -export type SettingsState = ( - | { status: "loading" } - | { status: "error"; error: string } - | { status: "invalid"; error: string; revision: string } - | { status: "ready"; values: output; revision: string } -) & { - saving: boolean; - saveError: string | null; - /** Save an entire document against the revision currently displayed. Never throws. */ - save(values: output, revision: string): Promise; - reset(): Promise; - reload(): Promise; -}; diff --git a/packages/server/package.json b/packages/server/package.json index 40d236670e..5db204d84a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -101,6 +101,7 @@ "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", + "typescript": "^5.9.3", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -116,7 +117,6 @@ "@vitest/ui": "^4.1.6", "cross-env": "^10.1.0", "tsx": "^4.6.0", - "typescript": "^5.2.2", "vitest": "^4.1.6" }, "overrides": { diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 4c8abd2357..75046396be 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -3,7 +3,7 @@ import type { ProviderEvent, ProviderInput, ProviderRegistration, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "./agent-sdk-types.js"; diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 7b7df64a22..b5f5ff7b92 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -21,7 +21,7 @@ import { type ProviderSessionConfig, type ProviderContent, type ProviderTimelineItem, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import type { AgentCapabilityFlags, AgentClient, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b1e50082da..7ca0510f51 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -2,7 +2,7 @@ import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 8eb9f20c8a..abf88d4488 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -48,7 +48,7 @@ import { type AgentConfigurationValidationInput, validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; diff --git a/packages/server/src/server/plugins/compiler-imports.ts b/packages/server/src/server/plugins/compiler-imports.ts new file mode 100644 index 0000000000..04d5549d50 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-imports.ts @@ -0,0 +1,126 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { ImportKind } from "esbuild"; +import type { Node, NamedImportBindings, NamedExportBindings } from "typescript"; + +const nodeRequire = createRequire(import.meta.url); + +export type PluginImportKind = ImportKind | "type-reference"; + +interface ModuleImport { + specifier: string; + kind: PluginImportKind; + typeOnly: boolean; +} + +// Keep the TypeScript compiler off daemon startup. esbuild erases these edges, so +// ownership validation needs the original syntax and declaration-aware resolution. +export function createPluginImportReader(directory: string) { + const ts = nodeRequire("typescript") as typeof import("typescript"); + const configFile = ts.findConfigFile(directory, ts.sys.fileExists); + const config = configFile + ? ts.getParsedCommandLineOfConfigFile( + configFile, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + const options = { + ...config?.options, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + }; + const cache = ts.createModuleResolutionCache(directory, (file) => file, options); + return { + resolve(specifier: string, importer: string, kind: PluginImportKind): string | undefined { + const resolved = + kind === "type-reference" + ? ts.resolveTypeReferenceDirective(specifier, importer, options, ts.sys) + .resolvedTypeReferenceDirective?.resolvedFileName + : ts.resolveModuleName( + specifier, + importer, + options, + ts.sys, + cache, + undefined, + kind === "require-call" ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext, + ).resolvedModule?.resolvedFileName; + return resolved ? realpathSync.native(resolved) : undefined; + }, + read(file: string): ModuleImport[] { + const source = ts.createSourceFile( + file, + readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const imports: ModuleImport[] = []; + function add( + node: Node | undefined, + typeOnly: boolean, + kind: ImportKind = "import-statement", + ) { + if (node && ts.isStringLiteralLike(node)) { + imports.push({ + specifier: node.text, + kind, + typeOnly: source.isDeclarationFile || typeOnly, + }); + } + } + function onlyTypeBindings( + bindings: NamedImportBindings | NamedExportBindings | undefined, + ): boolean { + return ( + !!bindings && + (ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) && + bindings.elements.length > 0 && + bindings.elements.every((element) => element.isTypeOnly) + ); + } + function visit(node: Node): void { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + const typeOnly = + !!clause?.isTypeOnly || (!clause?.name && onlyTypeBindings(clause?.namedBindings)); + add(node.moduleSpecifier, typeOnly); + } else if (ts.isExportDeclaration(node)) { + add(node.moduleSpecifier, node.isTypeOnly || onlyTypeBindings(node.exportClause)); + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + add(node.argument.literal, true); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + add(node.moduleReference.expression, node.isTypeOnly, "require-call"); + } else if (ts.isCallExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) + add(node.arguments[0], false, "dynamic-import"); + else if (ts.isIdentifier(node.expression) && node.expression.text === "require") + add(node.arguments[0], false, "require-call"); + } + ts.forEachChild(node, visit); + } + visit(source); + for (const reference of source.referencedFiles) { + imports.push({ + specifier: path.resolve(path.dirname(file), reference.fileName), + kind: "import-statement", + typeOnly: true, + }); + } + for (const reference of source.typeReferenceDirectives) { + imports.push({ specifier: reference.fileName, kind: "type-reference", typeOnly: true }); + } + return imports; + }, + }; +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index a922403aa7..dca7d3e3e2 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -80,6 +81,8 @@ async function createSplitPlugin(): Promise<{ client: string; server: string; }> { + // Keep the platform's original spelling (including Windows short names) in symlink + // targets. Only diagnostic expectations use canonical paths. const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); temporaryDirectories.push(directory); await Promise.all([ @@ -126,7 +129,321 @@ export default function contribute(server) { return { directory, client, server }; } +async function createRootAlias(directory: string): Promise { + const aliases = await mkdtemp(path.join(tmpdir(), "paseo-plugin-root-alias-")); + temporaryDirectories.push(aliases); + const rootAlias = path.join(aliases, "plugin"); + await symlink(directory, rootAlias, process.platform === "win32" ? "junction" : "dir"); + return rootAlias; +} + describe("plugin runtime entries", () => { + it.each([ + "react", + "react/jsx-runtime", + "react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + ])("rejects %s from server code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("client-only module"); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ])("rejects %s from client code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it.each([ + "react", + "node:fs", + "fs", + "@getpaseo/plugin/client", + "@getpaseo/plugin/server", + "../client/surface", + "../server/handler", + ])("rejects %s from shared code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "${specifier}"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { target: "client", dependency: "react" }, + { target: "server", dependency: "node:fs" }, + { target: "server", dependency: "@getpaseo/plugin/server/provider" }, + ] as const)( + "rejects a shared dependency reaching $dependency in the $target bundle", + async ({ target, dependency }) => { + const entries = await createSplitPlugin(); + const dependencyDirectory = path.join(entries.directory, "node_modules/impure"); + await mkdir(dependencyDirectory, { recursive: true }); + await writeFile( + path.join(dependencyDirectory, "package.json"), + JSON.stringify({ name: "impure", main: "index.js" }), + ); + await writeFile(path.join(dependencyDirectory, "index.js"), `export * from "${dependency}";`); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "impure"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect( + compilePlugin({ + client: target === "client" ? entries.client : null, + server: target === "server" ? entries.server : null, + }), + ).rejects.toThrow("plugin shared"); + }, + ); + + it.each([ + 'import type { PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + 'export type Context = import("@getpaseo/plugin/client").PluginClientContext;', + 'import { type PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'import type { ComponentType } from "react"; export type Component = ComponentType;', + ])("rejects runtime-owned types in shared code: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + typeSource + '\nexport const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([{ types: "./index.d.ts" }, { exports: { ".": { types: "./index.d.ts" } } }])( + "accepts declaration-only dependencies: %j", + async (manifest) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", ...manifest }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + "export type JsonValue = string | number | boolean | null;", + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { JsonValue } from "neutral-types"; export const clientLabel: JsonValue = "client"; export const serverLabel: JsonValue = "server";', + ); + const result = await compilePlugin(entries); + expect(result.clientBundle).toContain('"client"'); + expect(result.serverBundle).toContain('"server"'); + expect(result.clientBundle).not.toContain("neutral-types"); + }, + ); + + it("accepts a declaration import used only as a type without import type", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", types: "./index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.d.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel = Value; export const serverLabel = Value;', + ); + await expect(compilePlugin(entries)).rejects.toThrow('Could not resolve "neutral-types"'); + }); + + it("preserves guarded optional requires in server dependencies", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/optional-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "optional-helper", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + 'let value = "fallback"; try { value = require("missing-accelerator"); } catch {} module.exports = value;', + ); + await writeFile(entries.server, 'import value from "optional-helper"; export default value;'); + await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({ + serverBundle: expect.stringContaining("fallback"), + }); + await writeFile( + path.join(dependency, "index.js"), + 'module.exports = require("missing-accelerator");', + ); + await expect(compilePlugin({ client: null, server: entries.server })).rejects.toThrow( + 'Could not resolve "missing-accelerator"', + ); + }); + + it.each(["path", "types"])( + "checks declaration reference %s directives", + async (referenceKind) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + const referencedDirectory = + referenceKind === "path" + ? dependency + : path.join(entries.directory, "node_modules/@types/referenced-helper"); + await mkdir(dependency, { recursive: true }); + await mkdir(referencedDirectory, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", types: "index.d.ts" }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + `/// +export type Value = string;`, + ); + const referencedFile = path.join( + referencedDirectory, + referenceKind === "path" ? "context.d.ts" : "index.d.ts", + ); + await writeFile( + referencedFile, + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "typed-helper"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + await writeFile(referencedFile, "export type Neutral = string;"); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + }, + ); + + it("checks the physical owner of type-only symlinks", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "server/context.ts"), + "export type Value = string;", + ); + await symlink( + path.join(entries.directory, "server/context.ts"), + path.join(entries.directory, "shared/context.ts"), + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "./context"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it("resolves type-only path aliases from the plugin tsconfig", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "shared-types": ["shared/types.ts"] } }, + }), + ); + await writeFile(path.join(entries.directory, "shared/types.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "shared-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext as Value } from "@getpaseo/plugin/client";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { specifier: "@getpaseo/plugin/client", importKind: "import type" }, + { specifier: "@getpaseo/plugin/server", importKind: "import type" }, + { specifier: "@getpaseo/plugin/client", importKind: "import" }, + { specifier: "@getpaseo/plugin/server", importKind: "import" }, + ])( + "rejects transitive declaration dependencies on $specifier through $importKind", + async ({ specifier, importKind }) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", main: "index.js", types: "index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.js"), "export {};"); + await writeFile( + path.join(dependency, "index.d.ts"), + 'export type { Context } from "./context";', + ); + const context = specifier.endsWith("client") ? "PluginClientContext" : "PluginServerContext"; + await writeFile( + path.join(dependency, "context.d.ts"), + `export type { ${context} as Context } from "${specifier}";`, + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `${importKind} { Context } from "typed-helper"; export type Value = Context; export const clientLabel = "client"; export const serverLabel = "server";`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }, + ); + + it("checks files reached only through type imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'export type { PluginClientContext } from "./types"; export const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + "@paseo/plugin", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/provider", + "@getpaseo/plugin/acp", + "@getpaseo/plugin/host", + ])("rejects retired entry %s", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as sdk from "${specifier}"; export default function contribute() { return sdk; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow(specifier); + }); + it("builds each runtime from its own entry and shares neutral modules", async () => { const entries = await createSplitPlugin(); @@ -205,7 +522,7 @@ export default function contribute() { void Surface; return () => undefined; }`, export default function contribute() { void value; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(entries.directory, "helper")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(entries.directory), "helper")}`, ); }); @@ -225,7 +542,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "secret")}`, ); }); @@ -242,7 +559,7 @@ export default function contribute() { void secret; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${outside}`, + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(outside)}`, ); }); @@ -263,7 +580,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "node_modules", "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "node_modules", "secret")}`, ); }); @@ -363,10 +680,69 @@ export default function contribute() { void handler; return () => undefined; }`, ); }); - it("classifies symlinks by their canonical target", async () => { + it.each(["file", "directory"])( + "preserves authored ownership through root and %s aliases", + async (aliasKind) => { + const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const sharedValue = path.join(entries.directory, "shared/value.ts"); + await writeFile(sharedValue, 'export const value = "shared";'); + if (aliasKind === "file") { + await symlink(sharedValue, path.join(entries.directory, "server/alias.ts")); + } else { + await rm(path.join(entries.directory, "server"), { recursive: true }); + await symlink( + path.join(entries.directory, "shared"), + path.join(entries.directory, "server"), + process.platform === "win32" ? "junction" : "dir", + ); + } + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "server", aliasKind === "file" ? "alias.ts" : "value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "server-only module", + ); + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "shared/value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).resolves.toMatchObject({ + clientBundle: expect.stringContaining("shared"), + }); + }, + ); + + it("rejects generated React imports in dependencies reached through shared symlinks", async () => { const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const dependency = path.join(entries.directory, "node_modules/decoration"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "decoration", main: "index.tsx" }), + ); + await writeFile(path.join(dependency, "index.tsx"), "export const decoration = <>;"); + await writeFile( + path.join(entries.directory, "shared/decoration.ts"), + 'export { decoration } from "decoration";', + ); + await symlink( + path.join(rootAlias, "shared/decoration.ts"), + path.join(entries.directory, "client/decoration.ts"), + ); + await writeFile(entries.client, 'export { decoration } from "./client/decoration";'); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "plugin shared", + ); + }); + + it.each([false, true])("classifies symlink targets through a root alias: %s", async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; const link = path.join(entries.directory, "client", "handler.ts"); - await symlink(path.join(entries.directory, "server", "handler.ts"), link); + await symlink(path.join(targetRoot, "server", "handler.ts"), link); await writeFile( entries.client, `import { handler } from "./client/handler"; @@ -432,33 +808,37 @@ export default function contribute() { void value; return () => undefined; }`, expect(serverBundle).toContain("linked dependency"); }); - it("does not treat the plugin package manifest as a linked dependency root", async () => { - const entries = await createSplitPlugin(); - const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); - const secret = path.join(entries.directory, "secret.ts"); - await mkdir(dependency, { recursive: true }); - await Promise.all([ - writeFile( - path.join(entries.directory, "package.json"), - JSON.stringify({ name: "fixture-dependency" }), - ), - writeFile( - path.join(dependency, "package.json"), - JSON.stringify({ name: "fixture-dependency", main: "index.js" }), - ), - writeFile(secret, `export const secret = "plugin root";`), - symlink(secret, path.join(dependency, "index.js")), - writeFile( - entries.client, - `import { secret } from "fixture-dependency"; + it.each([false, true])( + "rejects plugin manifests as linked roots through an alias: %s", + async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + const secret = path.join(entries.directory, "secret.ts"); + await mkdir(dependency, { recursive: true }); + await Promise.all([ + writeFile( + path.join(entries.directory, "package.json"), + JSON.stringify({ name: "fixture-dependency" }), + ), + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile(secret, `export const secret = "plugin root";`), + symlink(path.join(targetRoot, "secret.ts"), path.join(dependency, "index.js")), + writeFile( + entries.client, + `import { secret } from "fixture-dependency"; export default function contribute() { void secret; return () => undefined; }`, - ), - ]); + ), + ]); - await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${secret}`, - ); - }); + await expect(compilePlugin(entries)).rejects.toThrow( + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(secret)}`, + ); + }, + ); it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => { const entries = await createSplitPlugin(); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 981ccc1229..ca5ae9f30c 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,9 +1,11 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire, isBuiltin } from "node:module"; import path from "node:path"; -import type { OnResolveResult, Plugin } from "esbuild"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; import { - PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, PLUGIN_SDK_SPECIFIERS, } from "./plugin-sdk-specifiers.js"; @@ -100,12 +102,13 @@ function findDependencyRoot( pluginDirectory: string, ): string | null { const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; let directory = path.dirname(resolvedPath); for (;;) { const manifestPath = path.join(directory, "package.json"); if (existsSync(manifestPath)) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName) { + if (manifest.name === expectedName || manifest.name === ambientName) { if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { return null; } @@ -120,7 +123,7 @@ function findDependencyRoot( function moduleBoundaryError( moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", filePath: string, ): OnResolveResult | null { if (moduleLocation === "invalid") { @@ -144,12 +147,22 @@ function lexicalBoundaryError( specifier: string, resolveDirectory: string, pluginDirectory: string, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", ): OnResolveResult | null { if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; const lexicalPath = path.resolve(resolveDirectory, specifier); - if (!containsPath(pluginDirectory, lexicalPath)) return null; - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } } function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { @@ -158,20 +171,115 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: return { name: `paseo-plugin-${target}-runtime-boundary`, setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); buildContext.onResolve({ filter: /.*/ }, async (args) => { if (args.kind === "entry-point") return null; if (args.pluginData === boundaryResolution) return null; - const lexicalError = lexicalBoundaryError( - args.path, - args.resolveDir, - pluginDirectory, - target, - ); + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); if (lexicalError) return lexicalError; const resolution = await buildContext.resolve(args.path, { - importer: args.importer, + importer, namespace: args.namespace, - resolveDir: args.resolveDir, + resolveDir, kind: args.kind, pluginData: boundaryResolution, with: args.with, @@ -183,26 +291,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: ) { return null; } - const resolvedPath = resolution.path; - const importedTarget = directoryTarget(resolvedPath, pluginDirectory); - if (importedTarget === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, args.importer) && containsPath(root, resolvedPath), - ) - ) { - return null; - } - if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) { - const dependencyRoot = findDependencyRoot(resolvedPath, args.path, pluginDirectory); - if (dependencyRoot) { - linkedDependencyRoots.add(dependencyRoot); - return null; - } - } - return moduleBoundaryError(importedTarget, target, resolvedPath); - } - return moduleBoundaryError(importedTarget, target, resolvedPath); + return resolvedBoundaryError(resolution.path, args.path, importer, owner); }); }, }; @@ -219,60 +308,86 @@ function makeHermesInteropEager(code: string): string { return code.replaceAll("get: () => from[key]", "value: from[key]"); } -function exactSpecifierFilter(specifiers: readonly string[]): RegExp { - const alternatives = specifiers.map((specifier) => - specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - ); - return new RegExp(`^(${alternatives.join("|")})$`); -} - -function createUnusedPlatformModulePlugin(): Plugin { - const filter = exactSpecifierFilter([ - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, - ]); - return { - name: "paseo-plugin-server-unused-platform-modules", - setup(buildContext) { - buildContext.onResolve({ filter }, (args) => ({ - path: args.path, - namespace: "paseo-unused-platform-module", - sideEffects: false, - })); - buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({ - contents: "module.exports = {};", - loader: "js", - })); - }, - }; -} - -function createClientNodeImportPlugin(): Plugin { - return { - name: "paseo-plugin-client-node-imports", - setup(buildContext) { - buildContext.onResolve({ filter: /^node:/ }, (args) => ({ +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { errors: [ { - text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`, + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, }, ], - })); - }, - }; + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } } async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { const { build } = loadEsbuild(); - const pluginDirectory = path.dirname(entryPath); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); const result = await build({ - entryPoints: [entryPath], + entryPoints: [realpathSync.native(entryPath)], bundle: true, format: "cjs", - jsx: target === "client" ? "automatic" : undefined, + jsx: "automatic", platform: target === "server" ? "node" : "neutral", target: target === "server" ? "node20" : "es2020", // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, @@ -289,16 +404,13 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "zod", ] : [...PLUGIN_SDK_SPECIFIERS, "zod"], - plugins: [ - createRuntimeBoundaryPlugin(target, pluginDirectory), - ...(target === "client" - ? [createClientNodeImportPlugin()] - : [createUnusedPlatformModulePlugin()]), - ], + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, logLevel: "silent", treeShaking: true, write: false, }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); const output = result.outputFiles[0]?.text; if (!output) throw new Error(`Plugin ${target} compilation produced no output`); return wrapCommonJsBundle(makeHermesInteropEager(output)); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 894b1663c4..5a449763f3 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -2,7 +2,7 @@ import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; import type pino from "pino"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { PluginIdSchema, type PluginLogEntry, diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 18e0ec3891..e2bd3f0566 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -26,7 +26,8 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy ); await writeFile( path.join(pluginDirectory, "index.server.ts"), - `import { defineRpc, type PluginServerContext } from "@getpaseo/plugin"; + `import { defineRpc } from "@getpaseo/plugin"; +import { type PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; const create = defineRpc({ diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 80d925652a..0066fad500 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -3,8 +3,8 @@ import type { ProviderCatalogOptions, ProviderEvent, ProviderInput, -} from "@getpaseo/plugin/provider"; -import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; +import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/server/provider"; import { z } from "zod"; export interface PluginProviderMetadata { diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts new file mode 100644 index 0000000000..6a4380d503 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -0,0 +1,39 @@ +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { expect, it } from "vitest"; + +it("keeps React out of the plugin host's runtime dependency graph", async () => { + await expect( + build({ + entryPoints: [ + fileURLToPath(new URL("./plugin-process.ts", import.meta.url)), + "@getpaseo/plugin", + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ], + outdir: "unused", + conditions: ["source"], + bundle: true, + platform: "node", + format: "esm", + // Node evaluates re-exports even when the host only imports one helper. + treeShaking: false, + write: false, + logLevel: "silent", + plugins: [ + { + name: "no-react", + setup(context) { + context.onResolve( + { filter: /^(react|react-dom|react-native|use-sync-external-store)(\/|$)/ }, + ({ path, importer }) => ({ + errors: [{ text: `React dependency ${path} imported by ${importer}` }], + }), + ); + }, + }, + ], + }), + ).resolves.toMatchObject({ errors: [] }); +}); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 41fe8fcff0..d08bd6f669 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -5,27 +5,20 @@ import { type PluginProcessRequest, } from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; -import { - defineSettings, - type SettingsDefinition, - defineAttachmentSource, - defineRpc, - type PluginRpcContract, -} from "@getpaseo/plugin"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; +import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; +import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { ProviderEventSchema, type ProviderConnection, type ProviderRegistration, -} from "@getpaseo/plugin/provider"; -import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +} from "@getpaseo/plugin/server/provider"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; -import { - isPluginClientOnlySdkSpecifier, - isPluginSdkSpecifier, - isPluginServerTypesSdkSpecifier, -} from "./plugin-sdk-specifiers.js"; +import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; @@ -215,21 +208,16 @@ async function closeProviderConnection(connectionId: string): Promise { send({ type: "provider.closed", connectionId }); } -const pluginAuthorRuntime = { - defineAttachmentSource, - defineSettings, - defineRpc, - Icon() { - throw new Error("Icon is available only in plugin client code"); - }, -}; - function runtimeRequire(name: string): unknown { if (isPluginClientOnlySdkSpecifier(name)) { throw new Error(`${name} is available only in plugin client code`); } - if (isPluginServerTypesSdkSpecifier(name)) return {}; - if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime; + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; + if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; + if (name === "@getpaseo/plugin/client/host") + throw new Error(`${name} is private to the app host`); return nodeRequire(name); } diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index 9961aafc8c..2c17db0483 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -1,36 +1,26 @@ -// Specifiers a plugin author can import to reach the SDK. The daemon never resolves these -// from disk: the compiler marks them external and the plugin subprocess hands back its own -// runtime, so a plugin typechecks against generated declarations without installing anything. -// -// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never -// published — that scope is not ours. Plugins scaffolded against that name still import it, so -// both spellings resolve. Remove the @paseo/* entries after 2026-11-19. +// These entries are supplied by the host and remain external in author bundles. export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ - "@getpaseo/plugin/ui", - "@getpaseo/plugin/react-native", - "@paseo/plugin/react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + "@getpaseo/plugin/client/react-native", ] as const; -export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [ +const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [ "@getpaseo/plugin/server", - "@paseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", ] as const; export const PLUGIN_SDK_SPECIFIERS = [ "@getpaseo/plugin", - "@paseo/plugin", - ...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS, + ...PLUGIN_SERVER_ONLY_SDK_SPECIFIERS, ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, ] as const; -export function isPluginSdkSpecifier(name: string): boolean { - return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name); -} - export function isPluginClientOnlySdkSpecifier(name: string): boolean { return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } -export function isPluginServerTypesSdkSpecifier(name: string): boolean { - return (PLUGIN_SERVER_TYPES_SDK_SPECIFIERS as readonly string[]).includes(name); +export function isPluginServerOnlySdkSpecifier(name: string): boolean { + return (PLUGIN_SERVER_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 940ca26e4c..593a4fd52b 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,10 +1,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { fork } from "node:child_process"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; @@ -207,6 +208,65 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it.each([ + { specifier: "@getpaseo/plugin", moduleDirectory: "shared" }, + { specifier: "@getpaseo/plugin", moduleDirectory: "server" }, + ])( + "loads $specifier contracts without React in the subprocess module graph", + async ({ specifier, moduleDirectory }) => { + const directory = await createPlugin( + "react-free", + `import { rpc } from "./${moduleDirectory}/contract"; +export default function contribute(server) { + server.handle(rpc, (input) => input); + return () => {}; +}`, + ); + await mkdir(path.join(directory, moduleDirectory)); + await writeFile( + path.join(directory, moduleDirectory, "contract.ts"), + `import { defineRpc, defineAttachmentSource } from "${specifier}"; +import { z } from "zod"; +export const rpc = defineRpc({ name: "echo", input: z.string(), output: z.string() }); +const source = defineAttachmentSource({ id: "test", search: rpc }); +if (source.search !== rpc) throw new Error("Attachment contract was not preserved");`, + ); + // Reject imports even when the workspace has React installed. This covers the host's + // static graph and SDK imports evaluated through the compiled plugin's runtimeRequire. + const guard = `export function resolve(specifier, context, nextResolve) { + if (/^(react|react-dom|react-native|use-sync-external-store)(\\/|$)/.test(specifier)) { + throw new Error("React module reached plugin subprocess: " + specifier); + } + return nextResolve(specifier, context); +}`; + const guardUrl = `data:text/javascript,${encodeURIComponent(guard)}`; + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const setup = `import { register } from "node:module"; +register(${JSON.stringify(loaderUrl)}); +register(${JSON.stringify(guardUrl)});`; + const runtime = createTestRuntime({ + spawnChild: () => + fork(new URL("./plugin-process.ts", import.meta.url), [], { + execArgv: [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(setup)}`, + ], + serialization: "advanced", + stdio: ["ignore", "pipe", "pipe", "ipc"], + }), + }); + try { + await runtime.startPlugin("react-free", directory); + await expect(runtime.invoke("react-free", "echo", "hello")).resolves.toBe("hello"); + } catch (error) { + throw new Error(JSON.stringify(runtime.getLogs("react-free")), { cause: error }); + } finally { + await runtime.stopAll(); + } + }, + ); + it("runs the direct example through the existing AgentClient path", async () => { const pluginId = "provider-direct-example"; const directory = fileURLToPath( @@ -253,8 +313,8 @@ describe("PluginRuntime", () => { it("runs a provider connection through the real plugin subprocess boundary", async () => { const directory = await createPlugin( "provider-round-trip", - `import type { PluginServerContext } from "@getpaseo/plugin"; -import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "direct-example", @@ -461,8 +521,8 @@ export default function contribute(server: PluginServerContext) { ); const directory = await createPlugin( "provider-acp-round-trip", - `import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; import { vendorEditTransformer } from "./server/vendor-edit.js"; export default function contribute(server: PluginServerContext) { @@ -598,7 +658,7 @@ lines.on("line", (line) => { it("rejects a malformed provider event from a real plugin subprocess", async () => { const directory = await createPlugin( "malicious-provider", - `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; let connectionId = ""; process.on("message", (message: unknown) => { const value = message as { type?: string; connectionId?: string }; @@ -665,7 +725,7 @@ export default function contribute(server: any) { server.registerProvider(provid it("emits runtime failure for live sessions when a real plugin process dies", async () => { const directory = await createPlugin( "dying-provider", - `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "dying", label: "Dying", @@ -966,7 +1026,7 @@ export default function contribute(plugin: unknown) { const directory = await createPlugin( "shutdown-connect", `import { writeFile } from "node:fs/promises"; -import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; const provider: ProviderRegistration = { id: "delayed", @@ -1449,34 +1509,6 @@ export default function contribute(server: any) { await runtime.stopAll(); }); - // COMPAT(plugin-sdk-scope): plugins scaffolded through 0.5.0-beta.1 import the unpublished - // @paseo/plugin name. Drop with the specifiers in plugin-sdk-specifiers.ts. - it("loads a plugin that imports the pre-rename @paseo/plugin specifier", async () => { - const directory = await createPlugin( - "legacy-sdk", - `import { z } from "zod"; -import { defineRpc } from "@paseo/plugin"; - -const pingRpc = defineRpc({ - name: "ping", - input: z.object({}), - output: z.object({ ok: z.boolean() }), -}); - -export default function contribute(plugin: any) { - plugin.handle(pingRpc, async () => ({ ok: true })); - return () => undefined; -}`, - ); - const runtime = createTestRuntime(); - - await runtime.startPlugin("legacy-sdk", directory); - - await expect(runtime.invoke("legacy-sdk", "ping", {})).resolves.toMatchObject({ ok: true }); - - 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); @@ -1493,7 +1525,7 @@ export default function contribute(plugin: any) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { client.addSurface("main", Surface); @@ -1503,7 +1535,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectRpc } from "./shared/inspect"; import { inspectHost } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -1576,7 +1608,7 @@ export function inspectHost(_input: z.input) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { @@ -1621,7 +1653,7 @@ export function Surface() { return readSecret(); }`, ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspect } from "./server/inspect"; import { inspectRpc } from "./shared/inspect"; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 8902793fcf..7affa3cd97 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -14,7 +14,7 @@ import { type ProviderConnection, type ProviderEvent, type ProviderInput, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index ce49b33876..2823cd79ab 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; import { z } from "zod"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { settingsRpc } from "@getpaseo/plugin"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index 8ac418330a..ccdd3e5dd6 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin/host"; +import { settingsRpc } from "@getpaseo/plugin"; const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); function message(error: unknown): string { diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index 6d74bae0f8..1f86e63a67 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { server.before("agent.create", ({ request }) => { diff --git a/plugin-examples/catppuccin/index.client.ts b/plugin-examples/catppuccin/index.client.ts index 3dd78f6882..da65198710 100644 --- a/plugin-examples/catppuccin/index.client.ts +++ b/plugin-examples/catppuccin/index.client.ts @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { client.addTheme({ diff --git a/plugin-examples/inline-thinking/client/thinking.tsx b/plugin-examples/inline-thinking/client/thinking.tsx index c24f210f21..88bf4d6903 100644 --- a/plugin-examples/inline-thinking/client/thinking.tsx +++ b/plugin-examples/inline-thinking/client/thinking.tsx @@ -1,5 +1,5 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; -import { useRevealedText } from "@getpaseo/plugin/react-native"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import { useRevealedText } from "@getpaseo/plugin/client/react-native"; import { useMemo } from "react"; import { Text } from "react-native"; import { z } from "zod"; diff --git a/plugin-examples/inline-thinking/index.client.tsx b/plugin-examples/inline-thinking/index.client.tsx index f78b646761..f93c0a894b 100644 --- a/plugin-examples/inline-thinking/index.client.tsx +++ b/plugin-examples/inline-thinking/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { InlineThinking, inlineThinkingSchema } from "./client/thinking"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index 9ca88734b8..44ee2cecff 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText, shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 92f5ca85b5..04d967949c 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { logHook } from "./server/log"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/linear/index.client.ts b/plugin-examples/linear/index.client.ts index 3985ff8c91..6b25651fc0 100644 --- a/plugin-examples/linear/index.client.ts +++ b/plugin-examples/linear/index.client.ts @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issueAttachments } from "./shared/issues"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/linear/index.server.ts b/plugin-examples/linear/index.server.ts index 841d26dd93..544ab0081c 100644 --- a/plugin-examples/linear/index.server.ts +++ b/plugin-examples/linear/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { searchIssues } from "./server/issues"; import { searchIssuesRpc } from "./shared/issues"; diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 8bea0bf554..878ba5da17 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -1,13 +1,13 @@ import { useMutation } from "@tanstack/react-query"; +import { Icon } from "@getpaseo/plugin/client/react-native"; import { - Icon, type PluginClientContext, type PluginComposerPillProps, type PluginWorkspacePanelProps, useAgent, useRpc, useWorkspace, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; import { incrementRpc } from "../shared/increment"; diff --git a/plugin-examples/local-plugin/index.client.tsx b/plugin-examples/local-plugin/index.client.tsx index 5eee9b545d..4a95c337fc 100644 --- a/plugin-examples/local-plugin/index.client.tsx +++ b/plugin-examples/local-plugin/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { contributeClient, ExamplePanel } from "./client/main"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/local-plugin/index.server.ts b/plugin-examples/local-plugin/index.server.ts index 3b703a617c..c0176ac40e 100644 --- a/plugin-examples/local-plugin/index.server.ts +++ b/plugin-examples/local-plugin/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { increment } from "./server/increment"; import { incrementRpc } from "./shared/increment"; diff --git a/plugin-examples/modal-ui/client/examples.tsx b/plugin-examples/modal-ui/client/examples.tsx index e834983bfc..bbd0c8c0e8 100644 --- a/plugin-examples/modal-ui/client/examples.tsx +++ b/plugin-examples/modal-ui/client/examples.tsx @@ -1,4 +1,4 @@ -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { Modal, ScrollView, @@ -6,7 +6,7 @@ import { TextInput, copyText, useToast, -} from "@getpaseo/plugin/react-native"; +} from "@getpaseo/plugin/client/react-native"; import { useCallback, useMemo, useRef, useState } from "react"; import { Pressable, diff --git a/plugin-examples/modal-ui/index.client.tsx b/plugin-examples/modal-ui/index.client.tsx index 15bd5ae2c8..e7b2baf54e 100644 --- a/plugin-examples/modal-ui/index.client.tsx +++ b/plugin-examples/modal-ui/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ModalExamples } from "./client/examples"; export default function contribute(plugin: PluginClientContext) { diff --git a/plugin-examples/provider-acp-transformer/index.server.ts b/plugin-examples/provider-acp-transformer/index.server.ts index 5b85f30fa5..bf63ffbc94 100644 --- a/plugin-examples/provider-acp-transformer/index.server.ts +++ b/plugin-examples/provider-acp-transformer/index.server.ts @@ -1,5 +1,5 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; import { vendorEditTransformer } from "./server/vendor-edit.js"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts index 58908678af..87229ca667 100644 --- a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts @@ -1,4 +1,4 @@ -import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; import { z } from "zod"; const vendorEditInputSchema = z.object({ diff --git a/plugin-examples/provider-direct/client/provider-result.tsx b/plugin-examples/provider-direct/client/provider-result.tsx index b7d2745f37..2a37e6f4d7 100644 --- a/plugin-examples/provider-direct/client/provider-result.tsx +++ b/plugin-examples/provider-direct/client/provider-result.tsx @@ -1,4 +1,4 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; import type { z } from "zod"; diff --git a/plugin-examples/provider-direct/index.client.tsx b/plugin-examples/provider-direct/index.client.tsx index 7954fdecb2..a1e8ca87b2 100644 --- a/plugin-examples/provider-direct/index.client.tsx +++ b/plugin-examples/provider-direct/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ProviderResult } from "./client/provider-result"; import { providerResultKind, providerResultSchema } from "./shared/provider-result"; diff --git a/plugin-examples/provider-direct/index.server.ts b/plugin-examples/provider-direct/index.server.ts index 61a094391d..d877d897d6 100644 --- a/plugin-examples/provider-direct/index.server.ts +++ b/plugin-examples/provider-direct/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createDirectExampleProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts index ce989f4b4f..db7282df86 100644 --- a/plugin-examples/provider-direct/server/provider.test.ts +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -1,4 +1,4 @@ -import type { ProviderEvent } from "@getpaseo/plugin/provider"; +import type { ProviderEvent } from "@getpaseo/plugin/server/provider"; import { describe, expect, it } from "vitest"; import { createDirectExampleProvider } from "./provider.js"; diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts index 6a8bc957cc..31ea177dd6 100644 --- a/plugin-examples/provider-direct/server/provider.ts +++ b/plugin-examples/provider-direct/server/provider.ts @@ -8,7 +8,7 @@ import { type ProviderPersistence, type ProviderRegistration, type ProviderSessionConfig, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; import { randomUUID } from "node:crypto"; import { providerResultKind } from "../shared/provider-result.js"; diff --git a/plugin-examples/settings/client/display-settings.tsx b/plugin-examples/settings/client/display-settings.tsx index f3f20199cc..36999c7a45 100644 --- a/plugin-examples/settings/client/display-settings.tsx +++ b/plugin-examples/settings/client/display-settings.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import { Text, View } from "react-native"; -import { useSettings, type PluginSurfaceProps, type SettingsState } from "@getpaseo/plugin"; +import { useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { SettingsState } from "@getpaseo/plugin/client"; import { SettingsAction, SettingsCard, @@ -9,7 +10,7 @@ import { SettingsSection, SettingsSelect, SettingsSwitch, -} from "@getpaseo/plugin/ui"; +} from "@getpaseo/plugin/client/ui"; import { preferences } from "../shared/preferences"; const grouping = [ diff --git a/plugin-examples/settings/index.client.tsx b/plugin-examples/settings/index.client.tsx index 09583a6024..7e7a2dfedc 100644 --- a/plugin-examples/settings/index.client.tsx +++ b/plugin-examples/settings/index.client.tsx @@ -1,4 +1,4 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { DisplaySettings } from "./client/display-settings"; export default function contribute(client: PluginClientContext) { diff --git a/plugin-examples/settings/index.server.ts b/plugin-examples/settings/index.server.ts index 13b136c58e..1dd653a091 100644 --- a/plugin-examples/settings/index.server.ts +++ b/plugin-examples/settings/index.server.ts @@ -1,4 +1,4 @@ -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { preferences } from "./shared/preferences"; export default function contribute(server: PluginServerContext) { diff --git a/plugin-examples/timeline-items/client/pi-tasks.tsx b/plugin-examples/timeline-items/client/pi-tasks.tsx index 4d7329a18a..f9b68edc62 100644 --- a/plugin-examples/timeline-items/client/pi-tasks.tsx +++ b/plugin-examples/timeline-items/client/pi-tasks.tsx @@ -1,4 +1,4 @@ -import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; import type { z } from "zod"; diff --git a/plugin-examples/timeline-items/client/transform-pi-tasks.ts b/plugin-examples/timeline-items/client/transform-pi-tasks.ts new file mode 100644 index 0000000000..b41a2a003c --- /dev/null +++ b/plugin-examples/timeline-items/client/transform-pi-tasks.ts @@ -0,0 +1,72 @@ +import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin/client"; +import { z } from "zod"; +import { piTaskListSchema } from "../shared/pi-tasks"; + +const rpivDetailsSchema = z.object({ + tasks: z.array( + z + .object({ + subject: z.string(), + status: z.enum(["pending", "in_progress", "completed", "deleted"]), + }) + .passthrough(), + ), +}); + +const piExampleDetailsSchema = z.object({ + todos: z.array( + z + .object({ + id: z.number().int().optional(), + text: z.string(), + done: z.boolean(), + }) + .passthrough(), + ), +}); + +type PiTask = z.output["tasks"][number]; +type ToolCallTransformer = PluginTimelineTransformerContribution<"tool_call">["transform"]; + +function replacement(tasks: PiTask[]) { + if (tasks.length === 0) return; + return { + items: [ + { + type: "plugin" as const, + kind: "pi-task-list", + version: 1, + data: { tasks }, + }, + ], + }; +} + +export const transformPiTodoToolCall: ToolCallTransformer = ({ item }) => { + if (item.name !== "todo" || item.detail.type !== "unknown") { + return; + } + + const result = item.detail.output; + if (!result || typeof result !== "object" || Array.isArray(result)) return; + const details = Reflect.get(result, "details"); + + const rpiv = rpivDetailsSchema.safeParse(details); + if (rpiv.success) { + return replacement( + rpiv.data.tasks.flatMap((task): PiTask[] => + task.status === "deleted" ? [] : [{ text: task.subject, status: task.status }], + ), + ); + } + + const piExample = piExampleDetailsSchema.safeParse(details); + if (piExample.success) { + return replacement( + piExample.data.todos.map((todo) => ({ + text: todo.text, + status: todo.done ? "completed" : "pending", + })), + ); + } +}; diff --git a/plugin-examples/timeline-items/index.client.tsx b/plugin-examples/timeline-items/index.client.tsx index 09724b095b..9e8c488320 100644 --- a/plugin-examples/timeline-items/index.client.tsx +++ b/plugin-examples/timeline-items/index.client.tsx @@ -1,6 +1,7 @@ -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { PiTaskList } from "./client/pi-tasks"; -import { piTaskListSchema, transformPiTodoToolCall } from "./shared/pi-tasks"; +import { piTaskListSchema } from "./shared/pi-tasks"; +import { transformPiTodoToolCall } from "./client/transform-pi-tasks"; export default function contribute(client: PluginClientContext) { client.addTimelineTransformer({ diff --git a/plugin-examples/timeline-items/pi-tasks.test.ts b/plugin-examples/timeline-items/pi-tasks.test.ts index 80fbbcd579..8007365cd0 100644 --- a/plugin-examples/timeline-items/pi-tasks.test.ts +++ b/plugin-examples/timeline-items/pi-tasks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { transformPiTodoToolCall } from "./shared/pi-tasks"; +import { transformPiTodoToolCall } from "./client/transform-pi-tasks"; function completedTodo(output: unknown) { return { diff --git a/plugin-examples/timeline-items/shared/pi-tasks.ts b/plugin-examples/timeline-items/shared/pi-tasks.ts index 0e7c7c739c..91c60617f1 100644 --- a/plugin-examples/timeline-items/shared/pi-tasks.ts +++ b/plugin-examples/timeline-items/shared/pi-tasks.ts @@ -1,31 +1,7 @@ -import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin"; import { z } from "zod"; const taskStatusSchema = z.enum(["pending", "in_progress", "completed"]); -const rpivDetailsSchema = z.object({ - tasks: z.array( - z - .object({ - subject: z.string(), - status: z.enum(["pending", "in_progress", "completed", "deleted"]), - }) - .passthrough(), - ), -}); - -const piExampleDetailsSchema = z.object({ - todos: z.array( - z - .object({ - id: z.number().int().optional(), - text: z.string(), - done: z.boolean(), - }) - .passthrough(), - ), -}); - export const piTaskListSchema = z.object({ tasks: z.array( z.object({ @@ -34,49 +10,3 @@ export const piTaskListSchema = z.object({ }), ), }); - -type PiTask = z.output["tasks"][number]; -type ToolCallTransformer = PluginTimelineTransformerContribution<"tool_call">["transform"]; - -function replacement(tasks: PiTask[]) { - if (tasks.length === 0) return; - return { - items: [ - { - type: "plugin" as const, - kind: "pi-task-list", - version: 1, - data: { tasks }, - }, - ], - }; -} - -export const transformPiTodoToolCall: ToolCallTransformer = ({ item }) => { - if (item.name !== "todo" || item.detail.type !== "unknown") { - return; - } - - const result = item.detail.output; - if (!result || typeof result !== "object" || Array.isArray(result)) return; - const details = Reflect.get(result, "details"); - - const rpiv = rpivDetailsSchema.safeParse(details); - if (rpiv.success) { - return replacement( - rpiv.data.tasks.flatMap((task): PiTask[] => - task.status === "deleted" ? [] : [{ text: task.subject, status: task.status }], - ), - ); - } - - const piExample = piExampleDetailsSchema.safeParse(details); - if (piExample.success) { - return replacement( - piExample.data.todos.map((todo) => ({ - text: todo.text, - status: todo.done ? "completed" : "pending", - })), - ); - } -}; diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index 95571e2df8..0329b31b2b 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -64,7 +64,7 @@ Each entry default-exports one function that registers contributions and returns function. `index.client.tsx` registers the surface and the sidebar item that opens it: ```tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingSurface } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -82,7 +82,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts` registers the handler for the contract in `shared/greeting.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetingRpc } from "./shared/greeting"; @@ -135,7 +135,7 @@ daemon-side output, including load errors. A workspace panel opens as a tab next to agents, terminals, and files. Create `client/overview.tsx`: ```tsx -import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin"; +import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index a574958e02..ce5c94d075 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -85,14 +85,31 @@ Use this table as the complete registration checklist. | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | | `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | -Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry. -Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side -types such as `PluginHandlerContext`. Every client `add*` now returns an idempotent removal -function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding +Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerContext` from +`@getpaseo/plugin/server`. Remove imports of the old context type. Every client `add*` now returns an idempotent removal function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding registrations after the entry cleanup runs. ## 4. Separate imports +Move hooks (`usePaseo`, `useRpc`, `useSettings`, `useAgent`, `useWorkspace`) and client contribution +types from `@getpaseo/plugin` to `@getpaseo/plugin/client`. Move `Icon` to +`@getpaseo/plugin/client/react-native`. Import server contexts and lifecycle contracts from +`@getpaseo/plugin/server`. Shared helpers (`defineRpc`, `defineSettings`, `defineAttachmentSource`), +schemas, and plain data types stay on the root. These rules include type imports. See +[Runtime modules](reference#runtime-modules) for the complete contract. + +Move the remaining SDK subpaths under their runtime owner: + +| Old entry | 0.8 entry | +| ------------------------------- | -------------------------------------- | +| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/react-native` | +| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` | +| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` | +| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` | + +The old entries and the pre-0.8 `@paseo/plugin` scope are removed. `/client/host` is private to +Paseo's app integration and is never a plugin-author import. + The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports only `server/`, `shared/`, and server-safe packages. A `node:` import in the client entry or anything reachable from it is a compile error. Never import a component into the server entry merely to wire @@ -169,7 +186,7 @@ local-plugin/ ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { contributeClient, ExamplePanel } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -196,7 +213,7 @@ export default function contribute(client: PluginClientContext) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { increment } from "./server/increment"; import { incrementRpc } from "./shared/increment"; diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index 8b488def40..f9af6596e9 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -34,7 +34,7 @@ Add a server entry: ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createProvider } from "./server/provider"; export default function contribute(server: PluginServerContext) { @@ -49,7 +49,7 @@ export default function contribute(server: PluginServerContext) { import { negotiateProviderCapabilities, type ProviderRegistration, -} from "@getpaseo/plugin/provider"; +} from "@getpaseo/plugin/server/provider"; const supported = ["prompt.message"] as const; @@ -304,7 +304,7 @@ emit({ Register the renderer independently in `index.client.tsx`: ```tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { z } from "zod"; import { ReviewVerdict } from "./client/review-verdict"; @@ -332,8 +332,8 @@ daemon timeline append. A renderer does not require a provider implementation. Use the ACP shim when the agent already speaks ACP: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; -import { runAcpProvider } from "@getpaseo/plugin/acp"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; export default function contribute(server: PluginServerContext) { server.registerProvider( @@ -355,7 +355,7 @@ Use `transformers` only for vendor differences ACP cannot describe. Validate ven Zod and leave malformed or unrelated values unchanged: ```ts -import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import type { AcpTransformer } from "@getpaseo/plugin/server/acp"; import { z } from "zod"; const editSchema = z.object({ diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index eeb703ae82..6147693d1f 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -108,24 +108,34 @@ plugin root is a compile error. ## Runtime modules Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle, -from `server/` into the app bundle, or of a `node:` module anywhere in the app bundle is a compile -error. Keep `shared/` free of Node and React Native runtime code. +from `server/` into the app bundle, or of a Node module anywhere in the app bundle is a compile +error. Server imports of React, React Native, or client SDK entries also fail. Shared code imports +only shared code: no Node, React, runtime-specific SDK entries, or runtime-specific types. + +The SDK root (`@getpaseo/plugin`) contains shared data, schemas, and runtime-neutral helpers only. +Import client contexts and hooks from `/client`, server contexts and lifecycle contracts from +`/server`, and UI from `/client/react-native` or `/client/ui`. These rules include type imports and transitive +dependencies. `/client/host` is private to the app host; plugins cannot import it. ### Client runtime Paseo provides these modules to client code: -| Module | Use it for | -| ------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks | -| `@getpaseo/plugin/ui` | Named, composable settings components | -| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | -| `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` | -| `@tanstack/react-query` | Request state and caching | -| `react` | Components and hooks | -| `react/jsx-runtime` | Compiled JSX | -| `react-native` | Cross-platform UI | -| `zod` | Shared schemas | +| Module | Use it for | +| -------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `@getpaseo/plugin` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` | +| `@getpaseo/plugin/client/ui` | Named, composable settings components | +| `@getpaseo/plugin/client/react-native` | Paseo UI components and UI hooks | +| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks | +| `@tanstack/react-query` | Request state and caching | +| `react` | Components and hooks | +| `react/jsx-runtime` | Compiled JSX | +| `react-native` | Cross-platform UI | +| `zod` | Shared schemas | + +The host owns its paired React and renderer versions. The SDK's React peer range permits patch +versions for tooling and Node consumers; it does not change the app's pinned React version or +guarantee compatibility with another host's renderer. These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "" is not available in plugin client code`. @@ -176,7 +186,7 @@ Use `openSettings`, `openSurface`, and `openPanel` for your own registered contr ### Server runtime Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, -`@getpaseo/plugin/provider`, `@getpaseo/plugin/acp`, and `zod` to server code. Backend +`@getpaseo/plugin/server/provider`, `@getpaseo/plugin/server/acp`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess. @@ -187,7 +197,7 @@ Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and AC session lifecycle, composer settings, timeline renderers, testing, and distribution. Call `server.registerProvider()` with a `ProviderRegistration` from -`@getpaseo/plugin/provider`. Its connection accepts inputs with `send()` and emits complete state +`@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state snapshots through `onEvent()`. `send()` reports acceptance only; prompt disposition, turns, configuration, persistence, permissions, and failures are events. @@ -202,7 +212,7 @@ session config. Paseo refreshes an agent by closing its current provider session and opening it with current configuration and persistence. Providers re-read external state during `session.open`. -Use `runAcpProvider()` from `@getpaseo/plugin/acp` to adapt a command-backed ACP. Add transformer +Use `runAcpProvider()` from `@getpaseo/plugin/server/acp` to adapt a command-backed ACP. Add transformer hooks only for a vendor's discovery, configuration, notification, or tool-call differences. `ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. @@ -219,7 +229,7 @@ receive `PluginClientContext`; server entries receive `PluginServerContext`. Eve returns an idempotent remover. The entry cleanup runs before Paseo removes remaining registrations. ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Main } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -235,7 +245,7 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre In `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { server.on("agent.turn_ended", (event) => { @@ -339,7 +349,7 @@ Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-exam into your plugin. `latestOutputText` joins text chunks after the latest user message. ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -372,7 +382,7 @@ add limits or delays in your plugin when needed. Attachments and tool effects ar Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -558,7 +568,7 @@ Register a component, then point a sidebar item at its surface ID: `client/main.tsx`: ```tsx -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -587,7 +597,7 @@ export function Main({ theme, host, layout }: PluginSurfaceProps) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Main } from "./client/main"; export default function contribute(client: PluginClientContext) { @@ -615,12 +625,12 @@ Paseo owns the route, header, close action, host picker, error boundary, and que ## Host UI -Import Paseo-owned UI from `@getpaseo/plugin/react-native` in client code. This example +Import Paseo-owned UI from `@getpaseo/plugin/client/react-native` in client code. This example opens a controlled modal, renders a host icon, and confirms the action with a toast: ```tsx -import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; import { useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -699,7 +709,7 @@ Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorksp ### Scrolling -Import `ScrollView` and `FlatList` from `@getpaseo/plugin/react-native` when content can appear in a +Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/react-native` when content can appear in a Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly. @@ -713,7 +723,7 @@ container without changing these gestures. Expand the sheet before using list me `scrollToEnd`; the sheet locks list offsets below its largest height. ```tsx -import { FlatList, Modal } from "@getpaseo/plugin/react-native"; +import { FlatList, Modal } from "@getpaseo/plugin/client/react-native"; import { Text } from "react-native"; // Inside your controlled Modal: @@ -743,7 +753,7 @@ user action and await it before reporting success. It rejects if the platform de clipboard is unavailable; browser permissions and secure-context requirements still apply. ```tsx -import { copyText, useToast } from "@getpaseo/plugin/react-native"; +import { copyText, useToast } from "@getpaseo/plugin/client/react-native"; // Inside your component: const toast = useToast(); @@ -758,7 +768,7 @@ async function copyResult() { ``` Programmatic copying and native text selection are separate interactions. Use `` -for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/react-native` for modal forms. It accepts React Native +for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/react-native` for modal forms. It accepts React Native input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read @@ -804,7 +814,7 @@ registrations are client contributions. Paseo applies the transformer while buil model, including every live streaming update. ```tsx -import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin"; +import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client"; import { Text } from "react-native"; import { z } from "zod"; @@ -860,7 +870,7 @@ assistant rows. A server handler can add a plugin-owned row to canonical history: ```ts -import type { PluginHandlerContext } from "@getpaseo/plugin"; +import type { PluginHandlerContext } from "@getpaseo/plugin/server"; async function publishReview(agentId: string, { paseo }: PluginHandlerContext) { await paseo.agents.ref(agentId).timeline.append({ @@ -919,7 +929,7 @@ Workspace and agent panels receive the same `theme`, `layout`, and optional `nav `name`. A theme is data, so it needs no component file: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { client.addTheme({ @@ -984,12 +994,12 @@ A disabled or removed plugin leaves an unavailable screen with working Back navi ### Named UI components -Import settings components from `@getpaseo/plugin/ui`. They work with your own state and RPCs; +Import settings components from `@getpaseo/plugin/client/ui`. They work with your own state and RPCs; no form wrapper or storage binding is required. ```tsx import { useState } from "react"; -import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/ui"; +import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui"; export function DisplaySettings() { const [visible, setVisible] = useState(true); @@ -1088,7 +1098,7 @@ Register one panel for workspace or agent context: `client/review.tsx`: ```tsx -import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin"; +import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -1119,7 +1129,7 @@ export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgent `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { @@ -1291,12 +1301,12 @@ The client entry owns pill creation and removal. This can live directly in `inde a function it imports from `client/`: ```tsx +import { Icon } from "@getpaseo/plugin/client/react-native"; import { - Icon, type PluginClientContext, type PluginComposerPillProps, useAgent, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { Text } from "react-native"; function ReviewPill({ theme, agentId }: PluginComposerPillProps) { @@ -1366,7 +1376,7 @@ registered by the same plugin. Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client. ```tsx -import { usePaseo } from "@getpaseo/plugin"; +import { usePaseo } from "@getpaseo/plugin/client"; import { Pressable, Text } from "react-native"; function PullRequestAction() { @@ -1420,7 +1430,7 @@ export const greeting = defineRpc({ `client/greeting.tsx`: ```tsx -import { useRpc } from "@getpaseo/plugin"; +import { useRpc } from "@getpaseo/plugin/client"; import { greeting } from "../shared/greeting"; export function GreetingButton() { @@ -1444,7 +1454,7 @@ export function createGreeting({ name }: RpcInput) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { GreetingButton } from "./client/greeting"; export default function contribute(client: PluginClientContext) { @@ -1456,7 +1466,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greeting } from "./shared/greeting"; @@ -1559,7 +1569,7 @@ export function search({ query }: RpcInput) { `index.client.tsx`: ```ts -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { @@ -1571,7 +1581,7 @@ export default function contribute(client: PluginClientContext) { `index.server.ts`: ```ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { search } from "./server/issues"; import { searchIssues } from "./shared/issues"; diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index cbc67341f5..997cb5c7cb 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -169,7 +169,14 @@ test("PR routing declares stable behavior ownership", () => { "packages/app/package.json", ], app: ["packages/app/**", "packages/expo-two-way-audio/**"], - sdk: ["packages/client/**", "packages/highlight/**", "packages/protocol/**"], + sdk: [ + "packages/plugin/**", + "plugin-examples/**", + "public-docs/plugins/v0.8/**", + "packages/client/**", + "packages/highlight/**", + "packages/protocol/**", + ], browser: [ "packages/server/src/server/agent/provider-snapshot-manager.ts", "packages/server/src/server/session/provider/provider-catalog-session.ts", @@ -182,7 +189,7 @@ test("PR routing declares stable behavior ownership", () => { "packages/server/src/server/agent/plugin-provider.ts", "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", "packages/server/src/executable-resolution/**", - "packages/plugin/src/provider.ts", + "packages/plugin/src/server/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", @@ -262,7 +269,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { "packages/server/src/server/agent/plugin-provider.ts", "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts", "packages/server/src/executable-resolution/**", - "packages/plugin/src/provider.ts", + "packages/plugin/src/server/provider.ts", "packages/app/src/!(desktop)/**", "packages/app/e2e/browser/**", "packages/app/e2e/support/**", diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index cc2c4eddc7..9bd927cc8c 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -105,7 +105,7 @@ Default-export one contribution function from each entry and return cleanup: ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; export default function contribute(client: PluginClientContext) { // Register components and client callbacks here. @@ -115,7 +115,7 @@ export default function contribute(client: PluginClientContext) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { // Register daemon-side RPC handlers here. @@ -139,7 +139,7 @@ import { type PluginClientContext, type PluginWorkspacePanelProps, useWorkspace, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -195,7 +195,7 @@ the active workspace or agent. Command callbacks receive the selected host's `pa Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`. ```tsx -import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client"; import { useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -244,13 +244,12 @@ export default function contribute(client: PluginClientContext) { Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. -Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@getpaseo/plugin`, and `@getpaseo/plugin/react-native`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. JSX compiles with the automatic runtime, so no `React` import is needed for JSX. Importing a `node:` module from client code is a compile error. - -| Module | Use it for | -| ------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, `usePaseo`, `useRpc`, and data hooks | -| `@getpaseo/plugin/react-native` | Paseo UI: `Icon`, `Modal`, `useToast`, `useRevealedText` | -| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | +Before writing imports, classify each module as shared, client, or server. Follow the +[SDK import boundaries](https://paseo.sh/docs/plugins/v0.8/reference.md#runtime-modules), including +transitive and type dependencies. The root is shared-only; hooks and client contexts belong to +`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`. +Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the +automatic runtime. Do not import `/client/host` from plugin code. ## Works on mobile @@ -281,7 +280,7 @@ Use the existing Paseo SDK for normal Paseo operations. Use plugin RPC only for `usePaseo()` borrows the selected host's current connection. Never create another client inside a surface. ```tsx -import { usePaseo } from "@getpaseo/plugin"; +import { usePaseo } from "@getpaseo/plugin/client"; function PullRequestAction() { const paseo = usePaseo(); @@ -338,7 +337,7 @@ export async function createGreeting({ name }: RpcInput) { ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greeting } from "./shared/greeting"; @@ -350,7 +349,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // client/greeting.tsx -import { useRpc } from "@getpaseo/plugin"; +import { useRpc } from "@getpaseo/plugin/client"; import { greeting } from "../shared/greeting"; function Greeting() { @@ -422,7 +421,7 @@ const issues = defineAttachmentSource({ ```ts // index.server.ts -import type { PluginServerContext } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; import { searchIssues } from "./shared/issues"; export default function contribute(server: PluginServerContext) { @@ -433,7 +432,7 @@ export default function contribute(server: PluginServerContext) { ```tsx // index.client.tsx -import type { PluginClientContext } from "@getpaseo/plugin"; +import type { PluginClientContext } from "@getpaseo/plugin/client"; import { issues } from "./shared/issues"; export default function contribute(client: PluginClientContext) { @@ -519,7 +518,7 @@ client.addTimelineRenderer({ }); ``` -Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card. +Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/client/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card. ## Append a timeline row from the daemon