diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ab825ddbc0..79d5d1c584 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,7 @@ on: - "packages/workspace-runtime-contract/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" + - "packages/plugin/**" - "packages/protocol/**" - "packages/relay/**" - "packages/server/**" diff --git a/.github/workflows/nix-update-hash.yml b/.github/workflows/nix-update-hash.yml index 0acf9ee21f..0aae8f81c7 100644 --- a/.github/workflows/nix-update-hash.yml +++ b/.github/workflows/nix-update-hash.yml @@ -10,6 +10,7 @@ on: - "package.json" - "package-lock.json" - "packages/highlight/**" + - "packages/plugin/**" - "packages/server/**" - "packages/relay/**" - "packages/cli/**" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6da8385187..bf198d1c94 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -12,6 +12,7 @@ on: - "packages/app/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" + - "packages/plugin/**" - "packages/protocol/**" - "packages/client/**" - "packages/server/**" diff --git a/CLAUDE.md b/CLAUDE.md index 2792cebf06..3068dd3fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 860891a14e..7670a91f96 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -25,6 +25,7 @@ RUN set -eux; \ npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 4da30bf0e0..a607873ade 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. -The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. +The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. @@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### Subagent accumulation under long-lived parents -A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts. +A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts. ### Cross-client tab dismissal diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000000..a59cb996e5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,173 @@ +# Local plugins + +Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one +`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client +contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it. + +## Install a directory source + +Create a typecheckable plugin project, install its development dependencies, then install it into +the daemon. `init` only writes the project files; it does not run the package manager. + +```bash +paseo plugin init /absolute/path/to/my-plugin +cd /absolute/path/to/my-plugin +npm install +npm run typecheck +paseo plugin install /absolute/path/to/my-plugin +paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id +paseo plugin ls +``` + +The daemon stores directory sources under the root `plugins` object: + +```json +{ + "pluginsEnabled": true, + "plugins": { + "my-plugin": { + "source": "directory", + "path": "/absolute/path/to/my-plugin", + "enabled": true + } + } +} +``` + +The plugin system is disabled unless `pluginsEnabled` is `true`. + +The directory contains an identity-only manifest, one entry point, and local typechecking support: + +```text +my-plugin/ + paseo-plugin.json + index.tsx + paseo-plugin.d.ts + package.json + tsconfig.json +``` + +Paseo compiles TSX when loading the plugin, so these packages are development dependencies only. +The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a +public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes. + +```json +{ + "id": "my-plugin" +} +``` + +The config key is the runtime plugin ID. The manifest ID is the default selected during install; +`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the +runtime does not compare the two IDs. The same directory can be installed under several config +keys. + +Source changes are explicit. Run `paseo plugin reload ` to stop and fully tear down the old +plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore +the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its +configuration, never its source directory. The global `pluginsEnabled` switch remains available. + +## Contribute behavior and UI + +Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler +removes UI registrations from the server bundle and RPC registrations from the client bundle before +resolving dependencies. + +```tsx +import { Text } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; +import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), +}); + +function Greeting() { + const greet = useRpc(greetRpc); + const query = useQuery({ + queryKey: ["greeting", "Paseo"], + queryFn: () => greet({ name: "Paseo" }), + }); + return {query.data?.message}; +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` })); + plugin.addSurface("main", Greeting); + plugin.addSidebarItem({ + id: "main", + title: "Greeting", + icon: "MessageCircle", + surface: "main", + }); + return () => undefined; +} +``` + +The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded, +disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code. +Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs, +and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown. + +Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts, +and render error boundary. The contributed component owns the complete body below the header. + +RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a +typed async function. Use the host-provided `@tanstack/react-query` for request state and caching; +Paseo gives each plugin installation its own query client. + +When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and +adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and +query cache. Plugin code cannot address another host. + +## Contribute composer attachments + +Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu, +search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots; +credentials and vendor API calls stay in the daemon handler. + +```tsx +const searchIssues = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + text: z.string(), + resourceType: z.string(), + }), + ), + }), +}); + +const issues = defineAttachmentSource({ + id: "issues", + title: "Acme issue", + icon: "CircleDot", + pickerTitle: "Attach Acme issue", + searchPlaceholder: "Search by identifier or title", + search: searchIssues, +}); + +export default function contribute(plugin: PluginContext) { + plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); + plugin.addAttachmentSource(issues); + return () => undefined; +} +``` + +Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources +on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral +external-resource presentation, so it remains readable if the plugin is removed or an older peer +drops the optional presentation fields. + +See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete +attachment-source example. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 6abe6bfec2..71bda619a2 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. -The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record -contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch, -older-history availability, authority status, or sync generation because those facts would describe -the complete source dataset rather than the truncated display dataset. +The durable replica cache persists synchronization authority only when it can store the complete +current canonical window losslessly. The stored range describes those exact items: `startSeq` drives +older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests +`after endSeq`, and requests `before startSeq` when the user loads older history. -Restoring that cache produces a painted timeline: the items may render immediately, but the first -daemon timeline request is still `tail`. A successful tail response atomically establishes canonical -items, range, and older-history availability. Live rows received between cache paint and that tail -response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are -reconciled with the authoritative tail and subsequent catch-up. +The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail +instead of replaying every missed page. Live gap recovery still pages forward until current. + +If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a +live head, or includes presentation data the cache cannot encode losslessly, persistence drops the +range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never +slice items while retaining the pre-slice range; that falsely certifies discarded source rows. + +Live rows received between cache paint and catch-up stay in the separate live head and reconcile with +the authoritative range through the existing forward-page path. The cache does not persist sync +generation or unreconciled local submissions. Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap replacement keeps only positioned rows newer than the page it installs, while unresolved local diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index a66d60fdec..3e19b6cc97 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-QFP2lkZBO/MOqgSyO2NmWVHEn6jsiwo7VKX42iKzh70= +sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM= diff --git a/package-lock.json b/package-lock.json index 339025e67e..aca4206551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "workspaces": [ "packages/expo-two-way-audio", "packages/highlight", + "packages/plugin", "packages/protocol", "packages/client", "packages/server", @@ -9583,6 +9584,10 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@paseo/plugin": { + "resolved": "packages/plugin", + "link": true + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -36144,6 +36149,7 @@ "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", "@mattermost/react-native-paste-input": "2.0.1", + "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", @@ -38607,6 +38613,22 @@ "vitest": "^4.1.6" } }, + "packages/plugin": { + "name": "@paseo/plugin", + "version": "0.3.0", + "devDependencies": { + "@types/node": "^20.9.0", + "@types/react": "~19.2.0", + "react": "19.1.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6", + "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "19.1.0", + "zod": "^4.4.3" + } + }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.4.0", @@ -38845,6 +38867,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", + "@babel/parser": "^7.29.8", "@getpaseo/client": "0.4.0", "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", @@ -38854,11 +38877,13 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@paseo/plugin": "*", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", @@ -39022,6 +39047,468 @@ "win32" ] }, + "packages/server/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/server/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "packages/server/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/server/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "packages/server/node_modules/@isaacs/ttlcache": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", @@ -39176,6 +39663,47 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "packages/server/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "packages/server/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", diff --git a/package.json b/package.json index ff7e882506..eee6171ca6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "workspaces": [ "packages/expo-two-way-audio", "packages/highlight", + "packages/plugin", "packages/protocol", "packages/client", "packages/server", @@ -49,6 +50,8 @@ "build": "npm run build --workspaces --if-present", "build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight", + "build:plugin": "npm run build --workspace=@paseo/plugin", + "build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin", "build:relay": "npm run build --workspace=@getpaseo/relay", "build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay", "build:protocol": "npm run build --workspace=@getpaseo/protocol", @@ -58,13 +61,13 @@ "build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract", "build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper", "build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime", - "build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"", - "build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", + "build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"", + "build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", - "build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", - "build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", + "build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", + "build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", "watch:protocol": "npm run watch --workspace=@getpaseo/protocol", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "typecheck": "npm run typecheck --workspaces --if-present", diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 528a9343f0..ed2bc794a9 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { selectModel } from "../support/helpers/app"; +import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; +import { + expectResumeOverflowFallsBackToOneTail, + rememberTimelineRequestCounts, +} from "../support/helpers/timeline-resume"; +import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui"; import { scrollTimelineToNewestLoadedEdge, scrollTimelineUntilOlderHistoryIsReachable, @@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect( } } +async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const subscriptions = observeTimelineSubscriptions(page); + const gate = await installDaemonWebSocketGate(page); + const target = await seedMockAgentWorkspace({ + repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`, + title: "Hidden streaming submission", + model: "ten-second-stream", + }); + const evictionAgents = await Promise.all( + Array.from({ length: 3 }, (_unused, index) => + seedMockAgentWorkspace({ + repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`, + title: `Workspace eviction ${index + 1}`, + }), + ), + ); + const prompt = "Keep this hidden image prompt before its streaming output."; + const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId); + + try { + await openAgentRoute(page, target); + await expectComposerVisible(page); + await subscriptions.waitForSubscribedAgents([target.agentId]); + + const userMessageCount = gate.getAgentStreamItemCount("user_message"); + gate.setAgentStreamSuppressed(true); + const promptRow = await submitMessageWithImage(page, prompt); + await gate.waitForAgentStreamItem("user_message", userMessageCount + 1); + + for (const evictionAgent of evictionAgents) { + await openAgentRoute(page, evictionAgent); + await expectComposerVisible(page); + } + await expect(targetDeckEntry).toHaveCount(0); + await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]); + gate.setAgentStreamSuppressed(false); + + await target.client.waitForFinish(target.agentId, 30_000); + const requestsBeforeReturn = rememberTimelineRequestCounts(gate); + await openAgentRoute(page, target); + await expectComposerVisible(page); + await subscriptions.waitForSubscribedAgents([target.agentId]); + + const response = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(response).toBeVisible(); + await expectRenderedBefore(promptRow, response); + expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn); + } finally { + gate.setAgentStreamSuppressed(false); + gate.restore(); + await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]); + } +} + async function expectCompletedSubmissionClearsAfterMissedRunningTransition( page: Page, testInfo: { workerIndex: number }, @@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => { await expectInterruptedTurnOrderAfterReconnect(page, testInfo); }); + test("keeps a streaming hidden submission before its output after workspace eviction", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo); + }); + test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({ page, }, testInfo) => { diff --git a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts index 72e7491dee..af6b0009cd 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -26,9 +26,41 @@ import { expectTimelineViewportAnchoredAfterPrepend, userNavigatesTimelineToHistoryStartWithKeyboard, userScrollsTimelineToHistoryStart, + waitForPersistedCanonicalTimelineRange, } from "../support/helpers/timeline-pagination"; +import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate"; +import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; test.describe("Agent timeline pagination", () => { + test("resumes a persisted canonical timeline after its exact end", async ({ page }) => { + const prompt = "timeline persisted range resumes without tail replay"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-persisted-range-", + title: "Persisted timeline range", + initialPrompt: prompt, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, prompt); + const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId); + + const tracker = await trackAgentTimelineRequests(page, agent.agentId); + await page.reload(); + await expectTimelinePromptVisible(page, prompt); + + const request = await tracker.nextRequest(); + expect(request).toEqual({ + direction: "after", + cursor: { epoch: range.epoch, seq: range.endSeq }, + }); + await tracker.waitForResponse(); + expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false); + } finally { + await agent.cleanup(); + } + }); + test("keeps a fixed history-start gutter before, during, and after pagination", async ({ page, }) => { diff --git a/packages/app/e2e/browser/agent-timeline-resume.spec.ts b/packages/app/e2e/browser/agent-timeline-resume.spec.ts index 7691f65434..8a093feffc 100644 --- a/packages/app/e2e/browser/agent-timeline-resume.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-resume.spec.ts @@ -13,9 +13,10 @@ import { import { commitTimelineTurnsWhileDisconnected, disconnectViewedTimeline, - expectOneTailCheckWithoutCatchUpPages, + expectOneResumeCheckWithoutTail, + expectResumeOverflowFallsBackToOneTail, rememberTimelineRequestCounts, - restoreViewedTimelineWithHeldTail, + restoreViewedTimelineWithHeldResponse, } from "../support/helpers/timeline-resume"; test.describe("Agent timeline resume", () => { @@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => { const requests = rememberTimelineRequestCounts(gate); await disconnectViewedTimeline(page, gate); const background = await commitTimelineTurnsWhileDisconnected(agent, 24); - await restoreViewedTimelineWithHeldTail(page, gate); + await restoreViewedTimelineWithHeldResponse(page, gate); await expectTimelinePromptVisible(page, background.newestPrompt); await expectTimelinePromptNotMounted(page, background.firstPrompt); - expectOneTailCheckWithoutCatchUpPages(gate, requests); + expectResumeOverflowFallsBackToOneTail(gate, requests); await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt); } finally { gate.restore(); @@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => { const requests = rememberTimelineRequestCounts(gate); await disconnectViewedTimeline(page, gate); - await restoreViewedTimelineWithHeldTail(page, gate); + await restoreViewedTimelineWithHeldResponse(page, gate); await expectTimelinePresentationUnchanged(page, presentation); - expectOneTailCheckWithoutCatchUpPages(gate, requests); + expectOneResumeCheckWithoutTail(gate, requests); } finally { gate.restore(); await agent.cleanup(); diff --git a/packages/app/e2e/browser/archive-finished-subagents.spec.ts b/packages/app/e2e/browser/archive-finished-subagents.spec.ts new file mode 100644 index 0000000000..9f7b42a4a6 --- /dev/null +++ b/packages/app/e2e/browser/archive-finished-subagents.spec.ts @@ -0,0 +1,79 @@ +import { test } from "../support/fixtures"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + archiveFinishedSubagents, + expectArchiveFinishedInProgress, + expectArchiveFinishedRetry, + expectManagedSubagentArchived, + expectManagedSubagentUnarchived, + expectSubagentRowGone, + expectSubagentRowVisible, + holdManagedSubagentArchiveRequest, + openSubagentsTrack, + rejectNextManagedSubagentArchiveRequest, + seedParentWithSubagent, +} from "../support/helpers/subagents"; + +test.describe("Archive finished subagents", () => { + let workspace: SeededWorkspace; + + test.beforeAll(async () => { + workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" }); + }); + + test.afterAll(async () => { + await workspace?.cleanup(); + }); + + test("archives a finished managed child from the track", async ({ page }) => { + const agents = await seedParentWithSubagent(workspace, { + parentTitle: "Archive parent", + childTitle: "Finished child", + }); + await workspace.client.waitForAgentUpsert( + agents.child.id, + (snapshot) => snapshot.status === "idle", + ); + + const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id); + await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id }); + await openSubagentsTrack(page); + await expectSubagentRowVisible(page, agents.child.id); + + await archiveFinishedSubagents(page); + await archiveGate.waitForRequest(); + + await expectSubagentRowGone(page, agents.child.id); + await expectArchiveFinishedInProgress(page, 0, 1); + await expectManagedSubagentUnarchived(workspace, agents.child.id); + archiveGate.release(); + await expectManagedSubagentArchived(workspace, agents.child.id); + }); + + test("restores a failed child and retries the archive", async ({ page }) => { + const agents = await seedParentWithSubagent(workspace, { + parentTitle: "Retry parent", + childTitle: "Retry child", + }); + await workspace.client.waitForAgentUpsert( + agents.child.id, + (snapshot) => snapshot.status === "idle", + ); + + const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id); + await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id }); + await openSubagentsTrack(page); + await expectSubagentRowVisible(page, agents.child.id); + + await archiveFinishedSubagents(page); + await rejection.waitForRejection(); + + await expectSubagentRowVisible(page, agents.child.id); + await expectArchiveFinishedRetry(page, 1, 1); + await archiveFinishedSubagents(page); + + await expectSubagentRowGone(page, agents.child.id); + await expectManagedSubagentArchived(workspace, agents.child.id); + }); +}); diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts new file mode 100644 index 0000000000..da4b042d42 --- /dev/null +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -0,0 +1,209 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { + expectSettingsHeader, + openHostSection, + openSettingsHost, +} from "../support/helpers/settings"; + +function observePluginCatalog(page: Page) { + let responses = 0; + page.on("websocket", (socket) => { + socket.on("framereceived", ({ payload }) => { + if (typeof payload !== "string") return; + try { + const envelope = JSON.parse(payload) as { + type?: unknown; + message?: { type?: unknown }; + }; + const message = envelope.type === "session" ? envelope.message : envelope; + if (message?.type === "plugin.catalog.get.response") responses += 1; + } catch { + return; + } + }); + }); + return { + waitForInitialFetch: async () => { + await expect.poll(() => responses).toBeGreaterThan(0); + }, + }; +} + +function pluginSource(title: string, renderError?: string): string { + return `import React from "react"; +import { Text } from "react-native"; +export default function contribute(plugin) { + function Surface() { + ${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""} + const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0"); + return ${title} cleanup {cleanups}; + } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" }); + ${ + renderError + ? `function HealthySurface() { return Healthy contribution; } + plugin.addSurface("healthy", HealthySurface); + plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });` + : "" + } + return () => { + const storage = globalThis.sessionStorage; + if (storage) { + const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0"); + storage.setItem("paseo-plugin-cleanups", String(cleanups + 1)); + } + }; +}`; +} + +async function openPluginSettings(page: Page): Promise { + const serverId = getServerId(); + await openSettings(page); + await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "plugins"); + await expectSettingsHeader(page, "Plugins"); +} + +async function reloadPlugin(page: Page): Promise { + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible(); +} + +async function expectContributionBody(page: Page, expectedBody: string): Promise { + const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true }); + await expect(body).toHaveCount(1); + await expect(body).toBeVisible(); +} + +async function openContribution(page: Page, title: string, expectedBody: string): Promise { + await page.getByRole("button", { name: title, exact: true }).click(); + await expectContributionBody(page, expectedBody); +} + +async function leavePluginSettings(page: Page): Promise { + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page).not.toHaveURL(/\/settings\//); +} + +async function openContributionFromSettings( + page: Page, + title: string, + expectedBody: string, +): Promise { + await leavePluginSettings(page); + await openContribution(page, title, expectedBody); +} + +async function reloadActiveContribution( + page: Page, + client: Awaited>, + expectedBody: string, +): Promise { + await client.reloadPlugin("e2e-plugin"); + await expectContributionBody(page, expectedBody); +} + +async function expectContributionRemoved(page: Page, title: string): Promise { + await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible(); +} + +test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({ + page, +}) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-")); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" })); + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1")); + + try { + const catalog = observePluginCatalog(page); + await gotoAppShell(page); + await openPluginSettings(page); + await catalog.waitForInitialFetch(); + await client.patchDaemonConfig({ pluginsEnabled: false }); + await page.getByRole("switch", { name: "Enable plugins" }).click(); + await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible(); + await page.getByLabel("Plugin directory").fill(directory); + await page.getByLabel("Plugin installation ID").focus(); + await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin"); + await page.getByRole("button", { name: "Install directory" }).click(); + await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0"); + + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2")); + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2"); + await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible(); + + await writeFile( + path.join(directory, "index.tsx"), + pluginSource("Broken surface", "render exploded"), + ); + await openPluginSettings(page); + await reloadPlugin(page); + await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded"); + await openContribution(page, "Healthy surface", "Healthy contribution"); + await openContribution(page, "Broken surface", "Plugin failed: render exploded"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface")); + await reloadActiveContribution(page, client, "Recovered surface cleanup 4"); + await expect( + page.getByRole("button", { name: "Broken surface", exact: true }), + ).not.toBeVisible(); + + await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await openPluginSettings(page); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed"); + await expect(page.getByLabel("e2e-plugin failed")).toBeVisible(); + await leavePluginSettings(page); + await expectContributionRemoved(page, "Recovered surface"); + + await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3")); + await openPluginSettings(page); + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5"); + + await openPluginSettings(page); + await page.getByRole("button", { name: "Disable", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible(); + await leavePluginSettings(page); + await expectContributionRemoved(page, "Plugin v3"); + + await openPluginSettings(page); + await page.getByRole("button", { name: "Enable", exact: true }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6"); + + await openPluginSettings(page); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Remove", exact: true }).click(); + await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible(); + + await page.getByLabel("Plugin directory").fill(directory); + await page.getByRole("button", { name: "Install directory" }).click(); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await gotoAppShell(page); + await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7"); + } finally { + await client.removePlugin("e2e-plugin").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await client.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index ae5b883aaf..eca355698b 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker { requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>; } +export interface TimelineRequestTracker { + nextRequest(): Promise<{ + direction: string | null; + cursor: { epoch: string; seq: number } | null; + }>; + requests(): Array<{ + direction: string | null; + cursor: { epoch: string; seq: number } | null; + }>; + waitForResponse(): Promise; +} + +export async function trackAgentTimelineRequests( + page: Page, + agentId: string, +): Promise { + type Request = ReturnType[number]; + const seen: Request[] = []; + const waiters: Array<(request: Request) => void> = []; + let responseSeen = false; + let resolveResponse: (() => void) | null = null; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if ( + sessionMessage?.type === "fetch_agent_timeline_request" && + sessionMessage.agentId === agentId + ) { + const rawCursor = sessionMessage.cursor; + const cursor = + rawCursor && + typeof rawCursor === "object" && + typeof (rawCursor as { epoch?: unknown }).epoch === "string" && + typeof (rawCursor as { seq?: unknown }).seq === "number" + ? { + epoch: (rawCursor as { epoch: string }).epoch, + seq: (rawCursor as { seq: number }).seq, + } + : null; + const request = { + direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null, + cursor, + }; + seen.push(request); + waiters.shift()?.(request); + } + server.send(message); + }); + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if ( + sessionMessage?.type === "fetch_agent_timeline_response" && + payload?.agentId === agentId + ) { + responseSeen = true; + resolveResponse?.(); + } + ws.send(message); + }); + }); + return { + nextRequest() { + const request = seen[0]; + if (request) return Promise.resolve(request); + return new Promise((resolve) => waiters.push(resolve)); + }, + requests: () => [...seen], + waitForResponse: () => (responseSeen ? Promise.resolve() : response), + }; +} + export async function trackPromptJumpRequests( page: Page, agentId: string, diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 2663557217..bc03bbe381 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick< | "on" | "patchDaemonConfig" | "removeProject" + | "removePlugin" + | "reloadPlugin" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/e2e/support/helpers/replica-cache-storage.ts b/packages/app/e2e/support/helpers/replica-cache-storage.ts index 8d42be348d..78df9b5389 100644 --- a/packages/app/e2e/support/helpers/replica-cache-storage.ts +++ b/packages/app/e2e/support/helpers/replica-cache-storage.ts @@ -5,10 +5,16 @@ const STORE_NAME = "key-value"; const STORAGE_KEY = "@paseo:replica-cache"; export interface ReplicaCacheRecord { + version?: number; hosts?: Array<{ timeline?: { agentId?: string; items?: Array>; + range?: { + endSeq?: number; + epoch?: string; + startSeq?: number; + } | null; } | null; }>; } diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 2af795375b..43c0579d1d 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -41,6 +41,7 @@ type HostSection = | "providers" | "usage" | "terminals" + | "plugins" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index b707973298..68fd5f7324 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -1,7 +1,19 @@ import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { expect, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; import type { SeededWorkspace } from "./seed-client"; +type WebSocketMessage = string | Buffer; + +export interface ManagedSubagentArchiveGate { + release(): void; + waitForRequest(): Promise; +} + +export interface ManagedSubagentArchiveRejection { + waitForRejection(): Promise; +} + export interface SeededSubagentPair { parent: { id: string; @@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis }); } +export async function expectManagedSubagentUnarchived( + workspace: Pick, + childId: string, +): Promise { + await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({ + agent: { id: childId, archivedAt: null }, + }); +} + +export async function expectManagedSubagentArchived( + workspace: Pick, + childId: string, +): Promise { + await expect + .poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 }) + .toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } }); +} + +export async function archiveFinishedSubagents(page: Page): Promise { + await page.getByRole("button", { name: "Archive finished subagents" }).click(); +} + +export async function expectArchiveFinishedInProgress( + page: Page, + completed: number, + total: number, +): Promise { + await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled(); + await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible(); +} + +export async function expectArchiveFinishedRetry( + page: Page, + failed: number, + total: number, +): Promise { + await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled(); +} + +export async function holdManagedSubagentArchiveRequest( + page: Page, + subagentId: string, +): Promise { + let released = false; + let resolveRequest: (() => void) | undefined; + const delayedForwards: Array<() => void> = []; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + if (isArchiveRequestForSubagent(message, subagentId) && !released) { + delayedForwards.push(() => server.send(message)); + resolveRequest?.(); + return; + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); + + return { + release() { + released = true; + for (const forward of delayedForwards.splice(0)) forward(); + }, + waitForRequest: () => request, + }; +} + +export async function rejectNextManagedSubagentArchiveRequest( + page: Page, + subagentId: string, +): Promise { + let resolveRejection: (() => void) | undefined; + const rejection = new Promise((resolve) => { + resolveRejection = resolve; + }); + let rejected = false; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const requestId = archiveRequestId(message, subagentId); + if (requestId && !rejected) { + rejected = true; + // A daemon-rejected archive RPC: the request never reaches the daemon, and its normal + // correlated error travels back through the real browser client and mutation rollback. + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "archive_agent_request", + error: "Archive rejected for test", + code: "archive_rejected", + }, + }, + }), + ); + resolveRejection?.(); + return; + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); + + return { waitForRejection: () => rejection }; +} + +function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean { + return archiveRequestId(message, subagentId) !== null; +} + +function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null { + const rawMessage = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(rawMessage) as { + type?: unknown; + message?: { type?: unknown; agentId?: unknown; requestId?: unknown }; + }; + if ( + envelope.type === "session" && + envelope.message?.type === "archive_agent_request" && + envelope.message.agentId === subagentId && + typeof envelope.message.requestId === "string" + ) { + return envelope.message.requestId; + } + return null; + } catch { + return null; + } +} + export async function detachSubagentFromTrack(page: Page, childId: string): Promise { const row = page.getByTestId(`subagents-track-row-${childId}`); await expect(row).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index f7c9722955..cf02760e96 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -40,6 +40,12 @@ interface TimelineViewportSnapshot { scrollTop: number; } +interface PersistedCanonicalTimelineRange { + epoch: string; + startSeq: number; + endSeq: number; +} + export interface TimelinePresentationSnapshot { marker: string; position: TimelinePromptPositionSnapshot; @@ -298,6 +304,32 @@ export async function reloadAgentTimelineFromPersistedReplica( await expectTimelinePromptVisible(page, agent.newestPrompt); } +export async function waitForPersistedCanonicalTimelineRange( + page: Page, + agentId: string, +): Promise { + const readRange = async () => { + const cache = await readReplicaCache(page); + if (cache?.version !== 6) return null; + const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range; + if ( + typeof range?.epoch !== "string" || + typeof range.startSeq !== "number" || + typeof range.endSeq !== "number" + ) { + return null; + } + return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }; + }; + + await expect.poll(readRange).not.toBeNull(); + const range = await readRange(); + if (!range) { + throw new Error(`Persisted canonical timeline range is missing for ${agentId}`); + } + return range; +} + export async function holdNextOlderTimelinePage( page: Page, agent: LongTimelineAgent, diff --git a/packages/app/e2e/support/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts index c4a44713c7..73c921776c 100644 --- a/packages/app/e2e/support/helpers/timeline-resume.ts +++ b/packages/app/e2e/support/helpers/timeline-resume.ts @@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli }; } -export function expectOneTailCheckWithoutCatchUpPages( +export function expectOneResumeCheckWithoutTail( gate: DaemonWebSocketGate, before: TimelineRequestCounts, ): void { + expect(gate.getTimelineRequestCount("after") - before.after).toBe(1); + expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0); +} + +export function expectResumeOverflowFallsBackToOneTail( + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): void { + expect(gate.getTimelineRequestCount("after") - before.after).toBe(1); expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); - expect(gate.getTimelineRequestCount("after") - before.after).toBe(0); } export async function disconnectViewedTimeline( @@ -56,7 +64,7 @@ export async function disconnectViewedTimeline( await expectReconnectingToastVisible(page); } -export async function restoreViewedTimelineWithHeldTail( +export async function restoreViewedTimelineWithHeldResponse( page: Page, gate: DaemonWebSocketGate, ): Promise { diff --git a/packages/app/package.json b/packages/app/package.json index 89e183cf6a..e92d61d149 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -49,6 +49,7 @@ "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", "@mattermost/react-native-paste-input": "2.0.1", + "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-masked-view/masked-view": "^0.3.2", "@react-native/normalize-colors": "^0.81.5", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 431d1cc66d..d4b6d9b482 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -127,6 +127,7 @@ import { } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { PluginCatalogSync } from "@/plugins"; import { ensureOsNotificationPermission, WEB_NOTIFICATION_CLICK_EVENT, @@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( + ); } diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 4cd3428e08..b40299f5e0 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -41,6 +41,7 @@ function KnownHostRoute() { + ); diff --git a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx new file mode 100644 index 0000000000..c92e97c3aa --- /dev/null +++ b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx @@ -0,0 +1,5 @@ +import { PluginSurfaceScreen } from "@/plugins"; + +export default function PluginSurfaceRoute() { + return ; +} diff --git a/packages/app/src/attachments/attachment-pill-content.test.tsx b/packages/app/src/attachments/attachment-pill-content.test.tsx new file mode 100644 index 0000000000..bfd33a3294 --- /dev/null +++ b/packages/app/src/attachments/attachment-pill-content.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { getAgentAttachmentPillContent } from "./attachment-pill-content"; + +describe("agent attachment pill content", () => { + it("presents external resources with their provider identity", () => { + const content = getAgentAttachmentPillContent( + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + i18n.t, + ); + + expect(content.title).toBe("Plugin attachments"); + expect(content.subtitle).toBe("Linear issue ENG-123"); + }); +}); diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx index 8acc585fb5..ed37c0d787 100644 --- a/packages/app/src/attachments/attachment-pill-content.tsx +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { TFunction } from "i18next"; +import React from "react"; import { CircleDot, FileText, @@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent( subtitle: `Issue #${attachment.number}`, }; case "text": + if (attachment.externalResource) { + return { + icon: attachmentGithubIssueIcon, + title: attachment.externalResource.title, + subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`, + }; + } return { icon: attachmentFileIcon, title: attachment.title ?? t("message.attachments.textAttachment"), diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 7e53567b23..1d953c3906 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -3,6 +3,7 @@ import type { ForgeSearchItem, UploadedFileAttachment, } from "@getpaseo/protocol/messages"; +import type { PluginResourceComposerAttachment } from "@/plugins/attachments"; export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file"; @@ -106,6 +107,7 @@ export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } | WorkspaceFileComposerAttachment + | PluginResourceComposerAttachment | { kind: "forge_issue"; item: ForgeSearchItem } | { kind: "forge_change_request"; item: ForgeSearchItem } // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 9c0b2dd182..6e97175cb1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; +import { PluginSidebarItems } from "@/plugins"; type SidebarTheme = ReturnType["theme"]; @@ -681,6 +682,7 @@ function MobileSidebar({ testID="sidebar-schedules" variant="compact" /> + + diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 743f5ebffe..4e66d8ef21 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -863,6 +863,35 @@ describe("openComposerAttachment", () => { }); expect(externalUrlCalls).toEqual([issueItem.url]); }); + + it("opens plugin resource URLs through the external url opener", () => { + const externalUrlCalls: string[] = []; + openComposerAttachment({ + attachment: { + kind: "plugin_resource", + pluginId: "linear", + sourceId: "issues", + sourceTitle: "Linear issue", + sourceIcon: "CircleDot", + item: { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + text: "Linear issue ENG-123: Plugin attachments", + resourceType: "issue", + }, + }, + setLightboxMetadata: () => { + throw new Error("unexpected lightbox call"); + }, + openWorkspaceAttachment: () => false, + openExternalUrl: (url) => { + externalUrlCalls.push(url); + }, + }); + expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]); + }); }); describe("toggleGithubAttachment", () => { diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index c1c15bb80c..cedce553bd 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -10,6 +10,7 @@ import { buildLegacyGitHubAttachmentFromSearchItem, } from "@/utils/review-attachments"; import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file"; +import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments"; export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github"; @@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit( continue; } + if (attachment.kind === "plugin_resource") { + agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment)); + continue; + } + if (isWorkspaceAttachment(attachment)) { if (attachment.kind === "browser_element" && attachment.attachment.screenshot) { images.push(attachment.attachment.screenshot); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 3f6e47db4c..975b30785d 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -127,6 +127,7 @@ import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { readClipboardImage } from "./clipboard-image"; import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image"; +import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { appendWorkspaceFileAttachment, @@ -442,6 +443,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R onRemove, }); } + if (attachment.kind === "plugin_resource") { + return ( + + ); + } return ( (null); const attachButtonRef = useRef(null); const messageInputRef = useRef(null); + const pluginAttachments = usePluginAttachmentPicker({ + serverId, + client, + connected: isConnected, + attachments, + onChangeAttachments: setSelectedAttachments, + anchorRef: attachButtonRef, + }); const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading); const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, @@ -1960,6 +1983,7 @@ export function Composer({ setIsGithubPickerOpen(true); }, }, + ...pluginAttachments.menuItems, { id: "file", label: t("composer.attachments.addFile"), @@ -1970,7 +1994,14 @@ export function Composer({ }, ); return items; - }, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]); + }, [ + forgePresentation, + handlePasteImage, + handlePickFile, + handlePickImage, + pluginAttachments.menuItems, + t, + ]); const handleToggleGithubItem = useCallback( (item: ForgeSearchItem) => { @@ -2230,6 +2261,7 @@ export function Composer({ emptyText={githubEmptyText} renderOption={renderGithubPickerOption} /> + {pluginAttachments.picker} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 2de5bc5236..6cf80708e9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -19,7 +19,10 @@ import { type TimelineReducerSideEffect, } from "@/timeline/session-stream-reducers"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { isTimelineResumeSnapshotAuthoritative } from "@/timeline/timeline-sync-plan"; +import { + isTimelineResumeSnapshotAuthoritative, + planTimelineTailFetch, +} from "@/timeline/timeline-sync-plan"; import { createViewedTimelineSync, type TimelineDeliveryMode, @@ -33,6 +36,7 @@ import { type AgentAttentionNotificationPayload, type NotificationPermissionRequest, } from "@getpaseo/protocol/agent-attention-notification"; + import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; import type { GitSetupOptions } from "@getpaseo/protocol/messages"; @@ -67,6 +71,20 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache" import { useProviderSubagentStore } from "@/subagents/provider-store"; import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; +type TimelineResponsePayload = Extract< + SessionOutboundMessage, + { type: "fetch_agent_timeline_response" } +>["payload"]; + +function consumeForcedTimelineTailReplacement( + payload: TimelineResponsePayload, + replacements: Set, +): TimelineResponsePayload { + if (payload.direction !== "tail") return payload; + if (!replacements.delete(payload.agentId)) return payload; + return { ...payload, reset: true }; +} + // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; export type { @@ -350,6 +368,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); + const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -528,12 +547,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); const applyTimelineResponse = useCallback( - ( - payload: Extract< - SessionOutboundMessage, - { type: "fetch_agent_timeline_response" } - >["payload"], - ) => { + (receivedPayload: TimelineResponsePayload) => { + const payload = consumeForcedTimelineTailReplacement( + receivedPayload, + forcedTimelineTailReplacements.current, + ); const agentId = payload.agentId; const initKey = getInitKey(serverId, agentId); const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({ @@ -642,6 +660,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const sync = createViewedTimelineSync({ initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + readCursor: (agentId) => { + const timeline = selectAgentTimelineState( + useSessionStore.getState().sessions[serverId], + agentId, + ); + return timeline.status === "synced" && timeline.range + ? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq } + : undefined; + }, fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); @@ -672,6 +699,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, + fetchLatestTail: async (agentId) => { + forcedTimelineTailReplacements.current.add(agentId); + try { + return await getHostRuntimeStore().fetchAgentTimeline( + serverId, + agentId, + planTimelineTailFetch(), + ); + } finally { + forcedTimelineTailReplacements.current.delete(agentId); + } + }, reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 003323719d..e1e28ee3b7 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -4,6 +4,7 @@ import { useQueries, useQuery, type QueryKey, + type QueryClient, type UseQueryOptions, type UseQueryResult, } from "@tanstack/react-query"; @@ -47,8 +48,11 @@ export function useFetchQuery< TError = Error, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(input: FetchQueryInput): UseQueryResult { - return useQuery(fetchQueryOptions(input)); +>( + input: FetchQueryInput, + queryClient?: QueryClient, +): UseQueryResult { + return useQuery(fetchQueryOptions(input), queryClient); } export function useFetchQueries( diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index a4a918de59..8786e3ac07 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -49,7 +49,7 @@ afterEach(() => { }); describe("ensureAgentIsInitialized", () => { - it("requests a bounded projected tail when authoritative history is loaded", () => { + it("catches up after loaded authoritative history", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -74,13 +74,14 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, request: { - direction: "tail", + direction: "after", + cursor: { epoch: "epoch-1", seq: 42 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", }, }, ]); - expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); + expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); }); it("requests a bounded projected tail when no authoritative cursor is available", () => { @@ -127,6 +128,8 @@ describe("ensureAgentIsInitialized", () => { timestamp: new Date("2026-07-27T10:00:00.000Z"), }, ], + range: null, + hasOlder: false, }, }); @@ -151,6 +154,52 @@ describe("ensureAgentIsInitialized", () => { ]); }); + it("catches up after the restored canonical replica range", () => { + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); + useSessionStore.getState().restoreSessionReplica(serverId, { + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + timeline: { + agentId, + items: [ + { + kind: "assistant_message", + id: "canonical-item", + text: "Canonical before restart", + timelineCursor: { epoch: "epoch-1", seq: 12 }, + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }, + ], + range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 }, + hasOlder: true, + }, + }); + + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + runtime, + setAgentInitializing: bindSetAgentInitializing(), + }); + + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "after", + cursor: { epoch: "epoch-1", seq: 12 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); + expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); + }); + it("times out initialization after 65 seconds", async () => { vi.useFakeTimers(); const client = new FakeDaemonClient(); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index c44621c0f0..a19f36bf20 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore } from "@/stores/session-store"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import { createInitDeferred, getInitDeferred, @@ -11,7 +11,7 @@ import { refreshInitTimeout, } from "@/utils/agent-initialization"; import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; -import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; +import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; @@ -51,7 +51,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return existing.promise; } - const timelineRequest = planTimelineTailFetch(); + const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId); + const timelineRequest = planTimelineResumeFetch( + timeline.status === "synced" ? timeline.range : null, + ); const deferred = createInitDeferred(key, timelineRequest.direction); refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b57546459e..98038fe27e 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { common: { @@ -1620,6 +1621,7 @@ export const ar: TranslationResources = { archiveTooltip: "أرشفة الوكيل الفرعي", archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", archiveFinishedTooltip: "أرشفة المكتملين", + archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})", }, panels: { draft: { @@ -1788,8 +1790,10 @@ export const ar: TranslationResources = { providers: "مقدمي الخدمات", usage: "الاستخدام", terminals: "Terminals", + plugins: "Plugins", host: "نظرة عامة", }, + plugins: pluginSettings.ar, metadataGeneration: { title: "إنشاء البيانات الوصفية", description: diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f93e57b33b..50502c43ef 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1631,6 +1631,7 @@ export const en = { archiveTooltip: "Archive subagent", archiveFinishedAction: "Archive finished subagents", archiveFinishedTooltip: "Archive finished", + archiveFinishedRetry: "Retry ({{failed}}/{{total}})", }, panels: { draft: { @@ -1799,8 +1800,56 @@ export const en = { providers: "Providers", usage: "Usage", terminals: "Terminals", + plugins: "Plugins", host: "Overview", }, + plugins: { + title: "Plugins", + trustedTitle: "Plugins are trusted code", + trustedDescription: + "They run on this host and inside the app without sandboxing. Install only code you trust.", + globalTitle: "Enable plugins", + globalHint: "Global switch for every configured plugin", + directoryLabel: "Plugin directory", + directoryPlaceholder: "/absolute/path/on/host", + idLabel: "Plugin installation ID", + idHint: "Leave blank to use paseo-plugin.json", + idPlaceholder: "Manifest default", + install: "Install directory", + installing: "Installing…", + status: { running: "running", disabled: "disabled", failed: "failed" }, + actions: { + reload: "Reload", + reloading: "Reloading…", + enable: "Enable", + enabling: "Enabling…", + disable: "Disable", + disabling: "Disabling…", + remove: "Remove", + removing: "Removing…", + }, + feedback: { + installed: "Installed {{id}}", + reload: "Reloaded {{id}}", + enable: "Enabled {{id}}", + disable: "Disabled {{id}}", + remove: "Removed {{id}}", + kept: "Kept {{id}}", + globalEnabled: "Plugins enabled", + globalDisabled: "Plugins disabled", + }, + removeConfirmTitle: "Remove {{id}}?", + removeConfirmMessage: "This removes the configuration. The source directory is not deleted.", + states: { + offlineTitle: "Plugin host is offline", + offlineDescription: "Reconnect to this host to manage its plugins.", + updateTitle: "Update this host to manage plugins", + loading: "Loading plugins…", + errorTitle: "Unable to load plugins", + retry: "Retry", + empty: "No plugins configured", + }, + }, metadataGeneration: { title: "Metadata generation", description: diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8720e61bd6..e88091bbc0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { common: { @@ -1663,6 +1664,7 @@ export const es: TranslationResources = { archiveTooltip: "Subagente de archivo", archiveFinishedAction: "Archivar subagentes finalizados", archiveFinishedTooltip: "Archivar finalizados", + archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})", }, panels: { draft: { @@ -1831,8 +1833,10 @@ export const es: TranslationResources = { providers: "Proveedores", usage: "Uso", terminals: "Terminals", + plugins: "Plugins", host: "Resumen", }, + plugins: pluginSettings.es, metadataGeneration: { title: "Generación de metadatos", description: diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c904e8b659..5ba2a36a76 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { common: { @@ -1667,6 +1668,7 @@ export const fr: TranslationResources = { archiveTooltip: "Sous-agent d'archivage", archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedTooltip: "Archiver les terminés", + archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { draft: { @@ -1835,8 +1837,10 @@ export const fr: TranslationResources = { providers: "Fournisseurs", usage: "Utilisation", terminals: "Terminals", + plugins: "Plugins", host: "Aperçu", }, + plugins: pluginSettings.fr, metadataGeneration: { title: "Génération de métadonnées", description: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e49caf5219..069cb1ff96 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { common: { @@ -1636,6 +1637,7 @@ export const ja: TranslationResources = { archiveTooltip: "サブエージェントをアーカイブ", archiveFinishedAction: "完了したサブエージェントをアーカイブ", archiveFinishedTooltip: "完了した項目をアーカイブ", + archiveFinishedRetry: "再試行 ({{failed}}/{{total}})", }, panels: { draft: { @@ -1804,8 +1806,10 @@ export const ja: TranslationResources = { providers: "プロバイダー", usage: "使用状況", terminals: "ターミナル", + plugins: "プラグイン", host: "概要", }, + plugins: pluginSettings.ja, metadataGeneration: { title: "メタデータ生成", description: diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2b35a0ed1c..80d2c45bc5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { common: { @@ -1630,6 +1631,7 @@ export const ko: TranslationResources = { archiveTooltip: "서브에이전트 보관", archiveFinishedAction: "완료된 하위 에이전트 보관", archiveFinishedTooltip: "아카이브 완료", + archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})", }, panels: { draft: { @@ -1798,8 +1800,10 @@ export const ko: TranslationResources = { providers: "프로바이더", usage: "사용량", terminals: "터미널", + plugins: "플러그인", host: "개요", }, + plugins: pluginSettings.ko, metadataGeneration: { title: "메타데이터 생성", description: diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts new file mode 100644 index 0000000000..9465990625 --- /dev/null +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -0,0 +1,380 @@ +const shared = { + directoryPlaceholder: "/absolute/path/on/host", +} as const; + +export const pluginSettings = { + ar: { + ...shared, + title: "الإضافات", + trustedTitle: "الإضافات تعليمات برمجية موثوقة", + trustedDescription: + "تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.", + globalTitle: "تفعيل الإضافات", + globalHint: "مفتاح عام لكل الإضافات المضبوطة", + directoryLabel: "مجلد الإضافة", + idLabel: "معرّف تثبيت الإضافة", + idHint: "اتركه فارغًا لاستخدام paseo-plugin.json", + idPlaceholder: "القيمة الافتراضية للبيان", + install: "تثبيت المجلد", + installing: "جارٍ التثبيت…", + status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, + actions: { + reload: "إعادة تحميل", + reloading: "جارٍ إعادة التحميل…", + enable: "تفعيل", + enabling: "جارٍ التفعيل…", + disable: "تعطيل", + disabling: "جارٍ التعطيل…", + remove: "إزالة", + removing: "جارٍ الإزالة…", + }, + feedback: { + installed: "تم تثبيت {{id}}", + reload: "تمت إعادة تحميل {{id}}", + enable: "تم تفعيل {{id}}", + disable: "تم تعطيل {{id}}", + remove: "تمت إزالة {{id}}", + kept: "تم الإبقاء على {{id}}", + globalEnabled: "تم تفعيل الإضافات", + globalDisabled: "تم تعطيل الإضافات", + }, + removeConfirmTitle: "إزالة {{id}}؟", + removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.", + states: { + offlineTitle: "مضيف الإضافات غير متصل", + offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.", + updateTitle: "حدّث هذا المضيف لإدارة الإضافات", + loading: "جارٍ تحميل الإضافات…", + errorTitle: "تعذّر تحميل الإضافات", + retry: "إعادة المحاولة", + empty: "لا توجد إضافات مضبوطة", + }, + }, + es: { + ...shared, + title: "Plugins", + trustedTitle: "Los plugins son código de confianza", + trustedDescription: + "Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.", + globalTitle: "Activar plugins", + globalHint: "Interruptor global para todos los plugins configurados", + directoryLabel: "Directorio del plugin", + idLabel: "ID de instalación del plugin", + idHint: "Déjalo vacío para usar paseo-plugin.json", + idPlaceholder: "Valor del manifiesto", + install: "Instalar directorio", + installing: "Instalando…", + status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, + actions: { + reload: "Recargar", + reloading: "Recargando…", + enable: "Activar", + enabling: "Activando…", + disable: "Desactivar", + disabling: "Desactivando…", + remove: "Eliminar", + removing: "Eliminando…", + }, + feedback: { + installed: "{{id}} instalado", + reload: "{{id}} recargado", + enable: "{{id}} activado", + disable: "{{id}} desactivado", + remove: "{{id}} eliminado", + kept: "Se conservó {{id}}", + globalEnabled: "Plugins activados", + globalDisabled: "Plugins desactivados", + }, + removeConfirmTitle: "¿Eliminar {{id}}?", + removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.", + states: { + offlineTitle: "El host de plugins está sin conexión", + offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.", + updateTitle: "Actualiza este host para gestionar plugins", + loading: "Cargando plugins…", + errorTitle: "No se pudieron cargar los plugins", + retry: "Reintentar", + empty: "No hay plugins configurados", + }, + }, + fr: { + ...shared, + title: "Plugins", + trustedTitle: "Les plugins sont du code de confiance", + trustedDescription: + "Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.", + globalTitle: "Activer les plugins", + globalHint: "Interrupteur global pour tous les plugins configurés", + directoryLabel: "Dossier du plugin", + idLabel: "ID d’installation du plugin", + idHint: "Laissez vide pour utiliser paseo-plugin.json", + idPlaceholder: "Valeur du manifeste", + install: "Installer le dossier", + installing: "Installation…", + status: { running: "actif", disabled: "désactivé", failed: "échec" }, + actions: { + reload: "Recharger", + reloading: "Rechargement…", + enable: "Activer", + enabling: "Activation…", + disable: "Désactiver", + disabling: "Désactivation…", + remove: "Supprimer", + removing: "Suppression…", + }, + feedback: { + installed: "{{id}} installé", + reload: "{{id}} rechargé", + enable: "{{id}} activé", + disable: "{{id}} désactivé", + remove: "{{id}} supprimé", + kept: "{{id}} conservé", + globalEnabled: "Plugins activés", + globalDisabled: "Plugins désactivés", + }, + removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", + states: { + offlineTitle: "L’hôte des plugins est hors ligne", + offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.", + updateTitle: "Mettez cet hôte à jour pour gérer les plugins", + loading: "Chargement des plugins…", + errorTitle: "Impossible de charger les plugins", + retry: "Réessayer", + empty: "Aucun plugin configuré", + }, + }, + ja: { + ...shared, + title: "プラグイン", + trustedTitle: "プラグインは信頼済みコードです", + trustedDescription: + "ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。", + globalTitle: "プラグインを有効化", + globalHint: "設定済みプラグインすべての共通スイッチ", + directoryLabel: "プラグインディレクトリ", + idLabel: "プラグインのインストール ID", + idHint: "paseo-plugin.json を使う場合は空欄にします", + idPlaceholder: "マニフェストの既定値", + install: "ディレクトリをインストール", + installing: "インストール中…", + status: { running: "実行中", disabled: "無効", failed: "失敗" }, + actions: { + reload: "再読み込み", + reloading: "再読み込み中…", + enable: "有効化", + enabling: "有効化中…", + disable: "無効化", + disabling: "無効化中…", + remove: "削除", + removing: "削除中…", + }, + feedback: { + installed: "{{id}} をインストールしました", + reload: "{{id}} を再読み込みしました", + enable: "{{id}} を有効にしました", + disable: "{{id}} を無効にしました", + remove: "{{id}} を削除しました", + kept: "{{id}} を保持しました", + globalEnabled: "プラグインを有効にしました", + globalDisabled: "プラグインを無効にしました", + }, + removeConfirmTitle: "{{id}} を削除しますか?", + removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。", + states: { + offlineTitle: "プラグインホストはオフラインです", + offlineDescription: "プラグインを管理するにはホストに再接続してください。", + updateTitle: "プラグインを管理するにはホストを更新してください", + loading: "プラグインを読み込み中…", + errorTitle: "プラグインを読み込めません", + retry: "再試行", + empty: "設定済みプラグインはありません", + }, + }, + ko: { + ...shared, + title: "플러그인", + trustedTitle: "플러그인은 신뢰하는 코드입니다", + trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", + globalTitle: "플러그인 활성화", + globalHint: "설정된 모든 플러그인의 전역 스위치", + directoryLabel: "플러그인 디렉터리", + idLabel: "플러그인 설치 ID", + idHint: "paseo-plugin.json을 사용하려면 비워 두세요", + idPlaceholder: "매니페스트 기본값", + install: "디렉터리 설치", + installing: "설치 중…", + status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, + actions: { + reload: "다시 로드", + reloading: "다시 로드 중…", + enable: "활성화", + enabling: "활성화 중…", + disable: "비활성화", + disabling: "비활성화 중…", + remove: "제거", + removing: "제거 중…", + }, + feedback: { + installed: "{{id}} 설치됨", + reload: "{{id}} 다시 로드됨", + enable: "{{id}} 활성화됨", + disable: "{{id}} 비활성화됨", + remove: "{{id}} 제거됨", + kept: "{{id}} 유지됨", + globalEnabled: "플러그인 활성화됨", + globalDisabled: "플러그인 비활성화됨", + }, + removeConfirmTitle: "{{id}}을(를) 제거할까요?", + removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.", + states: { + offlineTitle: "플러그인 호스트가 오프라인입니다", + offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.", + updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요", + loading: "플러그인 로드 중…", + errorTitle: "플러그인을 로드할 수 없습니다", + retry: "다시 시도", + empty: "설정된 플러그인이 없습니다", + }, + }, + "pt-BR": { + ...shared, + title: "Plugins", + trustedTitle: "Plugins são código confiável", + trustedDescription: + "Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.", + globalTitle: "Ativar plugins", + globalHint: "Controle global para todos os plugins configurados", + directoryLabel: "Diretório do plugin", + idLabel: "ID de instalação do plugin", + idHint: "Deixe vazio para usar paseo-plugin.json", + idPlaceholder: "Padrão do manifesto", + install: "Instalar diretório", + installing: "Instalando…", + status: { running: "em execução", disabled: "desativado", failed: "falhou" }, + actions: { + reload: "Recarregar", + reloading: "Recarregando…", + enable: "Ativar", + enabling: "Ativando…", + disable: "Desativar", + disabling: "Desativando…", + remove: "Remover", + removing: "Removendo…", + }, + feedback: { + installed: "{{id}} instalado", + reload: "{{id}} recarregado", + enable: "{{id}} ativado", + disable: "{{id}} desativado", + remove: "{{id}} removido", + kept: "{{id}} mantido", + globalEnabled: "Plugins ativados", + globalDisabled: "Plugins desativados", + }, + removeConfirmTitle: "Remover {{id}}?", + removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.", + states: { + offlineTitle: "O host de plugins está offline", + offlineDescription: "Reconecte este host para gerenciar seus plugins.", + updateTitle: "Atualize este host para gerenciar plugins", + loading: "Carregando plugins…", + errorTitle: "Não foi possível carregar os plugins", + retry: "Tentar novamente", + empty: "Nenhum plugin configurado", + }, + }, + ru: { + ...shared, + title: "Плагины", + trustedTitle: "Плагины — доверенный код", + trustedDescription: + "Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.", + globalTitle: "Включить плагины", + globalHint: "Общий переключатель для всех настроенных плагинов", + directoryLabel: "Каталог плагина", + idLabel: "ID установки плагина", + idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json", + idPlaceholder: "Значение из манифеста", + install: "Установить каталог", + installing: "Установка…", + status: { running: "работает", disabled: "отключён", failed: "ошибка" }, + actions: { + reload: "Перезагрузить", + reloading: "Перезагрузка…", + enable: "Включить", + enabling: "Включение…", + disable: "Отключить", + disabling: "Отключение…", + remove: "Удалить", + removing: "Удаление…", + }, + feedback: { + installed: "{{id}} установлен", + reload: "{{id}} перезагружен", + enable: "{{id}} включён", + disable: "{{id}} отключён", + remove: "{{id}} удалён", + kept: "{{id}} сохранён", + globalEnabled: "Плагины включены", + globalDisabled: "Плагины отключены", + }, + removeConfirmTitle: "Удалить {{id}}?", + removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.", + states: { + offlineTitle: "Хост плагинов не в сети", + offlineDescription: "Подключите хост снова, чтобы управлять плагинами.", + updateTitle: "Обновите хост для управления плагинами", + loading: "Загрузка плагинов…", + errorTitle: "Не удалось загрузить плагины", + retry: "Повторить", + empty: "Нет настроенных плагинов", + }, + }, + "zh-CN": { + ...shared, + title: "插件", + trustedTitle: "插件是受信任的代码", + trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", + globalTitle: "启用插件", + globalHint: "所有已配置插件的全局开关", + directoryLabel: "插件目录", + idLabel: "插件安装 ID", + idHint: "留空以使用 paseo-plugin.json", + idPlaceholder: "清单默认值", + install: "安装目录", + installing: "正在安装…", + status: { running: "运行中", disabled: "已禁用", failed: "失败" }, + actions: { + reload: "重新加载", + reloading: "正在重新加载…", + enable: "启用", + enabling: "正在启用…", + disable: "禁用", + disabling: "正在禁用…", + remove: "移除", + removing: "正在移除…", + }, + feedback: { + installed: "已安装 {{id}}", + reload: "已重新加载 {{id}}", + enable: "已启用 {{id}}", + disable: "已禁用 {{id}}", + remove: "已移除 {{id}}", + kept: "已保留 {{id}}", + globalEnabled: "插件已启用", + globalDisabled: "插件已禁用", + }, + removeConfirmTitle: "移除 {{id}}?", + removeConfirmMessage: "这会移除配置,但不会删除源目录。", + states: { + offlineTitle: "插件主机离线", + offlineDescription: "重新连接此主机以管理其插件。", + updateTitle: "更新此主机以管理插件", + loading: "正在加载插件…", + errorTitle: "无法加载插件", + retry: "重试", + empty: "没有已配置的插件", + }, + }, +} as const; diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 711f417b8f..6a3a85dd5a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { common: { @@ -1649,6 +1650,7 @@ export const ptBR: TranslationResources = { archiveTooltip: "Arquivar subagente", archiveFinishedAction: "Arquivar subagentes concluídos", archiveFinishedTooltip: "Arquivar concluídos", + archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})", }, panels: { draft: { @@ -1817,8 +1819,10 @@ export const ptBR: TranslationResources = { providers: "Provedores", usage: "Uso", terminals: "Terminais", + plugins: "Plugins", host: "Visão geral", }, + plugins: pluginSettings["pt-BR"], metadataGeneration: { title: "Geração de metadados", description: diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 771ef1c5ae..5af1845b6a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { common: { @@ -1654,6 +1655,7 @@ export const ru: TranslationResources = { archiveTooltip: "Архивный субагент", archiveFinishedAction: "Архивировать завершенные субагенты", archiveFinishedTooltip: "Архивировать завершенные", + archiveFinishedRetry: "Повторить ({{failed}}/{{total}})", }, panels: { draft: { @@ -1822,8 +1824,10 @@ export const ru: TranslationResources = { providers: "Провайдеры", usage: "Использование", terminals: "Terminals", + plugins: "Plugins", host: "Обзор", }, + plugins: pluginSettings.ru, metadataGeneration: { title: "Создание метаданных", description: diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b824abc8ba..06c78eb76b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1,4 +1,5 @@ import type { TranslationResources } from "./en"; +import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { common: { @@ -1600,6 +1601,7 @@ export const zhCN: TranslationResources = { archiveTooltip: "归档 subagent", archiveFinishedAction: "归档已完成的 subagent", archiveFinishedTooltip: "归档已完成项", + archiveFinishedRetry: "重试 ({{failed}}/{{total}})", }, panels: { draft: { @@ -1768,8 +1770,10 @@ export const zhCN: TranslationResources = { providers: "Providers", usage: "使用情况", terminals: "Terminals", + plugins: "插件", host: "概览", }, + plugins: pluginSettings["zh-CN"], metadataGeneration: { title: "元数据生成", description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 59eb41112b..e2cea4e574 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; import { - useHideFinishedProviderSubagents, + useArchiveFinishedSubagents, useArchiveSubagent, useDetachSubagent, useSubagentsForParent, @@ -1532,9 +1532,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); - const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ + const archiveFinishedSubagents = useArchiveFinishedSubagents({ serverId, parentAgentId: agentId, + rows: subagentRows, }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, @@ -1628,7 +1629,8 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} - onArchiveFinished={handleHideFinishedProviderSubagents} + onArchiveFinished={archiveFinishedSubagents.archiveFinished} + archiveFinishedStatus={archiveFinishedSubagents.status} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> { + it("creates a draft-safe composer attachment", () => { + const attachment = createPluginResourceAttachment(source, item); + + expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({ + kind: "plugin_resource", + ...source, + item, + }); + }); + + it("toggles one resource by plugin, source, and stable item id", () => { + const attachment = createPluginResourceAttachment(source, item); + + expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]); + expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]); + }); + + it("submits through the backward-compatible text attachment", () => { + const attachment = createPluginResourceAttachment(source, item); + + const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment); + expect(agentAttachment).toEqual({ + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: item.text, + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: item.url, + }, + }); + expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({ + images: [], + attachments: [agentAttachment], + }); + }); +}); diff --git a/packages/app/src/plugins/attachments/model.ts b/packages/app/src/plugins/attachments/model.ts new file mode 100644 index 0000000000..c9b2a34d48 --- /dev/null +++ b/packages/app/src/plugins/attachments/model.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@paseo/plugin"; +import type { UserComposerAttachment } from "@/attachments/types"; + +export const PluginResourceComposerAttachmentSchema = z.object({ + kind: z.literal("plugin_resource"), + pluginId: z.string().min(1), + sourceId: z.string().min(1), + sourceTitle: z.string().min(1), + sourceIcon: z.string().min(1), + item: PluginAttachmentItemSchema, +}); + +export type PluginResourceComposerAttachment = z.infer< + typeof PluginResourceComposerAttachmentSchema +>; + +export interface PluginResourceSourceIdentity { + pluginId: string; + sourceId: string; + sourceTitle: string; + sourceIcon: string; +} + +export function createPluginResourceAttachment( + source: PluginResourceSourceIdentity, + item: PluginAttachmentItem, +): PluginResourceComposerAttachment { + return { + kind: "plugin_resource", + ...source, + item, + }; +} + +export function togglePluginResourceAttachment( + current: UserComposerAttachment[], + attachment: PluginResourceComposerAttachment, +): UserComposerAttachment[] { + const matches = (candidate: UserComposerAttachment) => + candidate.kind === "plugin_resource" && + candidate.pluginId === attachment.pluginId && + candidate.sourceId === attachment.sourceId && + candidate.item.id === attachment.item.id; + if (current.some(matches)) { + return current.filter((candidate) => !matches(candidate)); + } + return [...current, attachment]; +} + +export function pluginResourceAttachmentToAgentAttachment( + attachment: PluginResourceComposerAttachment, +): AgentAttachment { + return { + type: "text", + mimeType: "text/plain", + title: `${attachment.item.identifier} ${attachment.item.title}`, + text: attachment.item.text, + externalResource: { + provider: attachment.pluginId, + providerLabel: attachment.sourceTitle, + resourceType: attachment.item.resourceType, + id: attachment.item.id, + identifier: attachment.item.identifier, + title: attachment.item.title, + url: attachment.item.url, + }, + }; +} diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx new file mode 100644 index 0000000000..7ccbb79da3 --- /dev/null +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -0,0 +1,179 @@ +import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react"; +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 "@paseo/plugin"; +import { searchPluginAttachments } from "@paseo/plugin/host"; +import type { LucideIcon } from "lucide-react-native"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { AttachmentMenuItem } from "@/composer/input/input"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useFetchQuery } from "@/data/query"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { resolvePluginIcon } from "../icons"; +import { useInstalledPlugins } from "../registry"; +import type { InstalledPlugin } from "../types"; +import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model"; + +const SEARCH_STALE_TIME_MS = 30_000; +const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = []; +const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { + return ; +} + +const ThemedSourceIcon = withUnistyles(SourceIcon); + +interface InstalledAttachmentSource { + plugin: InstalledPlugin; + source: PluginAttachmentSourceContribution; + key: string; +} + +interface PluginAttachmentPickerInput { + serverId: string; + client: DaemonClient | null; + connected: boolean; + attachments: UserComposerAttachment[]; + onChangeAttachments: (attachments: UserComposerAttachment[]) => void; + anchorRef: RefObject; +} + +interface PluginAttachmentPickerBinding { + menuItems: AttachmentMenuItem[]; + picker: ReactElement | null; +} + +function searchEmptyText(error: unknown, isFetching: boolean): string { + if (error instanceof Error) return error.message; + if (error) return String(error); + return isFetching ? "Searching..." : "No results"; +} + +function installedAttachmentSources( + plugins: InstalledPlugin[], + serverId: string, +): InstalledAttachmentSource[] { + return plugins + .filter((plugin) => plugin.serverId === serverId) + .flatMap((plugin) => + plugin.attachmentSources.map((source) => ({ + plugin, + source, + key: `${plugin.id}/${source.id}`, + })), + ); +} + +function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] { + return items.map((item) => ({ + id: item.id, + label: `${item.identifier} ${item.title}`, + description: item.subtitle, + })); +} + +export function usePluginAttachmentPicker( + input: PluginAttachmentPickerInput, +): PluginAttachmentPickerBinding { + const plugins = useInstalledPlugins(); + const sources = useMemo( + () => installedAttachmentSources(plugins, input.serverId), + [input.serverId, plugins], + ); + const [activeKey, setActiveKey] = useState(null); + const [query, setQuery] = useState(""); + const active = sources.find((candidate) => candidate.key === activeKey) ?? null; + const trimmedQuery = query.trim(); + const search = useFetchQuery( + { + queryKey: [ + "plugin-attachment-search", + input.serverId, + active?.plugin.id ?? "", + active?.source.id ?? "", + trimmedQuery, + ], + queryFn: async () => { + if (!input.client || !active) throw new Error("Plugin host is offline"); + const client = input.client; + return searchPluginAttachments( + active.source, + (method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput), + trimmedQuery, + ); + }, + enabled: input.connected && active !== null, + dataShape: "list", + staleTimeMs: SEARCH_STALE_TIME_MS, + }, + active?.plugin.queryClient, + ); + const items = search.error + ? EMPTY_ATTACHMENT_ITEMS + : (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS); + const options = useMemo(() => attachmentOptions(items), [items]); + const close = useCallback(() => { + setActiveKey(null); + setQuery(""); + }, []); + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) close(); + }, + [close], + ); + const handleSelect = useCallback( + (itemId: string) => { + if (!active) return; + const item = items.find((candidate) => candidate.id === itemId); + if (!item) return; + const attachment = createPluginResourceAttachment( + { + pluginId: active.plugin.id, + sourceId: active.source.id, + sourceTitle: active.source.title, + sourceIcon: active.source.icon, + }, + item, + ); + input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment)); + close(); + }, + [active, close, input, items], + ); + const menuItems = useMemo( + () => + sources.map(({ key, source }) => { + const Icon = resolvePluginIcon(source.icon); + return { + id: `plugin:${key}`, + label: `Attach ${source.title}`, + icon: , + onSelect: () => setActiveKey(key), + }; + }), + [sources], + ); + if (!active) return { menuItems, picker: null }; + return { + menuItems, + picker: ( + + ), + }; +} diff --git a/packages/app/src/plugins/attachments/pill.tsx b/packages/app/src/plugins/attachments/pill.tsx new file mode 100644 index 0000000000..18fb926b59 --- /dev/null +++ b/packages/app/src/plugins/attachments/pill.tsx @@ -0,0 +1,65 @@ +import { useCallback, useMemo } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; +import type { ComposerAttachment } from "@/attachments/types"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { resolvePluginIcon } from "../icons"; +import type { PluginResourceComposerAttachment } from "./model"; + +const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function ResourceIcon({ + Icon, + color = "", +}: { + Icon: ReturnType; + color?: string; +}) { + return ; +} + +const ThemedResourceIcon = withUnistyles(ResourceIcon); + +interface PluginResourceAttachmentPillProps { + attachment: PluginResourceComposerAttachment; + index: number; + disabled: boolean; + onOpen: (attachment: ComposerAttachment) => void; + onRemove: (index: number) => void; + openLabel: (kind: string, identifier: string) => string; + removeLabel: (kind: string, identifier: string) => string; +} + +export function PluginResourceAttachmentPill({ + attachment, + index, + disabled, + onOpen, + onRemove, + openLabel, + removeLabel, +}: PluginResourceAttachmentPillProps) { + const Icon = resolvePluginIcon(attachment.sourceIcon); + const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]); + const handleRemove = useCallback(() => onRemove(index), [index, onRemove]); + const icon = useMemo( + () => , + [Icon], + ); + return ( + + + + ); +} diff --git a/packages/app/src/plugins/catalog-sync.test.tsx b/packages/app/src/plugins/catalog-sync.test.tsx new file mode 100644 index 0000000000..bbb5ce6845 --- /dev/null +++ b/packages/app/src/plugins/catalog-sync.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +const connection = vi.hoisted(() => ({ connected: true, supported: true })); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeIsConnected: () => connection.connected, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: () => connection.supported, +})); + +import { PluginCatalogSync } from "./catalog-sync"; +import { pluginRegistry } from "./registry"; + +function bundle(): string { + return `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; }; + } }; })`; +} + +const unsubscribe = vi.fn(); +const catalogClient = { + getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]), + on: vi.fn(() => unsubscribe), +} as unknown as DaemonClient; + +describe("PluginCatalogSync", () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + connection.connected = true; + connection.supported = true; + unsubscribe.mockReset(); + vi.mocked(catalogClient.getPluginCatalog).mockClear(); + vi.mocked(catalogClient.on).mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + pluginRegistry.removeHost("host-a"); + container.remove(); + Reflect.deleteProperty(globalThis, "__catalogSyncCleanups"); + vi.unstubAllGlobals(); + }); + + async function renderSync(): Promise { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + } + + it("tears down through disconnect and unmount boundaries exactly once each", async () => { + await renderSync(); + const first = pluginRegistry.getSnapshot()[0]; + expect(first?.id).toBe("example"); + first?.queryClient.setQueryData(["owned"], "state"); + + connection.connected = false; + await renderSync(); + + expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1); + expect(first?.queryClient.getQueryCache().getAll()).toEqual([]); + expect(pluginRegistry.getSnapshot()).toEqual([]); + + connection.connected = true; + await renderSync(); + expect(pluginRegistry.getSnapshot()).toHaveLength(1); + + act(() => root.unmount()); + root = createRoot(container); + + expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2); + expect(pluginRegistry.getSnapshot()).toEqual([]); + expect(unsubscribe).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx new file mode 100644 index 0000000000..763b858ca8 --- /dev/null +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { pluginRegistry } from "./registry"; + +export function PluginCatalogSync({ + serverId, + client, +}: { + serverId: string; + client: DaemonClient; +}) { + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "plugins"); + + useEffect(() => { + let cancelled = false; + let refreshQueue = Promise.resolve(); + if (!supported) { + pluginRegistry.removeHost(serverId); + return; + } + if (!connected) { + pluginRegistry.removeHost(serverId); + return; + } + const refresh = (replacePluginId?: string) => { + refreshQueue = refreshQueue.then(() => + client + .getPluginCatalog() + .then((catalog) => { + if (!cancelled) { + pluginRegistry.installCatalog(serverId, catalog, { replacePluginId }); + } + return undefined; + }) + .catch((error) => { + if (!cancelled) { + console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); + } + return undefined; + }), + ); + return refreshQueue; + }; + void refresh(); + const unsubscribe = client.on("status", (message) => { + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [client, connected, serverId, supported]); + + useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); + return null; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts new file mode 100644 index 0000000000..48831cc73b --- /dev/null +++ b/packages/app/src/plugins/evaluate.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { evaluatePluginClientBundle } from "./evaluate"; + +function bundle(body: string): string { + return `(function() { + const module = { exports: {} }; + module.exports.default = function(plugin) { ${body}; return function() {}; }; + return module.exports; + })`; +} + +describe("evaluatePluginClientBundle", () => { + it("collects a surface and its sidebar placement", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + function Surface() { return null; } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + `), + ); + + expect(plugin.id).toBe("example"); + expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); + expect(plugin.sidebarItems).toEqual([ + { id: "main", title: "Example", icon: "Blocks", surface: "main" }, + ]); + }); + + it("collects a declarative attachment source", () => { + const plugin = evaluatePluginClientBundle( + "linear", + bundle(` + plugin.addAttachmentSource({ + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: { name: "issues.search", input: {}, output: {} }, + }); + `), + ); + + expect(plugin.attachmentSources).toEqual([ + { + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: { name: "issues.search", input: {}, output: {} }, + }, + ]); + }); + + it("rejects duplicate attachment source ids", () => { + expect(() => + evaluatePluginClientBundle( + "linear", + bundle(` + const source = { + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search", + search: { name: "issues.search", input: {}, output: {} }, + }; + plugin.addAttachmentSource(source); + plugin.addAttachmentSource(source); + `), + ), + ).toThrow("Duplicate attachment source: issues"); + }); + + it("rejects a sidebar placement whose surface does not exist", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" }); + `), + ), + ).toThrow("references missing surface missing"); + }); + + it("rejects a bundle without a default contribution function", () => { + expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow( + "must default export a function", + ); + }); + + it("requires a cleanup function", () => { + expect(() => + evaluatePluginClientBundle("example", `(function() { return { default: function() {} }; })`), + ).toThrow("must return a cleanup function"); + }); + + it("does not publish partial contributions when setup fails", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + throw new Error("setup exploded"); + } }; })`, + ), + ).toThrow("setup exploded"); + }); +}); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts new file mode 100644 index 0000000000..7171679505 --- /dev/null +++ b/packages/app/src/plugins/evaluate.ts @@ -0,0 +1,135 @@ +import * as React from "react"; +import * as ReactJsxRuntime from "react/jsx-runtime"; +import * as ReactNative from "react-native"; +// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers. +import * as ReactQuery from "@tanstack/react-query"; +import * as Zod from "zod"; +import { + defineAttachmentSource, + defineRpc, + type PluginAttachmentSourceContribution, + type PluginSidebarContribution, + type PluginSurfaceProps, + useRpc, +} from "@paseo/plugin"; +import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host"; +import type { EvaluatedPlugin } from "./types"; +import type { ComponentType } from "react"; +import { resolvePluginIcon } from "./icons"; + +const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; + +function requireId(value: string, label: string): string { + const id = value.trim(); + if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); + return id; +} + +export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin { + const collector: PluginRegistrationCollector = { + surfaces: [], + sidebarItems: [], + attachmentSources: [], + }; + const surfaceIds = new Set(); + const sidebarItemIds = new Set(); + const attachmentSourceIds = new Set(); + const pluginContext = createPluginContext({ + addSurface(surfaceId: string, Component: ComponentType) { + const normalizedId = requireId(surfaceId, "surface id"); + if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); + if (typeof Component !== "function") + throw new Error(`Surface ${normalizedId} is not a component`); + surfaceIds.add(normalizedId); + collector.surfaces.push({ id: normalizedId, Component }); + }, + addSidebarItem(contribution: PluginSidebarContribution) { + const normalizedId = requireId(contribution.id, "sidebar item id"); + if (sidebarItemIds.has(normalizedId)) + throw new Error(`Duplicate sidebar item: ${normalizedId}`); + if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`); + if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); + resolvePluginIcon(contribution.icon.trim()); + sidebarItemIds.add(normalizedId); + collector.sidebarItems.push({ + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }); + }, + addAttachmentSource(contribution: PluginAttachmentSourceContribution) { + const normalizedId = requireId(contribution.id, "attachment source id"); + if (attachmentSourceIds.has(normalizedId)) { + throw new Error(`Duplicate attachment source: ${normalizedId}`); + } + const title = contribution.title.trim(); + const icon = contribution.icon.trim(); + const pickerTitle = contribution.pickerTitle.trim(); + const searchPlaceholder = contribution.searchPlaceholder.trim(); + const method = contribution.search.name.trim(); + if (!title) throw new Error(`Attachment source ${normalizedId} has no title`); + if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`); + if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`); + if (!searchPlaceholder) { + throw new Error(`Attachment source ${normalizedId} has no search placeholder`); + } + if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`); + resolvePluginIcon(icon); + attachmentSourceIds.add(normalizedId); + collector.attachmentSources.push({ + id: normalizedId, + title, + icon, + pickerTitle, + searchPlaceholder, + search: { ...contribution.search, name: method }, + }); + }, + }); + const runtimeRequire = (name: string): unknown => { + if (name === "react") return React; + if (name === "react/jsx-runtime") return ReactJsxRuntime; + if (name === "react-native") return ReactNative; + if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, useRpc }; + if (name === "@tanstack/react-query") return ReactQuery; + if (name === "zod") return Zod; + throw new Error(`Module "${name}" is not available in plugin client code`); + }; + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") + throw new Error(`Plugin ${id} client bundle is not executable`); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error(`Plugin ${id} must default export a function`); + } + const cleanup = setup(pluginContext); + if (typeof cleanup !== "function") { + throw new Error(`Plugin ${id} contribution must return a cleanup function`); + } + + try { + for (const item of collector.sidebarItems) { + if (!surfaceIds.has(item.surface)) { + throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); + } + } + } catch (error) { + try { + cleanup(); + } catch (cleanupError) { + console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); + } + throw error; + } + return { + id, + cleanup, + surfaces: collector.surfaces, + sidebarItems: collector.sidebarItems, + attachmentSources: collector.attachmentSources, + }; +} diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts new file mode 100644 index 0000000000..de36d962d6 --- /dev/null +++ b/packages/app/src/plugins/icons.ts @@ -0,0 +1,11 @@ +import * as LucideIcons from "lucide-react-native"; +import type { LucideIcon } from "lucide-react-native"; + +export function resolvePluginIcon(name: string): LucideIcon { + const candidate = Reflect.get(LucideIcons, name); + const isComponent = + typeof candidate === "function" || + (typeof candidate === "object" && candidate !== null && "$$typeof" in candidate); + if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`); + return candidate as LucideIcon; +} diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts new file mode 100644 index 0000000000..dc74f3e079 --- /dev/null +++ b/packages/app/src/plugins/index.ts @@ -0,0 +1,5 @@ +export { PluginCatalogSync } from "./catalog-sync"; +export { PluginSidebarItems } from "./sidebar-items"; +export { PluginSurfaceScreen } from "./surface-screen"; +export { usePluginAttachmentPicker } from "./attachments/picker"; +export { PluginResourceAttachmentPill } from "./attachments/pill"; diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts new file mode 100644 index 0000000000..1b1a563431 --- /dev/null +++ b/packages/app/src/plugins/registry.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { pluginRegistry } from "./registry"; + +function bundle(marker: string): string { + return `(function() { + const module = { exports: {} }; + module.exports.default = function(plugin) { + function Surface() { return ${JSON.stringify(marker)}; } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; }; + }; + return module.exports; + })`; +} + +afterEach(() => { + pluginRegistry.removeHost("host-a"); + pluginRegistry.removeHost("host-b"); + Reflect.deleteProperty(globalThis, "__pluginCleanups"); +}); + +describe("PluginRegistry", () => { + it("preserves a plugin query cache when the same bundle reconnects", () => { + const clientBundle = bundle("one"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["counter"], 3); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second).toBe(first); + expect(second?.queryClient).toBe(first?.queryClient); + expect(second?.queryClient.getQueryData(["counter"])).toBe(3); + }); + + it("replaces the query cache when the plugin bundle changes", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["counter"], 3); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second?.queryClient).not.toBe(first?.queryClient); + expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined(); + expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined(); + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + }); + + it("isolates query caches between hosts", () => { + const clientBundle = bundle("same"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]); + const [hostA, hostB] = pluginRegistry.getSnapshot(); + hostA?.queryClient.setQueryData(["counter"], 3); + + expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined(); + }); + + it("cleans up the old installation before evaluating its replacement", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const replacement = `(function() { return { default: function(plugin) { + globalThis.__cleanupSeenByReplacement = globalThis.__pluginCleanups || 0; + plugin.addSurface("main", function() { return null; }); + return function() {}; + } }; })`; + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: replacement }]); + + expect(Reflect.get(globalThis, "__cleanupSeenByReplacement")).toBe(1); + Reflect.deleteProperty(globalThis, "__cleanupSeenByReplacement"); + }); + + it("replaces an unchanged bundle when explicit reload names the plugin", () => { + const clientBundle = bundle("same"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + const first = pluginRegistry.getSnapshot()[0]; + first?.queryClient.setQueryData(["attachment-search"], "cached"); + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], { + replacePluginId: "example", + }); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second).not.toBe(first); + expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined(); + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + }); + + it.each(["disable", "remove"])("cleans a catalog entry exactly once on %s", () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + + pluginRegistry.installCatalog("host-a", []); + pluginRegistry.installCatalog("host-a", []); + + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + expect(pluginRegistry.getSnapshot()).toEqual([]); + }); + + it("cancels an in-flight attachment query during teardown", async () => { + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); + const plugin = pluginRegistry.getSnapshot()[0]; + if (!plugin) throw new Error("Expected installed plugin"); + let observeAbort: () => void = () => undefined; + const aborted = new Promise((resolve) => { + observeAbort = resolve; + }); + void plugin.queryClient + .fetchQuery({ + queryKey: ["plugin-attachment-search", "example"], + queryFn: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + observeAbort(); + reject(new Error("aborted")); + }); + }), + }) + .catch(() => undefined); + + pluginRegistry.installCatalog("host-a", []); + + await aborted; + expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]); + }); + + it("continues host teardown when cleanup throws", () => { + const throwing = `(function() { return { default: function(plugin) { + plugin.addSurface("main", function() { return null; }); + return function() { throw new Error("cleanup exploded"); }; + } }; })`; + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: throwing }]); + pluginRegistry.getSnapshot()[0]?.queryClient.setQueryData(["counter"], 3); + + expect(() => pluginRegistry.removeHost("host-a")).not.toThrow(); + expect(pluginRegistry.getSnapshot()).toEqual([]); + }); +}); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts new file mode 100644 index 0000000000..32d4e1d525 --- /dev/null +++ b/packages/app/src/plugins/registry.ts @@ -0,0 +1,139 @@ +import { useSyncExternalStore } from "react"; +import { QueryClient } from "@tanstack/react-query"; +import { evaluatePluginClientBundle } from "./evaluate"; +import type { InstalledPlugin } from "./types"; + +interface CatalogPlugin { + id: string; + clientBundle: string; +} + +class PluginRegistry { + private readonly byHost = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshot: InstalledPlugin[] = []; + private readonly disposed = new WeakSet(); + private readonly evaluationErrors = new Map(); + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): InstalledPlugin[] => this.snapshot; + + getEvaluationError(serverId: string, pluginId: string): string | undefined { + return this.evaluationErrors.get(`${serverId}/${pluginId}`); + } + + installCatalog( + serverId: string, + catalog: CatalogPlugin[], + options: { replacePluginId?: string } = {}, + ): void { + const previous = this.byHost.get(serverId) ?? []; + const preserved = catalog.flatMap((entry) => { + const existing = previous.find( + (plugin) => + plugin.id !== options.replacePluginId && + plugin.id === entry.id && + plugin.clientBundle === entry.clientBundle, + ); + return existing ? [existing] : []; + }); + const removed = previous.filter((plugin) => !preserved.includes(plugin)); + if (removed.length > 0) { + this.byHost.set(serverId, preserved); + this.publish(); + for (const plugin of removed) this.dispose(plugin); + } + const installed = catalog.flatMap((entry) => { + const key = `${serverId}/${entry.id}`; + try { + const existing = preserved.find( + (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, + ); + if (existing) { + this.evaluationErrors.delete(key); + return [existing]; + } + const queryClient = new QueryClient(); + const evaluated = [ + { + ...evaluatePluginClientBundle(entry.id, entry.clientBundle), + serverId, + clientBundle: entry.clientBundle, + queryClient, + }, + ]; + this.evaluationErrors.delete(key); + return evaluated; + } catch (error) { + this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); + console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); + return []; + } + }); + const configuredIds = new Set(catalog.map((entry) => entry.id)); + for (const key of this.evaluationErrors.keys()) { + if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) { + this.evaluationErrors.delete(key); + } + } + this.byHost.set(serverId, installed); + this.publish(); + } + + removeHost(serverId: string): void { + const installed = this.byHost.get(serverId); + if (!installed) return; + for (const plugin of installed) this.dispose(plugin); + for (const key of this.evaluationErrors.keys()) { + if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); + } + this.byHost.delete(serverId); + this.publish(); + } + + private dispose(plugin: InstalledPlugin): void { + if (this.disposed.has(plugin)) return; + this.disposed.add(plugin); + plugin.queryClient.clear(); + try { + plugin.cleanup(); + } catch (error) { + console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error); + } + } + + private publish(): void { + this.snapshot = [...this.byHost.values()] + .flat() + .sort((left, right) => + `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), + ); + for (const listener of this.listeners) listener(); + } +} + +export const pluginRegistry = new PluginRegistry(); + +export function useInstalledPlugins(): InstalledPlugin[] { + return useSyncExternalStore( + pluginRegistry.subscribe, + pluginRegistry.getSnapshot, + pluginRegistry.getSnapshot, + ); +} + +export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null { + return ( + useInstalledPlugins().find( + (plugin) => plugin.serverId === serverId && plugin.id === pluginId, + ) ?? null + ); +} + +export function usePluginInstallations(pluginId: string): InstalledPlugin[] { + return useInstalledPlugins().filter((plugin) => plugin.id === pluginId); +} diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts new file mode 100644 index 0000000000..f59ebd6023 --- /dev/null +++ b/packages/app/src/plugins/routes.ts @@ -0,0 +1,17 @@ +export function buildPluginSurfaceRoute( + serverId: string, + pluginId: string, + contributionId: string, +): `/h/${string}/plugin/${string}/${string}` { + return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`; +} + +export function hostIdFromPathname(pathname: string): string | null { + const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1]; + if (!encoded) return null; + try { + return decodeURIComponent(encoded); + } catch { + return null; + } +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts new file mode 100644 index 0000000000..81998a4f26 --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -0,0 +1,45 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { InstalledPlugin } from "./types"; +import { groupPluginSidebarContributions } from "./sidebar-groups"; + +function installed(serverId: string, contributionId = "main"): InstalledPlugin { + return { + id: "example", + cleanup: () => undefined, + serverId, + clientBundle: serverId, + queryClient: new QueryClient(), + surfaces: [{ id: "surface", Component: () => null }], + sidebarItems: [ + { + id: contributionId, + title: "Example", + icon: "Blocks", + surface: "surface", + }, + ], + attachmentSources: [], + }; +} + +describe("groupPluginSidebarContributions", () => { + it("coalesces the same plugin contribution across hosts", () => { + const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]); + + expect(groups).toHaveLength(1); + expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([ + "host-a", + "host-b", + ]); + }); + + it("keeps different contribution ids separate", () => { + const groups = groupPluginSidebarContributions([ + installed("host-a", "main"), + installed("host-b", "settings"), + ]); + + expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]); + }); +}); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts new file mode 100644 index 0000000000..e61d37c8f6 --- /dev/null +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -0,0 +1,48 @@ +import type { InstalledPlugin, PluginSidebarContribution } from "./types"; + +const preferredHosts = new Map(); + +export interface PluginSidebarTarget { + plugin: InstalledPlugin; + item: PluginSidebarContribution; +} + +export interface PluginSidebarGroup { + key: string; + pluginId: string; + contributionId: string; + title: string; + icon: string; + targets: PluginSidebarTarget[]; +} + +export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] { + const groups = new Map(); + for (const plugin of plugins) { + for (const item of plugin.sidebarItems) { + const key = `${plugin.id}/${item.id}`; + const existing = groups.get(key); + if (existing) { + existing.targets.push({ plugin, item }); + } else { + groups.set(key, { + key, + pluginId: plugin.id, + contributionId: item.id, + title: item.title, + icon: item.icon, + targets: [{ plugin, item }], + }); + } + } + } + return [...groups.values()]; +} + +export function getPreferredPluginContributionHost(groupKey: string): string | null { + return preferredHosts.get(groupKey) ?? null; +} + +export function rememberPluginContributionHost(groupKey: string, serverId: string): void { + preferredHosts.set(groupKey, serverId); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx new file mode 100644 index 0000000000..f2cc614cc5 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items.tsx @@ -0,0 +1,80 @@ +import { router, usePathname } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { resolvePluginIcon } from "./icons"; +import { useInstalledPlugins } from "./registry"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes"; +import { + groupPluginSidebarContributions, + getPreferredPluginContributionHost, + rememberPluginContributionHost, + type PluginSidebarGroup, + type PluginSidebarTarget, +} from "./sidebar-groups"; + +function selectTarget( + group: PluginSidebarGroup, + currentHostId: string | null, +): PluginSidebarTarget { + const current = group.targets.find((target) => target.plugin.serverId === currentHostId); + if (current) return current; + const rememberedHostId = getPreferredPluginContributionHost(group.key); + const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId); + return remembered ?? group.targets[0]; +} + +export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { + const plugins = useInstalledPlugins(); + const pathname = usePathname(); + const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]); + const currentHostId = hostIdFromPathname(pathname); + + return groups.map((group) => ( + + )); +} + +function PluginSidebarItemRow({ + group, + currentHostId, + pathname, + onBeforeNavigate, +}: { + group: PluginSidebarGroup; + currentHostId: string | null; + pathname: string; + onBeforeNavigate?: () => void; +}) { + const target = selectTarget(group, currentHostId); + const route = buildPluginSurfaceRoute( + target.plugin.serverId, + group.pluginId, + group.contributionId, + ); + const isActive = group.targets.some( + (candidate) => + pathname === + buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId), + ); + const navigate = useCallback(() => { + rememberPluginContributionHost(group.key, target.plugin.serverId); + onBeforeNavigate?.(); + router.push(route); + }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); + return ( + + ); +} diff --git a/packages/app/src/plugins/surface-error-boundary.test.tsx b/packages/app/src/plugins/surface-error-boundary.test.tsx new file mode 100644 index 0000000000..2e137eb0da --- /dev/null +++ b/packages/app/src/plugins/surface-error-boundary.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import React, { act, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Text } from "react-native"; +import { SurfaceErrorBoundary } from "./surface-error-boundary"; + +let oldSurfaceCleanups = 0; +function FailingSurface() { + const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(true); + return () => { + oldSurfaceCleanups += 1; + }; + }, []); + if (failed) throw new Error("old render exploded"); + return Old surface mounted; +} +function CorrectedSurface() { + return Corrected surface; +} +const oldInstallation = {}; +const correctedInstallation = {}; + +describe("SurfaceErrorBoundary", () => { + const roots: Array> = []; + const containers: HTMLElement[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + for (const container of containers.splice(0)) container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + oldSurfaceCleanups = 0; + }); + + it("recovers a mounted failed route when its installation and surface are replaced", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const container = document.createElement("div"); + containers.push(container); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + await act(async () => { + root.render( + + + , + ); + }); + expect(container.textContent).toBe("Plugin failed: old render exploded"); + expect(oldSurfaceCleanups).toBe(1); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toBe("Corrected surface"); + expect(oldSurfaceCleanups).toBe(1); + }); +}); diff --git a/packages/app/src/plugins/surface-error-boundary.tsx b/packages/app/src/plugins/surface-error-boundary.tsx new file mode 100644 index 0000000000..46e306fe8a --- /dev/null +++ b/packages/app/src/plugins/surface-error-boundary.tsx @@ -0,0 +1,51 @@ +import React, { Component, type ErrorInfo, type ReactNode } from "react"; +import { Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +interface SurfaceErrorBoundaryProps { + children: ReactNode; + installation: object; + Surface: unknown; +} + +interface SurfaceErrorBoundaryState { + error: string | null; +} + +export class SurfaceErrorBoundary extends Component< + SurfaceErrorBoundaryProps, + SurfaceErrorBoundaryState +> { + state: SurfaceErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: unknown): SurfaceErrorBoundaryState { + return { error: error instanceof Error ? error.message : String(error) }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.warn("[Plugins] Surface render failed", error, info.componentStack); + } + + componentDidUpdate(previous: SurfaceErrorBoundaryProps): void { + if ( + this.state.error && + (previous.installation !== this.props.installation || previous.Surface !== this.props.Surface) + ) { + this.setState({ error: null }); + } + } + + render() { + if (this.state.error) { + return Plugin failed: {this.state.error}; + } + return this.props.children; + } +} + +const styles = StyleSheet.create((theme) => ({ + errorText: { + color: theme.colors.statusDanger, + padding: theme.spacing[4], + }, +})); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx new file mode 100644 index 0000000000..dd6802eaf2 --- /dev/null +++ b/packages/app/src/plugins/surface-screen.tsx @@ -0,0 +1,270 @@ +import { router, useLocalSearchParams } from "expo-router"; +import { QueryClientProvider } from "@tanstack/react-query"; +import type { PluginSurfaceProps } from "@paseo/plugin"; +import { PluginRpcProvider } from "@paseo/plugin/host"; +import { ChevronDown, X } from "lucide-react-native"; +import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; +import { Platform, Pressable, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { ScreenHeader } from "@/components/headers/screen-header"; +import { ScreenTitle } from "@/components/headers/screen-title"; +import { HostPicker } from "@/components/hosts/host-picker"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import type { ShortcutKey } from "@/utils/format-shortcut"; +import { resolvePluginIcon } from "./icons"; +import { useInstalledPlugin, usePluginInstallations } from "./registry"; +import { buildPluginSurfaceRoute } from "./routes"; +import { rememberPluginContributionHost } from "./sidebar-groups"; +import { SurfaceErrorBoundary } from "./surface-error-boundary"; + +const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; +const EMPTY_THEME_DTO: Record = {}; +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const pluginThemeMapping = (theme: Theme) => ({ + themeDto: JSON.parse(JSON.stringify(theme)) as Record, +}); +const ThemedX = withUnistyles(X); +const ThemedChevronDown = withUnistyles(ChevronDown); + +function routeParam(value: string | string[] | undefined): string { + return typeof value === "string" ? value : ""; +} + +function PluginHeaderIcon({ + Icon, + color = "", +}: { + Icon: ReturnType; + color?: string; +}) { + return ; +} + +const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); + +function SurfaceRenderer({ + Surface, + invoke, + plugin, + layout, + host, + themeDto = EMPTY_THEME_DTO, +}: { + Surface: ComponentType; + invoke(method: string, input: unknown): Promise; + plugin: NonNullable>; + layout: PluginSurfaceProps["layout"]; + host: PluginSurfaceProps["host"]; + themeDto?: Record; +}) { + return ( + + + + + + ); +} + +const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); + +function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} + +function PluginHostSwitcher({ + serverId, + pluginId, + contributionId, + serverIds, +}: { + serverId: string; + pluginId: string; + contributionId: string; + serverIds: string[]; +}) { + const allHosts = useHosts(); + const hosts = useMemo( + () => allHosts.filter((host) => serverIds.includes(host.serverId)), + [allHosts, serverIds], + ); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const selectHost = useCallback( + (nextServerId: string) => { + rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId)); + }, + [contributionId, pluginId], + ); + const openPicker = useCallback(() => setOpen(true), []); + const show = serverIds.length > 1 && hosts.length > 1; + if (!show) return null; + + return ( + + + + + {selectedLabel} + + + + + + ); +} + +export function PluginSurfaceScreen() { + const params = useLocalSearchParams<{ + serverId?: string | string[]; + pluginId?: string | string[]; + surfaceId?: string | string[]; + }>(); + const serverId = routeParam(params.serverId); + const pluginId = routeParam(params.pluginId); + const contributionId = routeParam(params.surfaceId); + const plugin = useInstalledPlugin(serverId, pluginId); + const installations = usePluginInstallations(pluginId); + const hosts = useHosts(); + const client = useHostRuntimeClient(serverId); + const compact = useIsCompactFormFactor(); + const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null; + const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null; + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const contributionServerIds = useMemo( + () => + installations + .filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId)) + .map((entry) => entry.serverId), + [contributionId, installations], + ); + const title = sidebarItem?.title ?? (pluginId || "Plugin"); + const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; + const invoke = useCallback( + async (method: string, input: unknown) => { + if (!client) throw new Error("Plugin host is offline"); + return client.invokePluginRpc(pluginId, method, input); + }, + [client, pluginId], + ); + const close = useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace(`/h/${encodeURIComponent(serverId)}`); + }, [serverId]); + const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); + const headerLeft = useMemo( + () => ( + <> + {Icon ? ( + + + + ) : null} + {title} + + ), + [Icon, title], + ); + const headerRight = useMemo( + () => ( + <> + + + + + + ), + [close, contributionId, contributionServerIds, pluginId, serverId], + ); + + return ( + + + + {plugin && surface ? ( + + + + ) : ( + This plugin surface is unavailable. + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + screen: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + body: { + flex: 1, + }, + errorText: { + color: theme.colors.statusDanger, + padding: theme.spacing[4], + }, + hostSwitcher: { + maxWidth: 180, + minHeight: 32, + paddingHorizontal: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + }, + hostSwitcherText: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts new file mode 100644 index 0000000000..931b98a0cd --- /dev/null +++ b/packages/app/src/plugins/types.ts @@ -0,0 +1,26 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { + PluginAttachmentSourceContribution, + PluginSidebarContribution, + PluginSurfaceContribution, +} from "@paseo/plugin"; + +export interface EvaluatedPlugin { + id: string; + cleanup: () => void; + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; + attachmentSources: PluginAttachmentSourceContribution[]; +} + +export interface InstalledPlugin extends EvaluatedPlugin { + serverId: string; + clientBundle: string; + queryClient: QueryClient; +} + +export type { + PluginAttachmentSourceContribution, + PluginSidebarContribution, + PluginSurfaceContribution, +}; diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index ee008f2ab0..301d3e1c9e 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -111,6 +111,31 @@ function message(id: string, text: string): StreamItem { }; } +function toolCall(): StreamItem { + return { + kind: "tool_call", + id: "tool-1", + timelineCursor: { epoch: "epoch-1", seq: 12 }, + timestamp: new Date("2026-07-18T08:02:00.000Z"), + payload: { + source: "agent", + data: { + provider: "codex", + callId: "call-1", + name: "shell", + status: "completed", + error: null, + detail: { + type: "shell", + command: "npm test", + output: "passed", + exitCode: 0, + }, + }, + }, + }; +} + function seedSession(): void { const store = useSessionStore.getState(); store.initializeSession(SERVER_ID, null); @@ -209,7 +234,7 @@ describe("ReplicaCache", () => { cache.setHosts([]); }); - it("restores a displayable stale replica without claiming remote hydration", async () => { + it("restores the exact persisted canonical timeline window", async () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage); writer.setHosts([SERVER_ID]); @@ -237,11 +262,65 @@ describe("ReplicaCache", () => { expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); - expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map()); - expect(session.agentTimelineCursor).toEqual(new Map()); - expect(session.agentTimelineHasOlder).toEqual(new Map()); - expect(session.agentTimelineHasNewer).toEqual(new Map()); + expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map([["agent-1", true]])); + expect(session.agentTimelineCursor).toEqual( + new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), + ); + expect(session.agentTimelineHasOlder).toEqual(new Map([["agent-1", true]])); + expect(session.agentTimelineHasNewer).toEqual(new Map([["agent-1", false]])); expect(session.agentHistorySyncGeneration).toEqual(new Map()); + expect(selectAgentTimelineState(session, "agent-1")).toEqual({ + status: "synced", + items: [message("message-1", "Cached")], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 12 }, + older: "available", + newer: "none", + }); + }); + + it("restores tool calls inside an authoritative cached window", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts([SERVER_ID]); + seedSession(); + useSessionStore.getState().setAgentStreamTail(SERVER_ID, new Map([["agent-1", [toolCall()]]])); + await writer.flush(); + + useSessionStore.getState().clearSession(SERVER_ID); + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(session?.agentStreamTail.get("agent-1")).toEqual([toolCall()]); + expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 12, + }); + }); + + it("restores display-only when retained items do not reach the stored range end", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts([SERVER_ID]); + seedSession(); + useSessionStore + .getState() + .setAgentTimelineCursor( + SERVER_ID, + new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 13 }]]), + ); + await writer.flush(); + + useSessionStore.getState().clearSession(SERVER_ID); + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); + expect(session?.agentTimelineCursor).toEqual(new Map()); expect(selectAgentTimelineState(session, "agent-1")).toEqual({ status: "painted", items: [message("message-1", "Cached")], @@ -264,9 +343,10 @@ describe("ReplicaCache", () => { normalizeWorkspaceDescriptor(workspace("workspace-2", "project-2", "/repo/other")), ), ); - const secondTimeline = Array.from({ length: 60 }, (_, index) => - message(`message-${index}`, `Second ${index}`), - ); + const secondTimeline = Array.from({ length: 60 }, (_, index) => ({ + ...message(`message-${index}`, `Second ${index}`), + timelineCursor: { epoch: "epoch-2", seq: index + 1 }, + })); store.setAgentStreamTail( SERVER_ID, new Map([ @@ -274,6 +354,12 @@ describe("ReplicaCache", () => { ["agent-2", secondTimeline], ]), ); + store.setAgentTimelineCursor( + SERVER_ID, + new Map([["agent-2", { epoch: "epoch-2", startSeq: 1, endSeq: 60 }]]), + ); + store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-2", true]])); + store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-2", true); store.setFocusedAgentId(SERVER_ID, "agent-2"); await cache.flush(); @@ -293,13 +379,23 @@ describe("ReplicaCache", () => { ]); expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); + expect(session?.agentTimelineCursor.has("agent-2")).toBe(false); + expect(selectAgentTimelineState(session, "agent-2")).toEqual({ + status: "painted", + items: secondTimeline.slice(-50), + }); const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as { version: number; hosts: Array<{ timeline: Record | null }>; }; - expect(persisted.version).toBe(5); - expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); + expect(persisted.version).toBe(6); + expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual([ + "agentId", + "hasOlder", + "items", + "range", + ]); }); it("persists reconciled rows without caching unreconciled local presentations", async () => { @@ -404,12 +500,12 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects and clears version 4 cache data before overwriting it on flush", async () => { + it("rejects and clears version 5 cache data before overwriting it on flush", async () => { const storage = new MemoryStorage(); storage.values.set( "@paseo:replica-cache", JSON.stringify({ - version: 4, + version: 5, hosts: [ { serverId: SERVER_ID, @@ -435,7 +531,7 @@ describe("ReplicaCache", () => { expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 5, + version: 6, hosts: [], }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 35ddf09fe5..961930fe2b 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,6 +1,6 @@ import { Buffer } from "buffer"; import { z } from "zod"; -import { AgentStatusSchema } from "@getpaseo/protocol/messages"; +import { AgentStatusSchema, AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { normalizeProjectDescriptor, @@ -9,6 +9,7 @@ import { useSessionStore, type Agent, type SessionReplica, + type SessionReplicaTimeline, type SessionState, type ProjectDescriptor, type WorkspaceDescriptor, @@ -17,7 +18,7 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream" import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 5; +const CACHE_VERSION = 6; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; @@ -91,6 +92,12 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ trigger: z.enum(["auto", "manual"]).optional(), preTokens: z.number().nonnegative().optional(), }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("tool_call"), + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema.refine((item) => item.type === "tool_call"), + }), ]); const AgentCapabilitiesSchema = z.strictObject({ @@ -246,6 +253,14 @@ const StoredProjectSchema = z.strictObject({ const StoredTimelineSchema = z.strictObject({ agentId: z.string(), items: z.array(StoredTimelineItemSchema), + range: z + .strictObject({ + epoch: z.string(), + startSeq: z.number().int().nonnegative(), + endSeq: z.number().int().nonnegative(), + }) + .nullable(), + hasOlder: z.boolean(), }); const StoredHostSchema = z.strictObject({ @@ -275,6 +290,8 @@ interface ReplicaInput { projects: ReadonlyMap; focusedAgent: Agent | undefined; timelineItems: StreamItem[] | undefined; + timelineRange: SessionReplicaTimeline["range"]; + timelineHasOlder: boolean; } export interface ReplicaCacheStorage { @@ -294,6 +311,8 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), + range: stored.range, + hasOlder: stored.hasOlder, }; } @@ -351,7 +370,23 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), }; case "tool_call": - return null; + if (item.payload.source !== "agent") return null; + const storedTool = AgentTimelineItemPayloadSchema.safeParse({ + type: "tool_call", + callId: item.payload.data.callId, + name: item.payload.data.name, + status: item.payload.data.status, + error: item.payload.data.error, + detail: item.payload.data.detail, + ...(item.payload.data.metadata ? { metadata: item.payload.data.metadata } : {}), + }); + if (!storedTool.success || storedTool.data.type !== "tool_call") return null; + return { + ...base, + kind: item.kind, + provider: item.payload.data.provider, + item: storedTool.data, + }; } } @@ -404,6 +439,28 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { ...(item.trigger ? { trigger: item.trigger } : {}), ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), }; + case "tool_call": { + const tool = item.item; + if (tool.type !== "tool_call") { + throw new Error("Stored tool call contains a non-tool timeline item"); + } + return { + ...base, + kind: item.kind, + payload: { + source: "agent", + data: { + provider: item.provider, + callId: tool.callId, + name: tool.name, + status: tool.status, + error: tool.error, + detail: tool.detail, + ...(tool.metadata ? { metadata: tool.metadata } : {}), + }, + }, + }; + } } } @@ -529,13 +586,28 @@ function serializeProject(project: ProjectDescriptor): StoredProject { }; } +function isTimelineItemStoredLosslessly(item: StreamItem): boolean { + switch (item.kind) { + case "user_message": + return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; + case "activity_log": + return item.metadata === undefined; + case "tool_call": + return item.payload.source === "agent"; + default: + return true; + } +} + function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean { return ( left.agents === right.agents && left.workspaces === right.workspaces && left.projects === right.projects && left.focusedAgent === right.focusedAgent && - left.timelineItems === right.timelineItems + left.timelineItems === right.timelineItems && + left.timelineRange === right.timelineRange && + left.timelineHasOlder === right.timelineHasOlder ); } @@ -550,14 +622,33 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl projects: session.projects, focusedAgent: agent, timelineItems: timeline.status === "cold" ? undefined : timeline.items, + timelineRange: timeline.status === "synced" ? timeline.range : null, + timelineHasOlder: timeline.status === "synced" && timeline.older === "available", }; } function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost { - const items = input.timelineItems - ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) - .map(serializeTimelineItem) - .filter((item) => item !== null); + const canonicalItems = input.timelineItems?.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); + const items = canonicalItems + ? canonicalItems.map(serializeTimelineItem).filter((item) => item !== null) + : undefined; + const range = input.timelineRange; + const canPersistCoverage = + range !== null && + range.retainedRanges === undefined && + canonicalItems !== undefined && + canonicalItems.length <= MAX_TIMELINE_ITEMS && + items?.length === canonicalItems.length && + canonicalItems.every( + (item) => + isTimelineItemStoredLosslessly(item) && + item.timelineCursor?.epoch === range.epoch && + item.timelineCursor.seq >= range.startSeq && + item.timelineCursor.seq <= range.endSeq, + ) && + canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); return { serverId, agents: Array.from(input.agents.values(), serializeAgent), @@ -569,6 +660,10 @@ function serializeHost(serverId: string, input: ReplicaInput, directorySync?: un ? { agentId: input.focusedAgent.id, items: items.slice(-MAX_TIMELINE_ITEMS), + range: canPersistCoverage + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: canPersistCoverage ? input.timelineHasOlder : false, } : null, ...(directorySync ? { directorySync } : {}), @@ -783,8 +878,21 @@ export class ReplicaCache { if (session.focusedAgentId) { this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); } - const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null); + const focusedAgentId = this.lastFocusedAgentIds.get(serverId) ?? null; const previous = this.capturedInputs.get(serverId); + const selected = selectReplicaInput(session, focusedAgentId); + const hasTimelineHead = + focusedAgentId !== null && (session.agentStreamHead.get(focusedAgentId)?.length ?? 0) > 0; + let input = selected; + if (hasTimelineHead && previous && selected.timelineItems === previous.timelineItems) { + input = { + ...selected, + timelineRange: previous.timelineRange, + timelineHasOlder: previous.timelineHasOlder, + }; + } else if (hasTimelineHead) { + input = { ...selected, timelineRange: null, timelineHasOlder: false }; + } if (previous && replicaInputsEqual(previous, input)) return false; this.capturedInputs.set(serverId, input); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c7e6df8e56..3fca4f5c9d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Code2, Smartphone, Sparkles, + Blocks, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -108,6 +109,7 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; @@ -191,6 +193,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, { id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal }, + { id: "plugins", labelKey: "settings.hostSections.plugins", icon: Blocks }, ]; function renderHostSettingsContent( @@ -216,6 +219,8 @@ function renderHostSettingsContent( return ; case "terminals": return ; + case "plugins": + return ; case "host": return ; } diff --git a/packages/app/src/screens/settings/plugins-page-state.test.ts b/packages/app/src/screens/settings/plugins-page-state.test.ts new file mode 100644 index 0000000000..192436029f --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page-state.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolvePluginPageState } from "./plugins-page-state"; + +describe("resolvePluginPageState", () => { + it.each([ + ["offline", false, false, false, false, 0], + ["unsupported", true, false, false, false, 0], + ["loading", true, true, true, false, 0], + ["error", true, true, false, true, 0], + ["empty", true, true, false, false, 0], + ["ready", true, true, false, false, 1], + ] as const)("resolves %s", (expected, connected, supported, loading, error, pluginCount) => { + expect(resolvePluginPageState({ connected, supported, loading, error, pluginCount })).toBe( + expected, + ); + }); +}); diff --git a/packages/app/src/screens/settings/plugins-page-state.ts b/packages/app/src/screens/settings/plugins-page-state.ts new file mode 100644 index 0000000000..0623dca047 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page-state.ts @@ -0,0 +1,15 @@ +export type PluginPageState = "offline" | "unsupported" | "loading" | "error" | "empty" | "ready"; + +export function resolvePluginPageState(input: { + connected: boolean; + supported: boolean; + loading: boolean; + error: boolean; + pluginCount: number; +}): PluginPageState { + if (!input.connected) return "offline"; + if (!input.supported) return "unsupported"; + if (input.loading) return "loading"; + if (input.error) return "error"; + return input.pluginCount === 0 ? "empty" : "ready"; +} diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx new file mode 100644 index 0000000000..89dc493881 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { i18n as testI18n } from "@/i18n/i18next"; +import React, { type ReactElement } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostPluginsPage } from "./plugins-page"; + +void testI18n; + +const runtime = vi.hoisted(() => ({ + connected: true, + supported: true, + client: null as DaemonClient | null, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => runtime.client, + useHostRuntimeIsConnected: () => runtime.connected, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: () => runtime.supported, +})); + +vi.mock("react-native-reanimated", () => ({ + default: { View: "div" }, + Easing: { ease: "ease", inOut: (value: unknown) => value }, + interpolateColor: (value: number, _input: number[], output: string[]) => + value >= 1 ? output[1] : output[0], + useAnimatedStyle: (factory: () => unknown) => factory(), + useDerivedValue: (factory: () => unknown) => ({ value: factory() }), + withTiming: (value: unknown) => value, +})); + +vi.mock("@gorhom/bottom-sheet", async () => { + const ReactModule = await vi.importActual("react"); + const { ScrollView, TextInput } = + await vi.importActual("react-native"); + return { + BottomSheetBackdrop: () => null, + BottomSheetModal: ReactModule.forwardRef(() => null), + BottomSheetScrollView: ScrollView, + BottomSheetTextInput: TextInput, + useBottomSheetInternal: () => null, + }; +}); + +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +function never(): Promise { + return new Promise(() => undefined); +} + +function plugin(enabled = true): PluginListItem { + return { + id: "example", + path: "/plugins/example", + enabled, + status: enabled ? "running" : "disabled", + }; +} + +function createClient() { + return { + on: vi.fn(() => () => undefined), + getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), + patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), + listPlugins: vi.fn(async (): Promise => []), + inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })), + installDirectoryPlugin: vi.fn(async () => plugin()), + reloadPlugin: vi.fn(async () => plugin()), + enablePlugin: vi.fn(async () => plugin()), + disablePlugin: vi.fn(async () => plugin(false)), + removePlugin: vi.fn(async () => undefined), + }; +} + +type PluginClient = ReturnType; + +function renderPage(client: PluginClient | null): void { + runtime.client = client as unknown as DaemonClient | null; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const element: ReactElement = ( + + + + ); + render(element); +} + +describe("HostPluginsPage", () => { + beforeEach(() => { + vi.stubGlobal("React", React); + runtime.connected = true; + runtime.supported = true; + runtime.client = null; + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the offline state", () => { + runtime.connected = false; + renderPage(null); + + expect(screen.getByRole("alert").textContent).toContain("Plugin host is offline"); + }); + + it("renders a catalog error and retries through the real query", async () => { + const client = createClient(); + client.listPlugins.mockRejectedValueOnce(new Error("catalog exploded")); + renderPage(client); + + expect(await screen.findByText("Unable to load plugins")).toBeDefined(); + expect(screen.getByText("catalog exploded")).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => expect(client.listPlugins).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("No plugins configured")).toBeDefined(); + }); + + it.each([ + ["reloadPlugin", "Reload", "Reloading…", true], + ["disablePlugin", "Disable", "Disabling…", true], + ["enablePlugin", "Enable", "Enabling…", false], + ["removePlugin", "Remove", "Removing…", true], + ] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => { + const client = createClient(); + client.listPlugins.mockResolvedValue([plugin(enabled)]); + client[method].mockImplementation(() => never()); + renderPage(client); + + fireEvent.click(await screen.findByRole("button", { name: trigger })); + + const pendingControl = await screen.findByRole("button", { name: pending }); + expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); + expect(client[method]).toHaveBeenCalledTimes(1); + }); + + it("renders install pending through the real form and mutation", async () => { + const client = createClient(); + client.installDirectoryPlugin.mockImplementation(() => never()); + renderPage(client); + + fireEvent.change(screen.getByLabelText("Plugin directory"), { + target: { value: "/plugins/example" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Install directory" })); + + const pendingControl = await screen.findByRole("button", { name: "Installing…" }); + expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); + expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined); + }); +}); diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx new file mode 100644 index 0000000000..129328e475 --- /dev/null +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useMutation } from "@tanstack/react-query"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { Switch } from "@/components/ui/switch"; +import { useFetchQuery } from "@/data/query"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; +import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; +type PluginRowAction = "reload" | "enable" | "disable" | "remove"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function pluginRowAction(action: string | undefined): PluginRowAction | undefined { + if (action === "reload" || action === "enable" || action === "disable" || action === "remove") { + return action; + } + return undefined; +} + +function PluginRow({ + plugin, + clientError, + pending, + pendingAction, + onAction, +}: { + plugin: PluginListItem; + clientError?: string; + pending: boolean; + pendingAction?: PluginRowAction; + onAction(action: PluginRowAction, plugin: PluginListItem): void; +}) { + const { t } = useTranslation(); + const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); + const toggle = useCallback( + () => onAction(plugin.enabled ? "disable" : "enable", plugin), + [onAction, plugin], + ); + const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); + const status = clientError ? "failed" : plugin.status; + let badgeVariant: "success" | "error" | "muted" = "muted"; + if (status === "running") badgeVariant = "success"; + else if (status === "failed") badgeVariant = "error"; + const statusLabel = t(`settings.plugins.status.${status}`); + let toggleLabel = t( + plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + ); + if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); + else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); + return ( + + + + {plugin.id} + + + {plugin.path} + {clientError || plugin.error ? ( + {clientError ?? plugin.error} + ) : null} + + + + + + + + ); +} + +export function HostPluginsPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginManagement"); + const { config, patchConfig } = useDaemonConfig(serverId); + const refreshQueue = useRef(Promise.resolve()); + useInstalledPlugins(); + const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); + const [directory, setDirectory] = useState(""); + const [pluginId, setPluginId] = useState(""); + const [directoryResetKey, setDirectoryResetKey] = useState(0); + const [pluginIdResetKey, setPluginIdResetKey] = useState(0); + const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( + null, + ); + const plugins = useFetchQuery({ + queryKey, + queryFn: async () => { + if (!client) throw new Error("Plugin host is offline"); + return client.listPlugins(); + }, + enabled: Boolean(client && connected && supported), + dataShape: "list", + staleTimeMs: 1_000, + }); + const refetchPlugins = plugins.refetch; + const refresh = useCallback(async () => { + if (!client) return; + const refetch = async () => { + await refetchPlugins(); + }; + refreshQueue.current = refreshQueue.current.then(refetch, refetch); + await refreshQueue.current; + }, [client, refetchPlugins]); + useEffect(() => { + if (!client) return; + return client.on("status", (message) => { + if (message.payload.status === "plugin_catalog_changed") void refresh(); + }); + }, [client, refresh]); + const mutation = useMutation({ + mutationFn: async (operation: { + action: string; + pluginId?: string; + run: () => Promise; + }) => operation.run(), + onSuccess: async (message) => { + setFeedback({ kind: "success", message }); + await refresh(); + }, + onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }), + }); + const install = useCallback(() => { + const path = directory.trim(); + if (!client || !path) return; + setFeedback(null); + mutation.mutate({ + action: "install", + run: async () => { + const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined); + setDirectory(""); + setPluginId(""); + setDirectoryResetKey((key) => key + 1); + setPluginIdResetKey((key) => key + 1); + return t("settings.plugins.feedback.installed", { id: installed.id }); + }, + }); + }, [client, directory, mutation, pluginId, t]); + const prefillManifestId = useCallback(() => { + const path = directory.trim(); + if (!client || !path || pluginId.trim()) return; + void client + .inspectDirectoryPlugin(path) + .then(({ id }) => { + setPluginId(id); + setPluginIdResetKey((key) => key + 1); + return undefined; + }) + .catch(() => undefined); + }, [client, directory, pluginId]); + const action = useCallback( + (name: PluginRowAction, plugin: PluginListItem) => { + if (!client) return; + const run = async () => { + if (name === "remove") { + const confirmed = await confirmDialog({ + title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }), + message: t("settings.plugins.removeConfirmMessage"), + confirmLabel: t("settings.plugins.actions.remove"), + destructive: true, + }); + if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id }); + await client.removePlugin(plugin.id); + } else { + await client[`${name}Plugin`](plugin.id); + } + return t(`settings.plugins.feedback.${name}`, { id: plugin.id }); + }; + setFeedback(null); + mutation.mutate({ action: name, pluginId: plugin.id, run }); + }, + [client, mutation, t], + ); + const toggleGlobal = useCallback( + (enabled: boolean) => { + setFeedback(null); + mutation.mutate({ + action: enabled ? "global-enable" : "global-disable", + run: async () => { + await patchConfig({ pluginsEnabled: enabled }); + return enabled + ? t("settings.plugins.feedback.globalEnabled") + : t("settings.plugins.feedback.globalDisabled"); + }, + }); + }, + [mutation, patchConfig, t], + ); + const pageState = resolvePluginPageState({ + connected, + supported, + loading: plugins.isLoading, + error: plugins.isError, + pluginCount: plugins.data?.length ?? 0, + }); + const retry = useCallback(() => { + void refetchPlugins(); + }, [refetchPlugins]); + + if (pageState === "offline") { + return ( + + ); + } + if (pageState === "unsupported") { + return ; + } + + let catalogContent = ; + if (pageState === "error") { + catalogContent = ( + + + + ); + } else if (pageState !== "loading") { + catalogContent = ( + + {plugins.data?.length ? ( + plugins.data.map((plugin) => { + const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id); + const pending = mutation.isPending && mutation.variables?.pluginId === plugin.id; + return ( + + ); + }) + ) : ( + + {t("settings.plugins.states.empty")} + + )} + + ); + } + + return ( + + + + + + + {t("settings.plugins.globalTitle")} + {t("settings.plugins.globalHint")} + + + + + + + + + + + + + + {feedback ? ( + + ) : null} + {catalogContent} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + install: { padding: theme.spacing[4], gap: theme.spacing[3] }, + pluginRow: { + padding: theme.spacing[4], + gap: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, + error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.xs }, + empty: { padding: theme.spacing[4], alignItems: "center" }, +})); diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index 56807d085a..a71ea96668 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => { }); describe("draft-store normalization", () => { + it("preserves plugin resources when hydrating a draft", () => { + const attachment = { + kind: "plugin_resource" as const, + pluginId: "linear", + sourceId: "issues", + sourceTitle: "Linear issue", + sourceIcon: "CircleDot", + item: { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + subtitle: "In progress", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + text: "Linear issue ENG-123: Plugin attachments", + resourceType: "issue", + }, + }; + + expect( + toDraftInputIfReady({ + input: { text: "Implement this", attachments: [attachment] }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }), + ).toEqual({ text: "Implement this", attachments: [attachment] }); + }); + it("preserves uploaded file attachments when hydrating a draft", () => { const attachment = { kind: "file" as const, diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index ee53825ed4..5d9fecd52f 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,6 +3,7 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; +import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments"; import { z } from "zod"; export const DRAFT_STORE_VERSION = 5; @@ -95,6 +96,7 @@ export const UserComposerAttachmentSchema: z.ZodType = z z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), + PluginResourceComposerAttachmentSchema, z.strictObject({ kind: z.literal("github_pr"), item: ChangeRequestItemSchema, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 9250809cc4..3b618b3b66 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -310,6 +310,8 @@ export interface AgentTimelineCursorState { export interface SessionReplicaTimeline { agentId: string; items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; } export interface SessionReplica { @@ -791,6 +793,16 @@ export const useSessionStore = create()( const tasks = latestTasksFromStream(timeline.items); if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks); } + const agentTimelineCursor = new Map(); + const agentTimelineHasOlder = new Map(); + const agentTimelineHasNewer = new Map(); + const agentAuthoritativeHistoryApplied = new Map(); + if (timeline?.range) { + agentTimelineCursor.set(timeline.agentId, timeline.range); + agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder); + agentTimelineHasNewer.set(timeline.agentId, false); + agentAuthoritativeHistoryApplied.set(timeline.agentId, true); + } const agentLastActivity = new Map(prev.agentLastActivity); for (const agent of replica.agents.values()) { agentLastActivity.set(agent.id, agent.lastActivityAt); @@ -808,6 +820,10 @@ export const useSessionStore = create()( hasWorkspaceDirectorySnapshot: true, agentStreamTail, agentTasks, + agentTimelineCursor, + agentTimelineHasOlder, + agentTimelineHasNewer, + agentAuthoritativeHistoryApplied, }, }, agentLastActivity, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts new file mode 100644 index 0000000000..6611b93f8a --- /dev/null +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, it } from "vitest"; +import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished"; +import type { PaseoSubagentRow, ProviderSubagentRow } from "./select"; + +function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow { + return { + kind: "paseo", + id, + provider: "codex", + title: id, + description: null, + subtitle: null, + status, + requiresAttention: false, + createdAt: new Date(), + }; +} + +function provider( + id: string, + status: ProviderSubagentRow["status"] = "completed", +): ProviderSubagentRow { + return { + kind: "provider", + id, + parentAgentId: "parent", + provider: "codex", + title: id, + description: null, + subtitle: null, + status, + requiresAttention: false, + createdAt: new Date(), + }; +} + +function managed( + id: string, + status: ManagedSubagentSnapshot["status"] = "idle", + overrides: Partial = {}, +): ManagedSubagentSnapshot { + return { + id, + status, + parentAgentId: "parent", + archivedAt: null, + ...overrides, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("createArchiveFinishedSubagents", () => { + it("dismisses provider rows before sequential managed archives and reports combined progress", async () => { + const first = deferred(); + const second = deferred(); + const current = new Map([ + ["first", managed("first")], + ["second", managed("second")], + ["resumed", managed("resumed", "running")], + ]); + const events: string[] = []; + const archive = createArchiveFinishedSubagents( + [provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")], + { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: (id) => { + events.push(`archive:${id}`); + return id === "first" ? first.promise : second.promise; + }, + dismissProviderSubagents: (ids) => events.push(`dismiss:${ids.join(",")}`), + }, + ); + const progress: string[] = []; + archive.subscribe(() => { + const status = archive.getState().status; + if (status.kind === "archiving") { + progress.push(`${status.completedCount}/${status.totalCount}`); + } + }); + + const operation = archive.archiveFinished(); + expect(archive.getState()).toEqual({ + eligibleCount: 4, + status: { kind: "archiving", completedCount: 1, totalCount: 4 }, + }); + expect(events).toEqual(["dismiss:native", "archive:first"]); + + first.resolve(); + await Promise.resolve(); + expect(archive.getState()).toEqual({ + eligibleCount: 4, + status: { kind: "archiving", completedCount: 2, totalCount: 4 }, + }); + expect(events).toEqual(["dismiss:native", "archive:first", "archive:second"]); + + second.resolve(); + await expect(operation).resolves.toEqual({ + archivedPaseoIds: ["first", "second"], + dismissedProviderIds: ["native"], + skippedPaseoIds: ["resumed"], + failures: [], + }); + expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]); + expect(archive.getState()).toEqual({ eligibleCount: 4, status: { kind: "idle" } }); + }); + + it("retains failure through optimistic rollback, retries current rows, and resets for a later eligible-set change", async () => { + const first = deferred(); + const second = deferred(); + const retry = deferred(); + const current = new Map([ + ["first", managed("first")], + ["second", managed("second")], + ]); + const responses = [first.promise, second.promise, retry.promise]; + const calls: string[] = []; + const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: (id) => { + calls.push(id); + const response = responses.shift(); + if (!response) throw new Error("Unexpected archive"); + return response; + }, + dismissProviderSubagents: () => undefined, + }); + + const firstAttempt = archive.archiveFinished(); + archive.setRows([paseo("second")]); + const firstError = new Error("first failed"); + first.reject(firstError); + await Promise.resolve(); + second.resolve(); + archive.setRows([]); + await expect(firstAttempt).resolves.toEqual({ + archivedPaseoIds: ["second"], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [{ id: "first", error: firstError }], + }); + + expect(archive.getState()).toEqual({ + eligibleCount: 0, + status: { kind: "failed", failedCount: 1, totalCount: 1 }, + }); + archive.setRows([paseo("first")]); + + expect(archive.getState()).toEqual({ + eligibleCount: 1, + status: { kind: "failed", failedCount: 1, totalCount: 1 }, + }); + expect(calls).toEqual(["first", "second"]); + + archive.setRows([paseo("first", "error")]); + expect(archive.getState()).toEqual({ + eligibleCount: 1, + status: { kind: "failed", failedCount: 1, totalCount: 1 }, + }); + + const retryAttempt = archive.archiveFinished(); + expect(archive.getState()).toEqual({ + eligibleCount: 1, + status: { kind: "archiving", completedCount: 0, totalCount: 1 }, + }); + expect(calls).toEqual(["first", "second", "first"]); + const retryError = new Error("retry failed"); + retry.reject(retryError); + await expect(retryAttempt).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [{ id: "first", error: retryError }], + }); + + expect(archive.getState()).toEqual({ + eligibleCount: 1, + status: { kind: "failed", failedCount: 1, totalCount: 1 }, + }); + archive.setRows([paseo("first"), paseo("later")]); + expect(archive.getState()).toEqual({ eligibleCount: 2, status: { kind: "idle" } }); + }); + + it("keeps retained failure for eligible status changes and row reorder, then resets on membership change", async () => { + const firstError = new Error("first failed"); + const secondError = new Error("second failed"); + const current = new Map([ + ["first", managed("first")], + ["second", managed("second")], + ]); + const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async (id) => { + throw id === "first" ? firstError : secondError; + }, + dismissProviderSubagents: () => undefined, + }); + + const operation = archive.archiveFinished(); + archive.setRows([]); + await operation; + archive.setRows([paseo("first"), paseo("second")]); + + archive.setRows([paseo("second", "error"), paseo("first", "error")]); + expect(archive.getState()).toEqual({ + eligibleCount: 2, + status: { kind: "failed", failedCount: 2, totalCount: 2 }, + }); + + archive.setRows([paseo("second", "error")]); + expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } }); + }); + + it("clears retry state when the authoritative failed child is running", async () => { + const current = new Map([["failed", managed("failed")]]); + const archive = createArchiveFinishedSubagents([paseo("failed")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async () => { + current.set("failed", managed("failed", "running")); + throw new Error("archive failed"); + }, + dismissProviderSubagents: () => undefined, + }); + + const operation = archive.archiveFinished(); + archive.setRows([]); + await operation; + archive.setRows([paseo("failed", "running")]); + + expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } }); + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [], + }); + }); + + it("clears retry state when the authoritative failed child is initializing", async () => { + const current = new Map([["failed", managed("failed")]]); + const archive = createArchiveFinishedSubagents([paseo("failed")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async () => { + current.set("failed", managed("failed", "initializing")); + throw new Error("archive failed"); + }, + dismissProviderSubagents: () => undefined, + }); + + const operation = archive.archiveFinished(); + archive.setRows([]); + await operation; + archive.setRows([paseo("failed", "initializing")]); + + expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } }); + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [], + }); + }); + + it.each([ + ["is missing", (current: Map) => current.delete("failed")], + [ + "is archived", + (current: Map) => + current.set("failed", managed("failed", "idle", { archivedAt: new Date() })), + ], + [ + "belongs to a different parent", + (current: Map) => + current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })), + ], + ])( + "clears retry state when the authoritative failed child %s", + async (_label, updateSnapshot) => { + const error = new Error("archive failed"); + const current = new Map([["failed", managed("failed")]]); + const archive = createArchiveFinishedSubagents([paseo("failed")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async () => { + updateSnapshot(current); + throw error; + }, + dismissProviderSubagents: () => undefined, + }); + + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [{ id: "failed", error }], + }); + expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } }); + }, + ); + + it("retains only eligible rolled-back failures and retries only those children", async () => { + const firstError = new Error("first failed"); + const secondError = new Error("second failed"); + const current = new Map([ + ["first", managed("first")], + ["second", managed("second")], + ]); + const calls: string[] = []; + const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async (id) => { + calls.push(id); + if (calls.length === 1) throw firstError; + if (calls.length === 2) { + current.set("second", managed("second", "running")); + throw secondError; + } + }, + dismissProviderSubagents: () => undefined, + }); + + const operation = archive.archiveFinished(); + archive.setRows([]); + await expect(operation).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [ + { id: "first", error: firstError }, + { id: "second", error: secondError }, + ], + }); + archive.setRows([paseo("first"), paseo("second", "running")]); + + expect(archive.getState()).toEqual({ + eligibleCount: 1, + status: { kind: "failed", failedCount: 1, totalCount: 1 }, + }); + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: ["first"], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [], + }); + expect(calls).toEqual(["first", "second", "first"]); + }); + + it("rechecks each managed child and skips resumed, missing, archived, reparented, running, and initializing rows", async () => { + const rows = [ + paseo("first"), + paseo("resumed"), + paseo("missing"), + paseo("archived"), + paseo("reparented"), + paseo("running"), + paseo("initializing"), + ]; + const current = new Map([ + ["first", managed("first")], + ["resumed", managed("resumed")], + ["archived", managed("archived", "idle", { archivedAt: new Date() })], + ["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })], + ["running", managed("running", "running")], + ["initializing", managed("initializing", "initializing")], + ]); + const archived: string[] = []; + const archive = createArchiveFinishedSubagents(rows, { + parentAgentId: "parent", + getManagedSubagent: (id) => current.get(id), + archiveManagedSubagent: async (id) => { + archived.push(id); + current.set("resumed", managed("resumed", "running")); + }, + dismissProviderSubagents: () => undefined, + }); + + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: ["first"], + dismissedProviderIds: [], + skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"], + failures: [], + }); + + expect(archived).toEqual(["first"]); + expect(archive.getState()).toEqual({ eligibleCount: 7, status: { kind: "idle" } }); + }); + + it("dismisses finished provider rows locally without removing their descriptors", async () => { + const descriptors = new Map([ + ["finished", provider("finished")], + ["running", provider("running", "running")], + ]); + const dismissed: string[][] = []; + const archive = createArchiveFinishedSubagents([...descriptors.values()], { + parentAgentId: "parent", + getManagedSubagent: () => undefined, + archiveManagedSubagent: async () => undefined, + dismissProviderSubagents: (ids) => dismissed.push(ids), + }); + + await expect(archive.archiveFinished()).resolves.toEqual({ + archivedPaseoIds: [], + dismissedProviderIds: ["finished"], + skippedPaseoIds: [], + failures: [], + }); + + expect(dismissed).toEqual([["finished"]]); + expect(descriptors.get("finished")).toEqual(provider("finished")); + expect(descriptors.get("running")).toEqual(provider("running", "running")); + }); +}); diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts new file mode 100644 index 0000000000..0f3c685591 --- /dev/null +++ b/packages/app/src/subagents/archive-finished.ts @@ -0,0 +1,203 @@ +import type { Agent } from "@/stores/session-store"; +import type { SubagentRow } from "./select"; + +export type ArchiveFinishedStatus = + | { kind: "idle" } + | { kind: "archiving"; completedCount: number; totalCount: number } + | { kind: "failed"; failedCount: number; totalCount: number }; + +export interface ArchiveFinishedState { + eligibleCount: number; + status: ArchiveFinishedStatus; +} + +export interface ArchiveFinishedOutcome { + archivedPaseoIds: string[]; + dismissedProviderIds: string[]; + skippedPaseoIds: string[]; + failures: Array<{ id: string; error: unknown }>; +} + +export type ManagedSubagentSnapshot = Pick; + +export interface ArchiveFinishedSubagentsDeps { + parentAgentId: string; + getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined; + archiveManagedSubagent: (id: string) => Promise; + dismissProviderSubagents: (ids: string[]) => void; +} + +export interface ArchiveFinishedSubagents { + getState(): ArchiveFinishedState; + setRows(rows: readonly SubagentRow[]): void; + subscribe(listener: () => void): () => void; + archiveFinished(): Promise; +} + +export function isFinishedSubagent(row: SubagentRow): boolean { + if (row.kind === "paseo") return row.status === "idle" || row.status === "error"; + return row.status === "completed" || row.status === "failed" || row.status === "canceled"; +} + +function canArchiveManagedSubagent( + agent: ManagedSubagentSnapshot | undefined, + parentAgentId: string, +): boolean { + return Boolean( + agent && + !agent.archivedAt && + agent.parentAgentId === parentAgentId && + (agent.status === "idle" || agent.status === "error"), + ); +} + +function eligibleSignature(rows: readonly SubagentRow[]): string { + return identitySignature(rowIdentities(rows)); +} + +function managedRowIdentity(id: string): string { + return `paseo:${id}`; +} + +function rowIdentity(row: SubagentRow): string { + return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`; +} + +function rowIdentities(rows: readonly SubagentRow[]): Set { + return new Set(rows.map(rowIdentity)); +} + +function identitySignature(ids: ReadonlySet): string { + return [...ids].sort().join("\0"); +} + +function emptyOutcome(): ArchiveFinishedOutcome { + return { + archivedPaseoIds: [], + dismissedProviderIds: [], + skippedPaseoIds: [], + failures: [], + }; +} + +export function createArchiveFinishedSubagents( + initialRows: readonly SubagentRow[], + deps: ArchiveFinishedSubagentsDeps, +): ArchiveFinishedSubagents { + let eligibleRows = initialRows.filter(isFinishedSubagent); + let signature = eligibleSignature(eligibleRows); + let failedSignature: string | null = null; + let state: ArchiveFinishedState = { + eligibleCount: eligibleRows.length, + status: { kind: "idle" }, + }; + const listeners = new Set<() => void>(); + + const publish = () => { + for (const listener of listeners) listener(); + }; + const setState = (next: ArchiveFinishedState) => { + state = next; + publish(); + }; + + const setRows = (rows: readonly SubagentRow[]) => { + const nextEligibleRows = rows.filter(isFinishedSubagent); + const nextSignature = eligibleSignature(nextEligibleRows); + const eligibilityChanged = signature !== nextSignature; + eligibleRows = nextEligibleRows; + signature = nextSignature; + + if (state.status.kind === "failed" && failedSignature !== signature) { + failedSignature = null; + setState({ eligibleCount: eligibleRows.length, status: { kind: "idle" } }); + return; + } + if (state.eligibleCount !== eligibleRows.length || eligibilityChanged) { + setState({ ...state, eligibleCount: eligibleRows.length }); + } + }; + + const archiveFinished = () => { + if (state.status.kind === "archiving" || eligibleRows.length === 0) { + return Promise.resolve(emptyOutcome()); + } + + const snapshot = eligibleRows; + const totalCount = snapshot.length; + setState({ + eligibleCount: eligibleRows.length, + status: { kind: "archiving", completedCount: 0, totalCount }, + }); + return runArchiveFinished(snapshot, deps, (completedCount) => { + setState({ + eligibleCount: eligibleRows.length, + status: { kind: "archiving", completedCount, totalCount }, + }); + }).then(({ outcome, retryableFailureIds }) => { + failedSignature = + retryableFailureIds.size > 0 ? identitySignature(retryableFailureIds) : null; + setState({ + eligibleCount: eligibleRows.length, + status: + retryableFailureIds.size > 0 + ? { + kind: "failed", + failedCount: retryableFailureIds.size, + totalCount: retryableFailureIds.size, + } + : { kind: "idle" }, + }); + return outcome; + }); + }; + + return { + getState: () => state, + setRows, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + archiveFinished, + }; +} + +async function runArchiveFinished( + rows: readonly SubagentRow[], + deps: ArchiveFinishedSubagentsDeps, + reportProgress: (completedCount: number) => void, +): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set }> { + const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id); + const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id); + let completedCount = 0; + const outcome = emptyOutcome(); + const retryableFailureIds = new Set(); + + if (providerIds.length > 0) { + deps.dismissProviderSubagents(providerIds); + outcome.dismissedProviderIds.push(...providerIds); + completedCount += providerIds.length; + reportProgress(completedCount); + } + + for (const id of paseoIds) { + if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { + try { + await deps.archiveManagedSubagent(id); + outcome.archivedPaseoIds.push(id); + } catch (error) { + outcome.failures.push({ id, error }); + if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { + retryableFailureIds.add(managedRowIdentity(id)); + } + } + } else { + outcome.skippedPaseoIds.push(id); + } + completedCount += 1; + reportProgress(completedCount); + } + + return { outcome, retryableFailureIds }; +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 959e54b696..e93d62eb2d 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -2,9 +2,10 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; -export { - useHideFinishedProviderSubagents, - type UseHideFinishedProviderSubagentsInput, -} from "./use-hide-finished-provider-subagents"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { isWorkspaceRootAgent } from "./workspace-root-policy"; +export { + useArchiveFinishedSubagents, + type ArchiveFinishedStatus, + type UseArchiveFinishedSubagentsInput, +} from "./use-archive-finished"; diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index d14edbd9b7..ea863ea37a 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -160,7 +160,7 @@ describe("provider subagent client store", () => { item: { type: "assistant_message", text: "Finished output." }, }); - store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); const state = useProviderSubagentStore.getState(); const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); @@ -185,7 +185,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }; store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); - store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); store.replaceList(SERVER_ID, PARENT_ID, [completed]); const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); @@ -215,7 +215,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }, }); - store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); store.replaceList(SERVER_ID, PARENT_ID, []); @@ -239,7 +239,7 @@ describe("provider subagent client store", () => { toolCallId: "call-1", }; store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); - store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]); store.applyUpdate(SERVER_ID, { kind: "remove", parentAgentId: PARENT_ID, diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 6bd8b90f25..15a9eb9b71 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -33,7 +33,7 @@ interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; - hideFinishedForParent(serverId: string, parentAgentId: string): void; + hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, parentAgentId: string, @@ -196,14 +196,12 @@ export const useProviderSubagentStore = create((set) => ( descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), - hideFinishedForParent(serverId, parentAgentId) { + hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { - const prefix = parentPrefix(serverId, parentAgentId); const hiddenFromTrack = new Set(state.hiddenFromTrack); - for (const [key, subagent] of state.descriptors) { - if (key.startsWith(prefix) && subagent.status !== "running") { - hiddenFromTrack.add(key); - } + for (const subagentId of subagentIds) { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key); } return { hiddenFromTrack }; }); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 6d5b38e651..5229acf88d 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -116,7 +116,7 @@ describe("selectSubagentsForParent", () => { toolCallId: "call-1", }, }); - store.hideFinishedForParent(SERVER_ID, "parent-a"); + store.hideFromTrack(SERVER_ID, "parent-a", ["provider-child"]); expect( selectProviderSubagentsForParent( diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 0dfd1b3cf7..75574a182c 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -76,7 +76,7 @@ describe("formatHeaderLabel", () => { }); describe("countFinishedSubagents", () => { - it("counts only terminal provider-owned children", () => { + it("counts eligible managed and terminal provider-owned children", () => { const providerRows: SubagentRow[] = [ { kind: "provider", @@ -110,6 +110,16 @@ describe("countFinishedSubagents", () => { row({ id: "managed-idle", status: "idle" }), ...providerRows, ]), + ).toBe(2); + }); + + it("excludes running and initializing managed children", () => { + expect( + countFinishedSubagents([ + row({ id: "running", status: "running" }), + row({ id: "initializing", status: "initializing" }), + row({ id: "finished", status: "idle" }), + ]), ).toBe(1); }); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index b1a0fc3b75..a78c1ecdf8 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,6 +1,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { @@ -55,7 +56,7 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { } export function countFinishedSubagents(rows: readonly SubagentRow[]): number { - return rows.filter((row) => row.kind === "provider" && row.status !== "running").length; + return rows.filter(isFinishedSubagent).length; } export function resolveRowLabel(title: string | null | undefined): string | null { diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 8878851da1..33f9efe571 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -14,6 +14,7 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; +import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { buildSubagentRowPresentationData, countFinishedSubagents, @@ -34,6 +35,7 @@ export interface SubagentsTrackProps { onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; + archiveFinishedStatus?: ArchiveFinishedStatus; onDetachSubagent?: (id: string) => void; } @@ -55,6 +57,7 @@ export function SubagentsTrack({ onOpenProviderSubagent, onArchiveSubagent, onArchiveFinished, + archiveFinishedStatus = { kind: "idle" }, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { const { t } = useTranslation(); @@ -75,12 +78,15 @@ export function SubagentsTrack({ ); const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]); - if (rows.length === 0) { + const isArchivingFinished = archiveFinishedStatus.kind === "archiving"; + const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed"; + if (rows.length === 0 && !isArchivingFinished && !isArchiveFinishedFailed) { return null; } const headerLabel = formatHeaderLabel(rows); const finishedCount = countFinishedSubagents(rows); + const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed; return ( @@ -100,7 +106,7 @@ export function SubagentsTrack({ > {headerLabel} - {finishedCount > 0 && onArchiveFinished ? ( + {showArchiveFinished && onArchiveFinished ? ( + {archiveFinishedStatus.kind === "archiving" ? ( + + {archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount} + + ) : null} + {archiveFinishedStatus.kind === "failed" ? ( + + {t("subagents.archiveFinishedRetry", { + failed: archiveFinishedStatus.failedCount, + total: archiveFinishedStatus.totalCount, + })} + + ) : null} ) : null} @@ -275,6 +301,7 @@ function SubagentActionButton({ tooltipLabel, icon, visible, + disabled = false, onPress, }: { accessibilityLabel: string; @@ -282,15 +309,17 @@ function SubagentActionButton({ tooltipLabel: string; icon: SubagentActionIcon; visible: boolean; + disabled?: boolean; onPress: () => void; }): ReactElement { return ( - + ({ }, headerAction: { paddingRight: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + archiveFinishedStatus: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, }, headerCollapsed: { paddingBottom: theme.spacing[4], diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts new file mode 100644 index 0000000000..e7653f054e --- /dev/null +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -0,0 +1,54 @@ +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useSessionStore } from "@/stores/session-store"; +import { + createArchiveFinishedSubagents, + type ArchiveFinishedOutcome, + type ArchiveFinishedState, + type ArchiveFinishedSubagents, +} from "./archive-finished"; +import { useProviderSubagentStore } from "./provider-store"; +import type { SubagentRow } from "./select"; + +export type { ArchiveFinishedStatus } from "./archive-finished"; + +export interface UseArchiveFinishedSubagentsInput { + serverId: string; + parentAgentId: string; + rows: readonly SubagentRow[]; +} + +export interface ArchiveFinishedSubagentsCapability extends ArchiveFinishedState { + archiveFinished: () => Promise; +} + +export function useArchiveFinishedSubagents({ + serverId, + parentAgentId, + rows, +}: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability { + const { archiveAgent } = useArchiveAgent(); + const archiveFinished = useMemo( + () => + createArchiveFinishedSubagents([], { + parentAgentId, + getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), + archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }), + dismissProviderSubagents: (ids) => { + useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids); + }, + }), + [archiveAgent, parentAgentId, serverId], + ); + const state = useSyncExternalStore( + archiveFinished.subscribe, + archiveFinished.getState, + archiveFinished.getState, + ); + + useEffect(() => { + archiveFinished.setRows(rows); + }, [archiveFinished, rows]); + + return { ...state, archiveFinished: archiveFinished.archiveFinished }; +} diff --git a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts deleted file mode 100644 index 7058fc8f17..0000000000 --- a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from "react"; -import { useProviderSubagentStore } from "./provider-store"; - -export interface UseHideFinishedProviderSubagentsInput { - serverId: string; - parentAgentId: string; -} - -export function useHideFinishedProviderSubagents({ - serverId, - parentAgentId, -}: UseHideFinishedProviderSubagentsInput): () => void { - return useCallback(() => { - useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId); - }, [parentAgentId, serverId]); -} diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 99a987a48b..9135b5fb85 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -5,6 +5,7 @@ import { isTimelineResumeSnapshotAuthoritative, planTimelineOlderFetch, planTimelinePromptJump, + planTimelineResumeFetch, planTimelineTailFetch, } from "./timeline-sync-plan"; @@ -19,6 +20,15 @@ describe("timeline sync planning", () => { }); }); + test("a restored canonical range catches up after its exact end", () => { + expect(planTimelineResumeFetch({ epoch: "epoch-1", endSeq: 49 })).toEqual({ + direction: "after", + cursor: { epoch: "epoch-1", seq: 49 }, + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }); + }); + test("older history loads one bounded page before the start cursor", () => { const plan = planTimelineOlderFetch({ epoch: "epoch-1", seq: 25 }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index 800cbc0070..e5e8b2fb95 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -51,6 +51,14 @@ export function planTimelineTailFetch() { } as const; } +export function planTimelineResumeFetch( + range: { epoch: string; endSeq: number } | null | undefined, +): ProjectedTimelineForwardFetchPlan { + return range + ? planTimelineCatchUpAfter({ epoch: range.epoch, seq: range.endSeq }) + : planTimelineTailFetch(); +} + export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { return { direction: "before", diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 55bfa8a001..a114ea2afe 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -33,6 +33,7 @@ interface TimelineFetch { class TimelineWorld { readonly errors: string[] = []; + readonly cursors = new Map(); readonly sync = createViewedTimelineSync({ initialDeliveryMode: "selective", setSubscription: async (agentIds) => { @@ -45,6 +46,7 @@ class TimelineWorld { this.releaseMembershipWaiter(); return result.promise; }, + readCursor: (agentId) => this.cursors.get(agentId), fetchPage: async (agentId, request) => { const result = deferred<{ hasNewer: boolean; @@ -63,6 +65,12 @@ class TimelineWorld { this.releaseFetchWaiters(); return result.promise; }, + fetchLatestTail: (agentId) => + this.fetchTimeline(agentId, { + direction: "tail", + limit: 40, + projection: "projected", + }), reportError: (error) => { this.errors.push(error instanceof Error ? error.message : String(error)); const waiter = this.errorWaiters.shift(); @@ -94,6 +102,25 @@ class TimelineWorld { private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; private readonly retryWaiters: Array<(retry: () => void) => void> = []; + private fetchTimeline( + agentId: string, + request: ProjectedTimelineForwardFetchPlan, + ): Promise<{ hasNewer: boolean; endCursor: { epoch: string; seq: number } | null }> { + const result = deferred<{ + hasNewer: boolean; + endCursor: { epoch: string; seq: number } | null; + }>(); + this.fetches.push({ + agentId, + request, + respond: ({ hasNewer, seq = 1 }) => + result.resolve({ hasNewer, endCursor: { epoch: `epoch-${agentId}`, seq } }), + fail: (message) => result.reject(new Error(message)), + }); + this.releaseFetchWaiters(); + return result.promise; + } + nextMembership(): Promise { const request = this.memberships.shift(); if (request) return Promise.resolve(request); @@ -166,6 +193,41 @@ test("uses a tail fetch when an agent becomes visible", async () => { fetch.respond({ hasNewer: false }); }); +test("catches up after the restored cursor when an agent becomes visible", async () => { + const world = new TimelineWorld(); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const fetch = await world.nextFetch("agent-a"); + expect(fetch.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 42 }, + limit: 40, + projection: "projected", + }); + fetch.respond({ hasNewer: false }); +}); + +test("falls back to the latest tail when a restored cursor has more than one catch-up page", async () => { + const world = new TimelineWorld(); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const probe = await world.nextFetch("agent-a"); + expect(probe.request.direction).toBe("after"); + probe.respond({ hasNewer: true, seq: 82 }); + + const fallback = await world.nextFetch("agent-a"); + expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + fallback.respond({ hasNewer: false }); +}); + test("a gap absorbed by a running tail is recovered after the tail completes", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index dce143d15e..4feed346b7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,6 +1,6 @@ import { planTimelineCatchUpAfter, - planTimelineTailFetch, + planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; @@ -12,10 +12,12 @@ interface TimelinePageResult { interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; setSubscription(agentIds: string[]): Promise; + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; fetchPage( agentId: string, request: ProjectedTimelineForwardFetchPlan, ): Promise; + fetchLatestTail(agentId: string): Promise; reportError(error: unknown): void; schedule(task: () => void, delayMs: number): () => void; } @@ -136,6 +138,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const isAcknowledged = (agentId: string) => acknowledged.includes(agentId); const isDesired = (agentId: string) => desired.includes(agentId); + const ownsCatchUp = (agentId: string, generation: number) => + !disposed && + connected && + isDesired(agentId) && + isAcknowledged(agentId) && + catchUps.get(agentId)?.generation === generation; const notifyListeners = () => { for (const listener of listeners) listener(); @@ -168,30 +176,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed agentId: string, generation: number, request: ProjectedTimelineForwardFetchPlan, + fallbackToLatestTailOnOverflow: boolean, ): Promise => { - if ( - disposed || - !connected || - !isDesired(agentId) || - !isAcknowledged(agentId) || - catchUps.get(agentId)?.generation !== generation - ) { - return; - } + if (!ownsCatchUp(agentId, generation)) return; try { const page = await ports.fetchPage(agentId, request); - if ( - disposed || - !connected || - !isDesired(agentId) || - !isAcknowledged(agentId) || - catchUps.get(agentId)?.generation !== generation - ) { - return; - } + if (!ownsCatchUp(agentId, generation)) return; if (page.hasNewer && page.endCursor) { - await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor)); + if (fallbackToLatestTailOnOverflow) { + await ports.fetchLatestTail(agentId); + catchUps.set(agentId, { generation, status: "complete" }); + setVisibilityCatchUpReady(agentId); + return; + } + await fetchUntilCurrent( + agentId, + generation, + planTimelineCatchUpAfter(page.endCursor), + false, + ); return; } if (page.hasNewer) { @@ -230,7 +234,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (request) pendingCatchUps.set(agentId, request); return; } - const nextRequest = request ?? planTimelineTailFetch(); + const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId)); const current = catchUps.get(agentId); const decision = decideCatchUp({ current, request: nextRequest, supersede }); if (decision === "keep-and-park") { @@ -245,7 +249,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed catchUpGenerations.set(agentId, generation); catchUps.set(agentId, { generation, status: "running", request: nextRequest }); pendingCatchUps.delete(agentId); - void fetchUntilCurrent(agentId, generation, nextRequest); + void fetchUntilCurrent( + agentId, + generation, + nextRequest, + request === undefined && nextRequest.direction === "after", + ); }; const startAcknowledgedCatchUps = () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 87e1517884..612d0f0023 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -517,6 +517,7 @@ export const HOST_SECTION_SLUGS = [ "providers", "usage", "terminals", + "plugins", "host", ] as const; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 4ec936feb0..d63acf56b4 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -7,14 +7,28 @@ const testTheme = { statusDanger: "#b91c1c", statusWarning: "#d97706", statusMerged: "#7c3aed", + accent: "#2563eb", + accentForeground: "#ffffff", + destructive: "#dc2626", + destructiveForeground: "#ffffff", surface1: "#fafafa", surface2: "#f4f4f5", + surface3: "#e4e4e7", border: "#e4e4e7", + borderAccent: "#a1a1aa", + palette: { + amber: { 500: "#f59e0b" }, + blue: { 300: "#93c5fd" }, + red: { 300: "#fca5a5" }, + white: "#ffffff", + }, }, + borderWidth: { 1: 1 }, spacing: [0, 4, 8, 12, 16, 20, 24, 28, 32], fontSize: { xs: 12, sm: 14, + base: 16, }, fontWeight: { normal: "400", @@ -23,7 +37,12 @@ const testTheme = { borderRadius: { base: 4, md: 6, + lg: 8, + xl: 12, + full: 9999, }, + iconSize: { sm: 16 }, + opacity: { 50: 0.5 }, }; type StyleFactory = (theme: typeof testTheme) => T; diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index c480fe824f..489dbc2571 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -55,4 +55,24 @@ describe("canonical CLI surface", () => { expect(open?.helpInformation()).toContain(""); expect(open?.helpInformation()).toContain("--server "); }); + + it("offers the complete local plugin lifecycle", () => { + const plugin = createCli().commands.find((command) => command.name() === "plugin"); + + expect(plugin?.commands.map((command) => command.name())).toEqual([ + "init", + "ls", + "install", + "reload", + "enable", + "disable", + "remove", + ]); + expect( + plugin?.commands.find((command) => command.name() === "init")?.helpInformation(), + ).toContain("--id "); + expect( + plugin?.commands.find((command) => command.name() === "install")?.helpInformation(), + ).toContain("--id "); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cb93b810a4..cf12a74263 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; +import { createPluginCommand } from "./commands/plugin/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createScriptCommand } from "./commands/script/index.js"; @@ -182,6 +183,7 @@ export function createCli(): Command { // Provider commands program.addCommand(createProviderCommand()); + program.addCommand(createPluginCommand()); // Speech model commands program.addCommand(createSpeechCommand()); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts new file mode 100644 index 0000000000..14b8b52348 --- /dev/null +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const listPlugins = vi.fn(async () => []); +const close = vi.fn(async () => undefined); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + getLastServerInfoMessage: () => ({ features: { pluginManagement: false } }), + listPlugins, + close, + })), +})); + +import { runPluginListCommand } from "./index.js"; + +describe("plugin management commands", () => { + it("requires host support before attempting a management RPC", async () => { + await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use plugin management.", + }); + expect(listPlugins).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts new file mode 100644 index 0000000000..8c41cd111c --- /dev/null +++ b/packages/cli/src/commands/plugin/index.ts @@ -0,0 +1,121 @@ +import { Command } from "commander"; +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; +import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js"; +import { withPluginManagementClient } from "./shared.js"; + +interface PluginOptions extends CommandOptions { + host?: string; + id?: string; +} + +const pluginSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "PLUGIN", field: "id", width: 20 }, + { header: "STATUS", field: "status", width: 10 }, + { header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, + { header: "DIRECTORY", field: "path", width: 40 }, + { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, + ], +}; + +const scaffoldSchema: OutputSchema = { + idField: "id", + columns: [ + { header: "PLUGIN", field: "id", width: 20 }, + { header: "DIRECTORY", field: "directory", width: 60 }, + ], +}; + +export async function runPluginInitCommand( + directory: string, + options: PluginOptions, + _command: Command, +): Promise> { + return { + type: "single", + data: await scaffoldPluginDirectory(directory, options.id), + schema: scaffoldSchema, + }; +} + +export async function runPluginListCommand( + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + return { type: "list", data, schema: pluginSchema }; +} + +async function install( + directory: string, + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => + client.installDirectoryPlugin(directory, options.id), + ); + return { type: "single", data, schema: pluginSchema }; +} + +async function act( + action: "reload" | "enable" | "disable", + pluginId: string, + options: PluginOptions, +): Promise> { + const data = await withPluginManagementClient(options.host, (client) => + client[`${action}Plugin`](pluginId), + ); + return { type: "single", data, schema: pluginSchema }; +} + +async function remove( + pluginId: string, + options: PluginOptions, + _command: Command, +): Promise> { + const data = await withPluginManagementClient(options.host, async (client) => { + const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); + if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); + await client.removePlugin(pluginId); + return { ...current, enabled: false, status: "disabled" as const }; + }); + return { type: "single", data, schema: pluginSchema }; +} + +export function createPluginCommand(): Command { + const plugin = new Command("plugin").description("Manage trusted local plugins"); + addJsonOption( + plugin + .command("init") + .description("Create a typecheckable local plugin") + .argument("") + .option("--id ", "Manifest plugin ID (defaults to the directory name)"), + ).action(withOutput(runPluginInitCommand)); + addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action( + withOutput(runPluginListCommand), + ); + addJsonAndDaemonHostOptions( + plugin + .command("install") + .description("Install a local plugin directory") + .argument("", "Host filesystem directory") + .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)"), + ).action(withOutput(install)); + for (const action of ["reload", "enable", "disable"] as const) { + addJsonAndDaemonHostOptions( + plugin.command(action).description(`${action} a local plugin`).argument(""), + ).action( + withOutput((id: string, options: PluginOptions, _command: Command) => + act(action, id, options), + ), + ); + } + addJsonAndDaemonHostOptions( + plugin.command("remove").description("Remove plugin configuration").argument(""), + ).action(withOutput(remove)); + return plugin; +} diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts new file mode 100644 index 0000000000..02bb3d1566 --- /dev/null +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import ts from "typescript"; +import { afterEach, describe, expect, it } from "vitest"; +import { scaffoldPluginDirectory } from "./scaffold.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("plugin scaffold", () => { + it("creates a standalone strict TSX project that typechecks", async () => { + const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + + await scaffoldPluginDirectory(directory); + + const configPath = path.join(directory, "tsconfig.json"); + const loaded = ts.readConfigFile(configPath, ts.sys.readFile); + expect(loaded.error).toBeUndefined(); + const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory); + const diagnostics = ts.getPreEmitDiagnostics( + ts.createProgram(parsed.fileNames, parsed.options), + ); + expect( + diagnostics.map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); + expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({ + id: "hello-plugin", + }); + }); + + it("refuses to write into a non-empty directory", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(directory); + await writeFile(path.join(directory, "notes.txt"), "keep me"); + + await expect(scaffoldPluginDirectory(directory, "hello-plugin")).rejects.toThrow( + "Plugin directory must be empty", + ); + expect(await readFile(path.join(directory, "notes.txt"), "utf8")).toBe("keep me"); + }); +}); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts new file mode 100644 index 0000000000..dc107dd6c1 --- /dev/null +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -0,0 +1,167 @@ +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { PluginIdSchema } from "@getpaseo/protocol/messages"; + +const SDK_DECLARATIONS = `declare module "@paseo/plugin" { + import type { ComponentType } from "react"; + import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + + export interface PluginSurfaceProps { + theme: Record; + host: { id: string; label: string }; + layout: { compact: boolean; platform: "ios" | "android" | "web" }; + } + + export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, + > { + name: string; + input: InputSchema; + output: OutputSchema; + } + + export interface PluginAttachmentItem { + id: string; + identifier: string; + title: string; + subtitle?: string; + url: string; + text: string; + resourceType: string; + } + + export interface PluginAttachmentSearchPayload { + items: PluginAttachmentItem[]; + } + + export interface PluginAttachmentSourceContribution { + id: string; + title: string; + icon: string; + pickerTitle: string; + searchPlaceholder: string; + search: PluginRpcContract; + } + + export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; + } + + export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; + } + + export interface PluginContext { + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + ) => ZodInput | Promise>, + ): void; + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; + } + + export type PluginCleanup = () => void; + export type PluginContribution = (plugin: PluginContext) => PluginCleanup; + + export function defineRpc(definition: { + name: string; + input: InputSchema; + output: OutputSchema; + }): PluginRpcContract; + + export function useRpc( + contract: PluginRpcContract, + ): (input: ZodInput) => Promise>; + + export function defineAttachmentSource( + definition: Definition, + ): Definition; + + export const PluginAttachmentItemSchema: import("zod").ZodType; + export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType; +} +`; + +const TSCONFIG = { + compilerOptions: { + target: "ES2020", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["ES2023", "DOM"], + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + include: ["index.tsx", "paseo-plugin.d.ts"], +}; + +const ENTRY = `import type { PluginContext } from "@paseo/plugin"; +import React from "react"; +import { Text } from "react-native"; + +function MainSurface() { + return Hello from my plugin; +} + +export default function contribute(plugin: PluginContext) { + plugin.addSurface("main", MainSurface); + return () => undefined; +} +`; + +export interface PluginScaffold { + id: string; + directory: string; +} + +export async function scaffoldPluginDirectory( + targetDirectory: string, + requestedId?: string, +): Promise { + const directory = path.resolve(targetDirectory); + const id = PluginIdSchema.parse(requestedId ?? path.basename(directory)); + await mkdir(directory, { recursive: true }); + const existing = await readdir(directory); + if (existing.length > 0) { + throw new Error(`Plugin directory must be empty: ${directory}`); + } + + const packageJson = { + name: id, + private: true, + version: "0.0.0", + scripts: { typecheck: "tsc --noEmit" }, + devDependencies: { + "@tanstack/react-query": "^5.90.11", + "@types/react": "~19.2.0", + react: "19.1.0", + "react-native": "0.81.5", + typescript: "^5.9.3", + zod: "^4.4.3", + }, + }; + const files = new Map([ + ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], + ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], + ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], + ["paseo-plugin.d.ts", SDK_DECLARATIONS], + ["index.tsx", ENTRY], + ]); + await Promise.all( + [...files].map(([filename, contents]) => + writeFile(path.join(directory, filename), contents, { flag: "wx" }), + ), + ); + return { id, directory }; +} diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts new file mode 100644 index 0000000000..e6e9f92bef --- /dev/null +++ b/packages/cli/src/commands/plugin/shared.ts @@ -0,0 +1,23 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { CommandError } from "../../output/index.js"; +import { connectToDaemon } from "../../utils/client.js"; + +export async function withPluginManagementClient( + host: string | undefined, + run: (client: DaemonClient) => Promise, +): Promise { + const client = await connectToDaemon({ host }); + // COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14. + if (client.getLastServerInfoMessage()?.features?.pluginManagement !== true) { + await client.close().catch(() => undefined); + throw { + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use plugin management.", + } satisfies CommandError; + } + try { + return await run(client); + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts new file mode 100644 index 0000000000..51ecfcf94d --- /dev/null +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { connectToDaemon } from "../../src/utils/client.ts"; +import { createE2ETestContext } from "../helpers/test-daemon.ts"; + +const pluginSource = `export default function contribute(plugin: unknown) { + void plugin; + return () => undefined; +}`; + +async function main(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); + const context = await createE2ETestContext({ timeout: 45_000 }); + try { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" })); + await writeFile(path.join(directory, "index.tsx"), pluginSource); + + const install = await context.paseo(["plugin", "install", directory, "--json"]); + assert.equal(install.exitCode, 0, install.stderr); + assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); + + const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.close(); + + const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]); + assert.equal(reload.exitCode, 0, reload.stderr); + assert.equal(JSON.parse(reload.stdout).status, "running"); + + const disable = await context.paseo(["plugin", "disable", "cli-e2e", "--json"]); + assert.equal(disable.exitCode, 0, disable.stderr); + assert.equal(JSON.parse(disable.stdout).status, "disabled"); + + const enable = await context.paseo(["plugin", "enable", "cli-e2e", "--json"]); + assert.equal(enable.exitCode, 0, enable.stderr); + assert.equal(JSON.parse(enable.stdout).status, "running"); + + const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]); + assert.equal(remove.exitCode, 0, remove.stderr); + const list = await context.paseo(["plugin", "ls", "--json"]); + assert.equal(list.exitCode, 0, list.stderr); + assert.deepEqual(JSON.parse(list.stdout), []); + } finally { + await context.stop(); + await rm(directory, { recursive: true, force: true }); + } +} + +await main(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4ba76be16e..cf62055850 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -103,6 +103,7 @@ import type { WorkspaceRecoveryState, WorkspaceRuntimeListPayload, WorkspaceRuntimeEnsureProbePayload, + PluginListItem, } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest, @@ -5056,6 +5057,95 @@ export class DaemonClient { }); } + async getPluginCatalog(): Promise> { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.catalog.get.request", requestId }, + responseType: "plugin.catalog.get.response", + }); + return payload.plugins; + } + + async listPlugins(): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.list.request", requestId }, + responseType: "plugin.list.response", + }); + return payload.plugins; + } + + async installDirectoryPlugin(path: string, id?: string): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.directory.install.request", requestId, path, ...(id ? { id } : {}) }, + responseType: "plugin.directory.install.response", + }); + return payload.plugin; + } + + async inspectDirectoryPlugin(path: string): Promise<{ id: string }> { + const requestId = this.createRequestId(); + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.directory.inspect.request", requestId, path }, + responseType: "plugin.directory.inspect.response", + }); + } + + async reloadPlugin(pluginId: string): Promise { + return this.managePlugin("reload", pluginId); + } + + async enablePlugin(pluginId: string): Promise { + return this.managePlugin("enable", pluginId); + } + + async disablePlugin(pluginId: string): Promise { + return this.managePlugin("disable", pluginId); + } + + async removePlugin(pluginId: string): Promise { + const requestId = this.createRequestId(); + await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.remove.request", requestId, pluginId }, + responseType: "plugin.remove.response", + }); + } + + private async managePlugin( + action: "reload" | "enable" | "disable", + pluginId: string, + ): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: `plugin.${action}.request`, requestId, pluginId }, + responseType: `plugin.${action}.response`, + }); + return payload.plugin; + } + + async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "plugin.rpc.invoke.request", + requestId, + pluginId, + method, + input, + }, + responseType: "plugin.rpc.invoke.response", + }); + return payload.output; + } + async respondToPermissionAndWait( agentId: string, requestId: string, diff --git a/packages/plugin/package.json b/packages/plugin/package.json new file mode 100644 index 0000000000..586658f08e --- /dev/null +++ b/packages/plugin/package.json @@ -0,0 +1,42 @@ +{ + "name": "@paseo/plugin", + "version": "0.3.0", + "private": true, + "files": [ + "dist", + "!dist/**/*.map" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./host": { + "types": "./dist/host.d.ts", + "source": "./src/host.ts", + "default": "./dist/host.js" + } + }, + "scripts": { + "clean": "node ../../scripts/clean-package-dist.mjs", + "build": "tsc -p tsconfig.json --incremental false", + "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", + "typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "@types/react": "~19.2.0", + "react": "19.1.0", + "typescript": "^5.9.3", + "vitest": "^4.1.6", + "zod": "^4.4.3" + }, + "peerDependencies": { + "react": "19.1.0", + "zod": "^4.4.3" + } +} diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts new file mode 100644 index 0000000000..ee7c2b0888 --- /dev/null +++ b/packages/plugin/src/attachments.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const PluginAttachmentItemSchema = z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.url(), + text: z.string(), + resourceType: z.string(), +}); + +export const PluginAttachmentSearchPayloadSchema = z.object({ + items: z.array(PluginAttachmentItemSchema), +}); + +export type PluginAttachmentItem = z.infer; +export type PluginAttachmentSearchPayload = z.infer; diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts new file mode 100644 index 0000000000..b05fa3e039 --- /dev/null +++ b/packages/plugin/src/contracts.ts @@ -0,0 +1,52 @@ +import type { ComponentType } from "react"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "./rpc.js"; + +export interface PluginSurfaceProps { + theme: Record; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export interface PluginAttachmentSourceContribution { + id: string; + title: string; + icon: string; + pickerTitle: string; + searchPlaceholder: string; + search: PluginRpcContract; +} + +export interface PluginContext { + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + ) => ZodInput | Promise>, + ): void; + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; +} + +export type PluginCleanup = () => void; + +export type PluginContribution = (plugin: PluginContext) => PluginCleanup; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/host.test.ts new file mode 100644 index 0000000000..bc1eac2d01 --- /dev/null +++ b/packages/plugin/src/host.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { defineAttachmentSource, defineRpc } from "./index.js"; +import { callPluginRpc, searchPluginAttachments } from "./host.js"; + +const contract = defineRpc({ + name: "increment", + input: z.unknown().pipe(z.object({ value: z.number() })), + output: z.object({ value: z.number() }), +}); + +describe("plugin RPC contracts", () => { + it("validates input before invoking the daemon", async () => { + let invocations = 0; + const invoke = async () => { + invocations += 1; + return { value: 2 }; + }; + + await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf( + z.ZodError, + ); + expect(invocations).toBe(0); + }); + + it("validates output before resolving", async () => { + const invoke = async () => ({ value: "wrong" }); + + await expect(callPluginRpc(contract, invoke, { value: 1 })).rejects.toBeInstanceOf(z.ZodError); + }); + + it("returns parsed output for a valid invocation", async () => { + const invoke = async (method: string, input: unknown) => ({ + value: method === "increment" && typeof input === "object" ? 2 : 0, + }); + + await expect(callPluginRpc(contract, invoke, { value: 1 })).resolves.toEqual({ value: 2 }); + }); +}); + +describe("plugin attachment sources", () => { + const search = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string(), + text: z.string(), + resourceType: z.string(), + }), + ), + }), + }); + + it("searches through the declared RPC and validates the standard result", async () => { + const source = defineAttachmentSource({ + id: "issues", + title: "Issue", + icon: "CircleDot", + pickerTitle: "Attach issue", + searchPlaceholder: "Search", + search, + }); + + const payload = await searchPluginAttachments( + source, + async (method, input) => ({ + items: [ + { + id: "issue-1", + identifier: `${method}:${String(Reflect.get(input as object, "query"))}`, + title: "Plugin attachments", + url: "https://example.com/issues/1", + text: "Issue snapshot", + resourceType: "issue", + }, + ], + }), + "ENG-123", + ); + + expect(payload.items[0]?.identifier).toBe("issues.search:ENG-123"); + }); + + it("rejects a malformed standard search result", async () => { + const source = defineAttachmentSource({ + id: "issues", + title: "Issue", + icon: "CircleDot", + pickerTitle: "Attach issue", + searchPlaceholder: "Search", + search, + }); + + await expect( + searchPluginAttachments(source, async () => ({ items: [{ id: "issue-1" }] }), "issue"), + ).rejects.toBeInstanceOf(z.ZodError); + }); +}); diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts new file mode 100644 index 0000000000..dd1454d7d2 --- /dev/null +++ b/packages/plugin/src/host.ts @@ -0,0 +1,50 @@ +import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +import type { + PluginAttachmentSourceContribution, + PluginContext, + PluginSidebarContribution, + PluginSurfaceContribution, + PluginSurfaceProps, +} from "./contracts.js"; +import { PluginRpcProvider } from "./rpc-context.js"; +import { callPluginRpc } from "./rpc.js"; +import type { ComponentType } from "react"; + +interface PluginCollector { + addSurface(id: string, Component: ComponentType): void; + addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; +} + +export interface PluginRegistrationCollector { + surfaces: PluginSurfaceContribution[]; + sidebarItems: PluginSidebarContribution[]; + attachmentSources: PluginAttachmentSourceContribution[]; +} + +export function createPluginContext( + collector: PluginCollector, +): Pick { + return { + addSurface(id, Component) { + collector.addSurface(id, Component); + }, + addSidebarItem(contribution) { + collector.addSidebarItem(contribution); + }, + addAttachmentSource(contribution) { + collector.addAttachmentSource(contribution); + }, + }; +} + +export async function searchPluginAttachments( + source: PluginAttachmentSourceContribution, + invoke: (method: string, input: unknown) => Promise, + query: string, +) { + const output = await callPluginRpc(source.search, invoke, { query }); + return PluginAttachmentSearchPayloadSchema.parseAsync(output); +} + +export { callPluginRpc, PluginRpcProvider }; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts new file mode 100644 index 0000000000..7591d3446a --- /dev/null +++ b/packages/plugin/src/index.ts @@ -0,0 +1,24 @@ +export { + PluginAttachmentItemSchema, + PluginAttachmentSearchPayloadSchema, + type PluginAttachmentItem, + type PluginAttachmentSearchPayload, +} from "./attachments.js"; +export type { + PluginAttachmentSourceContribution, + PluginCleanup, + PluginContribution, + PluginContext, + PluginSidebarContribution, + PluginSurfaceContribution, + PluginSurfaceProps, +} from "./contracts.js"; +import type { PluginAttachmentSourceContribution } from "./contracts.js"; +export { useRpc } from "./rpc-context.js"; +export { defineRpc, type PluginRpcContract } from "./rpc.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/rpc-context.tsx new file mode 100644 index 0000000000..104fac8532 --- /dev/null +++ b/packages/plugin/src/rpc-context.tsx @@ -0,0 +1,32 @@ +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"; + +interface PluginRpcContextValue { + invoke(method: string, input: unknown): Promise; +} + +const PluginRpcContext = createContext(null); + +export function PluginRpcProvider({ + children, + invoke, +}: { + children: ReactNode; + invoke(method: string, input: unknown): Promise; +}) { + const value = useMemo(() => ({ invoke }), [invoke]); + return {children}; +} + +export function useRpc( + contract: PluginRpcContract, +): (input: ZodInput) => Promise> { + const context = useContext(PluginRpcContext); + if (!context) throw new Error("useRpc must run inside a contributed plugin surface"); + + return useCallback( + (input: ZodInput) => callPluginRpc(contract, context.invoke, input), + [context, contract], + ); +} diff --git a/packages/plugin/src/rpc.ts b/packages/plugin/src/rpc.ts new file mode 100644 index 0000000000..fab69276d4 --- /dev/null +++ b/packages/plugin/src/rpc.ts @@ -0,0 +1,36 @@ +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + +export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, +> { + name: string; + input: InputSchema; + output: OutputSchema; +} + +interface RpcDefinition { + name: string; + input: InputSchema; + output: OutputSchema; +} + +const RPC_NAME = /^[a-z][a-z0-9._-]*$/; + +export function defineRpc( + definition: RpcDefinition, +): PluginRpcContract { + const name = definition.name.trim(); + if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`); + return { ...definition, name }; +} + +export async function callPluginRpc( + contract: PluginRpcContract, + invoke: (method: string, input: unknown) => Promise, + input: ZodInput, +): Promise> { + const parsedInput = await contract.input.parseAsync(input); + const output = await invoke(contract.name, parsedInput); + return contract.output.parseAsync(output); +} diff --git a/packages/plugin/tsconfig.examples.json b/packages/plugin/tsconfig.examples.json new file mode 100644 index 0000000000..7aa081b897 --- /dev/null +++ b/packages/plugin/tsconfig.examples.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"] +} diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json new file mode 100644 index 0000000000..b6c46d8b3d --- /dev/null +++ b/packages/plugin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 2a037d9139..7d544e3e65 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -310,6 +310,50 @@ describe("shared messages attachments", () => { ]); }); + it("preserves neutral external-resource presentation on text attachments", () => { + const parsed = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "req-external-resource", + agentId: "agent-1", + text: "Implement this", + attachments: [ + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + ], + }); + + expect(parsed.attachments).toEqual([ + { + type: "text", + mimeType: "text/plain", + title: "ENG-123 Plugin attachments", + text: "Linear issue ENG-123: Plugin attachments", + externalResource: { + provider: "linear", + providerLabel: "Linear issue", + resourceType: "issue", + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + }, + }, + ]); + }); + it("keeps known firstAgentContext attachments and drops unknown ones", () => { const parsed = CreatePaseoWorktreeRequestSchema.parse({ type: "create_paseo_worktree_request", diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts new file mode 100644 index 0000000000..9a13780c87 --- /dev/null +++ b/packages/protocol/src/messages.plugins.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + MutableDaemonConfigSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, + StatusMessageSchema, +} from "./messages.js"; + +describe("plugin protocol compatibility", () => { + it("keeps old directory plugin config valid when enabled is absent", () => { + const config = MutableDaemonConfigSchema.parse({ + mcp: { injectIntoAgents: true }, + plugins: { example: { source: "directory", path: "/plugins/example" } }, + }); + + expect(config.plugins?.example?.enabled).toBeUndefined(); + }); + + it("uses namespaced management request and response pairs", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "plugin.directory.install.request", + requestId: "request-1", + path: "/plugins/example", + id: "example-work", + }).type, + ).toBe("plugin.directory.install.request"); + expect( + SessionInboundMessageSchema.parse({ + type: "plugin.directory.inspect.request", + requestId: "request-0", + path: "/plugins/example", + }).type, + ).toBe("plugin.directory.inspect.request"); + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.directory.install.response", + payload: { + requestId: "request-1", + plugin: { + id: "example-work", + path: "/plugins/example", + enabled: true, + status: "running", + }, + }, + }).type, + ).toBe("plugin.directory.install.response"); + }); + + it("keeps the catalog change notification safe for older clients", () => { + expect( + StatusMessageSchema.parse({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }), + ).toEqual({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }); + }); + + it("requires plugin action payloads and keeps remove empty", () => { + expect(() => + SessionOutboundMessageSchema.parse({ + type: "plugin.reload.response", + payload: { requestId: "request-1" }, + }), + ).toThrow(); + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.remove.response", + payload: { requestId: "request-2" }, + }), + ).toEqual({ type: "plugin.remove.response", payload: { requestId: "request-2" } }); + expect(() => + SessionOutboundMessageSchema.parse({ + type: "plugin.remove.response", + payload: { requestId: "request-2", plugin: { id: "extra" } }, + }), + ).toThrow(); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d579b2c3ff..efb714e0ce 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -175,6 +175,21 @@ const MutableRelayConfigSchema = z enabled: z.boolean(), }) .passthrough(); + +export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/); + +export const DirectoryPluginSourceSchema = z + .object({ + source: z.literal("directory"), + path: z.string().min(1), + enabled: z.boolean().optional(), + }) + .strict(); + +export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]); + +export type PluginSource = z.infer; + export const MutableDaemonConfigSchema = z .object({ // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. @@ -192,6 +207,8 @@ export const MutableDaemonConfigSchema = z appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .passthrough(); @@ -210,6 +227,8 @@ export const MutableDaemonConfigPatchSchema = z appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), }) .partial() .passthrough(); @@ -1005,6 +1024,16 @@ export const ForgeIssueAttachmentSchema = z.object({ projectPath: z.string().optional(), }); +export const ExternalResourceAttachmentMetadataSchema = z.object({ + provider: z.string(), + providerLabel: z.string(), + resourceType: z.string(), + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), +}); + export const TextAttachmentSchema = z .object({ type: z.literal("text"), @@ -1012,6 +1041,7 @@ export const TextAttachmentSchema = z contextKind: z.string().optional(), title: z.string().nullable().optional(), text: z.string(), + externalResource: ExternalResourceAttachmentMetadataSchema.optional(), }) .transform(({ contextKind, ...attachment }) => ({ ...attachment, @@ -1294,6 +1324,46 @@ export const DiagnosticsRequestSchema = z.object({ requestId: z.string(), }); +export const PluginCatalogGetRequestSchema = z.object({ + type: z.literal("plugin.catalog.get.request"), + requestId: z.string(), +}); + +export const PluginListRequestSchema = z.object({ + type: z.literal("plugin.list.request"), + requestId: z.string(), +}); + +export const PluginDirectoryInstallRequestSchema = z.object({ + type: z.literal("plugin.directory.install.request"), + requestId: z.string(), + path: z.string().min(1), + id: PluginIdSchema.optional(), +}); + +export const PluginDirectoryInspectRequestSchema = z.object({ + type: z.literal("plugin.directory.inspect.request"), + requestId: z.string(), + path: z.string().min(1), +}); + +function pluginIdRequest(type: Type) { + return z.object({ type: z.literal(type), requestId: z.string(), pluginId: PluginIdSchema }); +} + +export const PluginReloadRequestSchema = pluginIdRequest("plugin.reload.request"); +export const PluginEnableRequestSchema = pluginIdRequest("plugin.enable.request"); +export const PluginDisableRequestSchema = pluginIdRequest("plugin.disable.request"); +export const PluginRemoveRequestSchema = pluginIdRequest("plugin.remove.request"); + +export const PluginRpcInvokeRequestSchema = z.object({ + type: z.literal("plugin.rpc.invoke.request"), + requestId: z.string(), + pluginId: PluginIdSchema, + method: z.string().min(1), + input: z.unknown(), +}); + export const GetDaemonConfigRequestMessageSchema = z.object({ type: z.literal("get_daemon_config_request"), requestId: z.string(), @@ -2820,6 +2890,15 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonDisconnectRequestSchema, DiagnosticsRequestSchema, + PluginCatalogGetRequestSchema, + PluginListRequestSchema, + PluginDirectoryInstallRequestSchema, + PluginDirectoryInspectRequestSchema, + PluginReloadRequestSchema, + PluginEnableRequestSchema, + PluginDisableRequestSchema, + PluginRemoveRequestSchema, + PluginRpcInvokeRequestSchema, GetDaemonConfigRequestMessageSchema, SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, @@ -3144,6 +3223,10 @@ export const ServerInfoStatusPayloadSchema = z relayConfig: z.boolean().optional(), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. pushTokenRevocation: z.boolean().optional(), + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: z.boolean().optional(), + // COMPAT(pluginManagement): added in v0.4.0, remove gate after 2027-08-14. + pluginManagement: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -3332,6 +3415,11 @@ export const DaemonConfigChangedStatusPayloadSchema = z }) .passthrough(); +export const PluginCatalogChangedStatusPayloadSchema = z.object({ + status: z.literal("plugin_catalog_changed"), + pluginId: PluginIdSchema, +}); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -3340,6 +3428,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ ShutdownRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, + PluginCatalogChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer; @@ -5795,6 +5884,69 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu export type DaemonUpdateProgressMessage = z.infer; +export const PluginCatalogGetResponseSchema = z.object({ + type: z.literal("plugin.catalog.get.response"), + payload: z.object({ + requestId: z.string(), + plugins: z.array( + z.object({ + id: PluginIdSchema, + clientBundle: z.string(), + }), + ), + }), +}); + +export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]); +export type PluginStatus = z.infer; + +export const PluginListItemSchema = z.object({ + id: PluginIdSchema, + path: z.string(), + enabled: z.boolean(), + status: PluginStatusSchema, + error: z.string().optional(), +}); +export type PluginListItem = z.infer; + +export const PluginListResponseSchema = z.object({ + type: z.literal("plugin.list.response"), + payload: z.object({ requestId: z.string(), plugins: z.array(PluginListItemSchema) }), +}); + +export const PluginDirectoryInstallResponseSchema = z.object({ + type: z.literal("plugin.directory.install.response"), + payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }), +}); + +export const PluginDirectoryInspectResponseSchema = z.object({ + type: z.literal("plugin.directory.inspect.response"), + payload: z.object({ requestId: z.string(), id: PluginIdSchema }), +}); + +function pluginActionResponse(type: Type) { + return z.object({ + type: z.literal(type), + payload: z.object({ requestId: z.string(), plugin: PluginListItemSchema }), + }); +} + +export const PluginReloadResponseSchema = pluginActionResponse("plugin.reload.response"); +export const PluginEnableResponseSchema = pluginActionResponse("plugin.enable.response"); +export const PluginDisableResponseSchema = pluginActionResponse("plugin.disable.response"); +export const PluginRemoveResponseSchema = z.object({ + type: z.literal("plugin.remove.response"), + payload: z.object({ requestId: z.string() }).strict(), +}); + +export const PluginRpcInvokeResponseSchema = z.object({ + type: z.literal("plugin.rpc.invoke.response"), + payload: z.object({ + requestId: z.string(), + output: z.unknown(), + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, @@ -5802,6 +5954,15 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentUpdateSchema, HubExecutionAgentStreamSchema, BrowserAutomationExecuteRequestSchema, + PluginCatalogGetResponseSchema, + PluginListResponseSchema, + PluginDirectoryInstallResponseSchema, + PluginDirectoryInspectResponseSchema, + PluginReloadResponseSchema, + PluginEnableResponseSchema, + PluginDisableResponseSchema, + PluginRemoveResponseSchema, + PluginRpcInvokeResponseSchema, ActivityLogMessageSchema, AssistantChunkMessageSchema, AudioOutputMessageSchema, diff --git a/packages/server/package.json b/packages/server/package.json index c6768fc8bc..52af6a7f37 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -73,6 +73,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", + "@babel/parser": "^7.29.8", "@getpaseo/client": "0.4.0", "@getpaseo/highlight": "0.4.0", "@getpaseo/protocol": "0.4.0", @@ -82,11 +83,13 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", + "@paseo/plugin": "*", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", + "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 3509453322..bb1d3f74b3 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,7 +1,7 @@ import os from "node:os"; import http from "node:http"; import path from "node:path"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; @@ -42,6 +42,16 @@ type WebSocketProbeResult = | { status: "connected" } | { status: "rejected"; statusCode: number | null }; +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + describe("paseo daemon bootstrap", () => { afterEach(() => { vi.restoreAllMocks(); @@ -398,7 +408,24 @@ describe("paseo daemon bootstrap", () => { const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const pluginDirectory = path.join(paseoHomeRoot, "plugin"); + const pluginPidPath = path.join(pluginDirectory, "plugin.pid"); await mkdir(paseoHome, { recursive: true }); + if (!isPlatform("win32")) { + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "startup-rollback" }), + ); + await writeFile( + path.join(pluginDirectory, "index.tsx"), + `import { writeFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + writeFileSync(${JSON.stringify(pluginPidPath)}, String(process.pid)); +}`, + ); + } const config: PaseoDaemonConfig = { listen: `127.0.0.1:${mainPort}`, paseoHome, @@ -414,12 +441,22 @@ describe("paseo daemon bootstrap", () => { openai: undefined, speech: undefined, serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` }, + pluginsEnabled: !isPlatform("win32"), + plugins: isPlatform("win32") + ? {} + : { "startup-rollback": { source: "directory", path: pluginDirectory } }, }; const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); try { await expect(daemon.start()).rejects.toThrow(); await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow(); + if (!isPlatform("win32")) { + const pluginPid = Number(await readFile(pluginPidPath, "utf8")); + await vi.waitFor(() => { + expect(processExists(pluginPid)).toBe(false); + }); + } } finally { await daemon.stop().catch(() => undefined); await new Promise((resolve) => occupiedMain.close(() => resolve())); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f4ba00ff01..a582f07a37 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -183,7 +183,12 @@ import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; +import type { + AgentProfile, + FirstAgentContext, + PluginSource, + TerminalProfile, +} from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -234,6 +239,7 @@ import { type HubRelationshipRemote, } from "./hub/relationship-remote.js"; import { DaemonExecutions } from "./hub/daemon-executions.js"; +import { PluginService } from "./plugins/index.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -418,6 +424,8 @@ export interface PaseoDaemonConfig { appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; agentProfiles?: AgentProfile[]; + pluginsEnabled?: boolean; + plugins?: Record; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -552,6 +560,8 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", + pluginsEnabled: config.pluginsEnabled ?? false, + plugins: config.plugins ?? {}, }; if (config.terminalProfiles !== undefined) { @@ -583,6 +593,7 @@ export async function createPaseoDaemon( ); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); + const pluginRuntime = new PluginService(logger, daemonConfigStore); const serverId = getOrCreateServerId(config.paseoHome, { logger }); const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger); @@ -1704,6 +1715,7 @@ export async function createPaseoDaemon( const start = async () => { let mainStarted = false; try { + await pluginRuntime.start(); if (serviceProxyListenTarget) { const boundServiceProxyTarget = await serviceProxy.startStandalone({ listenTarget: serviceProxyListenTarget, @@ -1835,6 +1847,7 @@ export async function createPaseoDaemon( workspaceSetupRuntime, workspaceRuntime, providerProbe, + pluginRuntime, ); relayRuntime = createRelayRuntime({ config: { @@ -1878,6 +1891,7 @@ export async function createPaseoDaemon( speechService.start(); scriptHealthMonitor.start(); } catch (error) { + await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); if (mainStarted) { httpServer.closeAllConnections(); @@ -1888,6 +1902,7 @@ export async function createPaseoDaemon( }; const stop = async () => { + await pluginRuntime.stopAllPlugins(); await hubRelationships.stop(); workspaceReconciliation.dispose(); detachProviderProbeInvalidation(); diff --git a/packages/server/src/server/config-plugins.test.ts b/packages/server/src/server/config-plugins.test.ts new file mode 100644 index 0000000000..0e0232a2a8 --- /dev/null +++ b/packages/server/src/server/config-plugins.test.ts @@ -0,0 +1,34 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { loadConfig } from "./config.js"; + +const roots: string[] = []; + +async function createPaseoHome(config: unknown): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-plugins-")); + roots.push(root); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); + return paseoHome; +} + +describe("daemon plugin config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("defaults plugins off when config is absent", async () => { + const home = await createPaseoHome({ version: 1 }); + + expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(false); + }); + + test("loads the explicit plugin opt-in", async () => { + const home = await createPaseoHome({ version: 1, pluginsEnabled: true }); + + expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(true); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 42f2322c6e..c708c33d7c 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -541,6 +541,8 @@ export function loadConfig( appendSystemPrompt, terminalProfiles, agentProfiles, + pluginsEnabled: persisted.pluginsEnabled ?? false, + plugins: persisted.plugins, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 9ed029e088..47e7061160 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -198,6 +198,7 @@ export class DaemonConfigStore { const { removeProviders = [], ...configPatch } = parsedPatch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins; const next = MutableDaemonConfigSchema.parse( omitMetadataGenerationProvidersFromConfig( omitProvidersFromConfig(merged, removedProviders), @@ -335,6 +336,8 @@ function mergeMutableConfigIntoPersistedConfig(params: { return { ...persisted, + pluginsEnabled: mutable.pluginsEnabled, + plugins: mutable.plugins, daemon: { ...persisted.daemon, ...(persistRelayEnabled diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index 904ee483fa..1d9e4fd40a 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -187,7 +187,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution" type: "hub.execution.agent.create.response", payload: { success: true, executionId: "replayed-execution" }, }); - const providerCreations = hub.providerCreations(); + const executionProviderCreations = hub.executionProviderCreations(); hub.beginOwnedCreate("replay-create", "replayed-execution", { mcpServers: { @@ -204,7 +204,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution" agentId: original.payload.agentId, }, }); - expect(hub.providerCreations()).toBe(providerCreations); + expect(hub.executionProviderCreations()).toBe(executionProviderCreations); expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]); }); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 9f83a5a6a9..b4ecf9984e 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -475,7 +475,7 @@ describe("Hub relationship", () => { }); expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId); expect(durableAgentIds).toEqual([created.payload.agentId]); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(relationship.providerResumes()).toBe(0); expect(relationship.providerPromptTexts()).toEqual([prompt]); expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0); @@ -640,7 +640,7 @@ describe("Hub relationship", () => { agentId: durableAgentIds[0], }, }); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(durableAgentIds).toHaveLength(1); }); @@ -665,7 +665,7 @@ describe("Hub relationship", () => { agent: { id: expect.any(String) }, }, }); - expect(relationship.providerCreations()).toBe(1); + expect(relationship.executionProviderCreations()).toBe(1); expect(await relationship.durableOwnedAgentIds()).toHaveLength(1); }); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 8f0171fdb0..5d00ee9fde 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -862,6 +862,10 @@ export class HubRelationshipHarness { return this.codex.creations; } + executionProviderCreations(): number { + return this.codex.createdConfigs.filter((config) => config.internal !== true).length; + } + providerResumes(): number { return this.codex.resumes; } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index b2eadf8d60..30da7939db 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,7 +9,12 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { + AgentProfileSchema, + PluginIdSchema, + PluginSourceSchema, + TerminalProfileSchema, +} from "@getpaseo/protocol/messages"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -321,6 +326,8 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginsEnabled: z.boolean().optional(), + plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), workspaceRuntimes: WorkspaceRuntimesConfigSchema.optional(), agents: z diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts new file mode 100644 index 0000000000..1726f10bf7 --- /dev/null +++ b/packages/server/src/server/plugins/compiler.ts @@ -0,0 +1,206 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "@babel/parser"; +import { build, type Plugin } from "esbuild"; + +type PluginBuildTarget = "client" | "server"; + +const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { + client: new Set(["handle"]), + server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]), +}; + +function registrationName(statement: unknown, contextName: string): string | null { + if (!statement || typeof statement !== "object") return null; + const expression = Reflect.get(statement, "expression"); + if ( + !expression || + typeof expression !== "object" || + Reflect.get(expression, "type") !== "CallExpression" + ) { + return null; + } + const callee = Reflect.get(expression, "callee"); + if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") { + return null; + } + const object = Reflect.get(callee, "object"); + const property = Reflect.get(callee, "property"); + if ( + !object || + typeof object !== "object" || + Reflect.get(object, "type") !== "Identifier" || + Reflect.get(object, "name") !== contextName || + !property || + typeof property !== "object" || + Reflect.get(property, "type") !== "Identifier" + ) { + return null; + } + return String(Reflect.get(property, "name")); +} + +function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } { + const defaultExports = programBody.filter( + (statement) => + statement !== null && + typeof statement === "object" && + Reflect.get(statement, "type") === "ExportDefaultDeclaration", + ); + if (defaultExports.length !== 1) { + throw new Error("Plugin index.tsx must have exactly one default export function"); + } + const declaration = Reflect.get(defaultExports[0] as object, "declaration"); + const declarationType = + declaration !== null && typeof declaration === "object" + ? Reflect.get(declaration, "type") + : null; + if ( + declarationType !== "FunctionDeclaration" && + declarationType !== "FunctionExpression" && + declarationType !== "ArrowFunctionExpression" + ) { + throw new Error("Plugin default export must be a function receiving its context"); + } + const parameters = Reflect.get(declaration, "params"); + const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null; + if ( + parameter === null || + typeof parameter !== "object" || + Reflect.get(parameter, "type") !== "Identifier" + ) { + throw new Error("Plugin default export must receive one named context parameter"); + } + const body = Reflect.get(declaration, "body"); + if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") { + throw new Error("Plugin default export must have a block body"); + } + return { body, contextName: String(Reflect.get(parameter, "name")) }; +} + +function collectRemovedRegistrationRanges( + node: unknown, + contextName: string, + removedNames: ReadonlySet, + ranges: Array<{ start: number; end: number }>, +): void { + if (node === null || typeof node !== "object") return; + if (Reflect.get(node, "type") === "ExpressionStatement") { + const name = registrationName(node, contextName); + if (name && removedNames.has(name)) { + const start = Reflect.get(node, "start"); + const end = Reflect.get(node, "end"); + if (typeof start !== "number" || typeof end !== "number") { + throw new Error(`Could not locate plugin context ${name} registration in index.tsx`); + } + ranges.push({ start, end }); + return; + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) + collectRemovedRegistrationRanges(child, contextName, removedNames, ranges); + } else if (value !== null && typeof value === "object") { + collectRemovedRegistrationRanges(value, contextName, removedNames, ranges); + } + } +} + +function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string { + const ast = parse(source, { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + const pluginFunction = defaultPluginFunction(ast.program.body); + const ranges: Array<{ start: number; end: number }> = []; + collectRemovedRegistrationRanges( + pluginFunction.body, + pluginFunction.contextName, + REGISTRATIONS_REMOVED_BY_TARGET[target], + ranges, + ); + + let output = source; + for (const range of ranges.toReversed()) { + output = `${output.slice(0, range.start)}${output.slice(range.end)}`; + } + return output; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { + const filter = + target === "server" + ? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/ + : /^node:/; + return { + name: `paseo-plugin-${target}-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", + })); + }, + }; +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const source = await readFile(entryPath, "utf8"); + const filteredSource = removeOppositeTargetRegistrations(source, target); + const result = await build({ + stdin: { + contents: filteredSource, + loader: "tsx", + resolveDir: path.dirname(entryPath), + sourcefile: entryPath, + }, + bundle: true, + format: "cjs", + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + external: + target === "client" + ? [ + "@paseo/plugin", + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : ["@paseo/plugin", "zod"], + plugins: [createUnusedPlatformModulePlugin(target)], + treeShaking: true, + write: false, + }); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + return wrapCommonJsBundle(makeHermesInteropEager(output)); +} + +export async function compilePlugin(entryPath: string): Promise<{ + clientBundle: string; + serverBundle: string; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + compileTarget(entryPath, "client"), + compileTarget(entryPath, "server"), + ]); + return { clientBundle, serverBundle }; +} diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts new file mode 100644 index 0000000000..1a3ec585c0 --- /dev/null +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -0,0 +1,370 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { afterEach, describe, expect, it } from "vitest"; +import { DaemonConfigStore } from "../daemon-config-store.js"; +import { PluginService } from "./index.js"; + +const roots: string[] = []; + +async function createPlugin(id: string, source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); + roots.push(directory); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id })); + await writeFile(path.join(directory, "index.tsx"), source); + return directory; +} + +function createStore( + home: string, + plugins: Record = {}, +): DaemonConfigStore { + return new DaemonConfigStore(home, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins, + }); +} + +function createService( + home: string, + plugins: Record = {}, + runtime?: ConstructorParameters[2], +): PluginService { + return new PluginService(pino({ level: "silent" }), createStore(home, plugins), runtime); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +function createPausedRuntime() { + let releaseStart = () => undefined; + let markStarted = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const running = new Set(); + const runtime: NonNullable[2]> = { + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + markStarted(); + await startGate; + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + }; + return { runtime, started, releaseStart }; +} + +function createPluginSelectivePausedRuntime(pausedPluginId: string) { + let releaseStart = () => undefined; + let markStarted = () => undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const starts: string[] = []; + const running = new Set(); + const runtime: NonNullable[2]> = { + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + starts.push(pluginId); + if (pluginId === pausedPluginId) { + markStarted(); + await startGate; + } + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + }; + return { runtime, started, releaseStart, starts }; +} + +describe("PluginService", () => { + it("uses an explicit config key, exposes reload failure, and retries from disk", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "manifest-default", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + + await expect( + service.installDirectory({ path: directory, id: "work-plugin" }), + ).resolves.toMatchObject({ + id: "work-plugin", + status: "running", + }); + await expect(service.installDirectory({ path: directory, id: "work-plugin" })).rejects.toThrow( + "choose another ID with --id", + ); + + await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!"); + await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); + expect(service.catalog()).toEqual([]); + expect(service.listPlugins()).toEqual([ + expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }), + ]); + + await writeFile( + path.join(directory, "index.tsx"), + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" }); + await service.stopAllPlugins(); + }); + + it("disables and removes a plugin without touching its source directory", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const cleanupFile = path.join(home, "cleanup.txt"); + const directory = await createPlugin( + "cleanup-plugin", + `import { writeFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + return () => writeFileSync(${JSON.stringify(cleanupFile)}, "cleaned"); +}`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + await service.installDirectory({ path: directory }); + + await expect(service.disablePlugin("cleanup-plugin")).resolves.toMatchObject({ + status: "disabled", + }); + expect(await readFile(cleanupFile, "utf8")).toBe("cleaned"); + await service.removePlugin("cleanup-plugin"); + + expect(service.listPlugins()).toEqual([]); + await expect(stat(directory)).resolves.toMatchObject({}); + await service.stopAllPlugins(); + }); + + it("detaches every plugin synchronously when the global switch turns off and recovers", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const first = await createPlugin( + "first", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const second = await createPlugin( + "second", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const store = createStore(home); + const service = new PluginService(pino({ level: "silent" }), store); + await service.start(); + await service.installDirectory({ path: first }); + await service.installDirectory({ path: second }); + + store.patch({ pluginsEnabled: false }); + + expect(service.catalog()).toEqual([]); + await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + store.patch({ pluginsEnabled: true }); + await service.reloadPlugin("first"); + expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([ + { id: "first", status: "running" }, + { id: "second", status: "running" }, + ]); + await service.stopAllPlugins(); + }); + + it("does not publish an in-flight start after a later global disable", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: true }, + }); + store.patch({ pluginsEnabled: false }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + store.patch({ pluginsEnabled: true }); + await paused.started; + store.patch({ pluginsEnabled: false }); + paused.releaseStart(); + await service.stopAllPlugins(); + + expect(service.catalog()).toEqual([]); + expect(service.listPlugins()).toEqual([ + { id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" }, + ]); + }); + + it("does not publish an in-flight enable after a later plugin disable", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + const enabling = expect(service.enablePlugin("slow")).rejects.toThrow( + "Plugin start cancelled: slow", + ); + await paused.started; + const disabling = service.disablePlugin("slow"); + paused.releaseStart(); + + await enabling; + await expect(disabling).resolves.toMatchObject({ status: "disabled" }); + expect(service.catalog()).toEqual([]); + }); + + it("keeps a later disable authoritative over an enable waiting behind another plugin", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + occupier: { source: "directory", path: "/plugins/occupier", enabled: false }, + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPluginSelectivePausedRuntime("occupier"); + const service = new PluginService(pino({ level: "silent" }), store, paused.runtime); + await service.start(); + + const occupying = service.enablePlugin("occupier"); + await paused.started; + const enabling = service.enablePlugin("slow"); + const disabling = service.disablePlugin("slow"); + paused.releaseStart(); + + await occupying; + await expect(enabling).resolves.toMatchObject({ + id: "slow", + enabled: false, + status: "disabled", + }); + await expect(disabling).resolves.toMatchObject({ + id: "slow", + enabled: false, + status: "disabled", + }); + expect(paused.starts).toEqual(["occupier"]); + expect(service.catalog()).toEqual([{ id: "occupier", clientBundle: "bundle" }]); + }); + + it("notifies exactly once after successful and failed configured installs", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const successful = await createPlugin( + "successful-install", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const failed = await createPlugin( + "failed-install", + `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, + ); + const service = createService(home); + const events: string[] = []; + service.subscribe((pluginId) => events.push(pluginId)); + await service.start(); + + await service.installDirectory({ path: successful }); + expect(events).toEqual(["successful-install"]); + + events.length = 0; + await expect(service.installDirectory({ path: failed })).rejects.toThrow("startup exploded"); + expect(events).toEqual(["failed-install"]); + await service.stopAllPlugins(); + }); + + it("reports invalid manifests, missing entries, and startup failures", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};"); + await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({})); + const missingEntry = await createPlugin("missing-entry", "export default () => () => {};"); + await rm(path.join(missingEntry, "index.tsx")); + const startupFailure = await createPlugin( + "startup-failure", + `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, + ); + const service = createService(home); + await service.start(); + + await expect(service.installDirectory({ path: invalid })).rejects.toThrow(); + await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow( + "Plugin entry point is missing", + ); + await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( + "startup exploded", + ); + expect(service.listPlugins()).toEqual([ + expect.objectContaining({ + id: "missing-entry", + status: "failed", + error: expect.stringContaining("Plugin entry point is missing"), + }), + expect.objectContaining({ + id: "startup-failure", + status: "failed", + error: "startup exploded", + }), + ]); + await service.stopAllPlugins(); + }); + + it("contains cleanup errors and invokes server cleanup once per stopped installation", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const cleanupFile = path.join(home, "cleanups.txt"); + const directory = await createPlugin( + "cleanup-count", + `import { appendFileSync } from "node:fs"; +export default function contribute(plugin: unknown) { + void plugin; + return () => { + appendFileSync(${JSON.stringify(cleanupFile)}, "cleanup\\n"); + throw new Error("cleanup exploded"); + }; +}`, + ); + const service = createService(home); + await service.start(); + await service.installDirectory({ path: directory }); + await service.reloadPlugin("cleanup-count"); + await service.disablePlugin("cleanup-count"); + await service.enablePlugin("cleanup-count"); + await service.removePlugin("cleanup-count"); + await service.installDirectory({ path: directory }); + await service.stopAllPlugins(); + + expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4); + }); +}); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts new file mode 100644 index 0000000000..d933bd3e5e --- /dev/null +++ b/packages/server/src/server/plugins/index.ts @@ -0,0 +1,278 @@ +import path from "node:path"; +import type pino from "pino"; +import { + PluginIdSchema, + type PluginListItem, + type PluginSource, +} from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../daemon-config-store.js"; +import { readPluginManifest } from "./manifest.js"; +import { PluginRuntime } from "./runtime.js"; + +interface PluginRuntimePort { + catalog(): Array<{ id: string; clientBundle: string }>; + invoke(pluginId: string, method: string, input: unknown): Promise; + startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; + stopPluginById(pluginId: string): Promise; + stopAll(): Promise; + subscribe(listener: (pluginId: string, error?: string) => void): () => void; +} + +export class PluginService { + private readonly runtime: PluginRuntimePort; + private readonly logger: pino.Logger; + private readonly errors = new Map(); + private readonly listeners = new Set<(pluginId: string) => void>(); + private lifecycle = Promise.resolve(); + private globalStartsBlocked = true; + private started = false; + + constructor( + logger: pino.Logger, + private readonly configStore: DaemonConfigStore, + runtime: PluginRuntimePort = new PluginRuntime(logger), + ) { + this.logger = logger.child({ module: "plugin-service" }); + this.runtime = runtime; + this.runtime.subscribe((pluginId, error) => { + if (error) this.errors.set(pluginId, error); + this.notify(pluginId); + }); + } + + subscribe(listener: (pluginId: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async start(): Promise { + if (this.started) return; + this.started = true; + const config = this.configStore.get(); + this.globalStartsBlocked = config.pluginsEnabled !== true; + if (config.pluginsEnabled === true) { + for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { + if (source.enabled === false) continue; + await this.startConfigured(pluginId); + } + } + this.configStore.onFieldChange("pluginsEnabled", (value) => { + this.handleGlobalSwitch(value === true); + }); + } + + listPlugins(): PluginListItem[] { + const config = this.configStore.get(); + const running = new Set(this.runtime.catalog().map((plugin) => plugin.id)); + return Object.entries(config.plugins ?? {}) + .map(([id, source]) => { + const enabled = source.enabled !== false; + if (!enabled || config.pluginsEnabled !== true) { + return { id, path: source.path, enabled, status: "disabled" as const }; + } + const error = this.errors.get(id); + const item: PluginListItem = { + id, + path: source.path, + enabled, + status: running.has(id) ? ("running" as const) : ("failed" as const), + }; + if (error) item.error = error; + return item; + }) + .sort((left, right) => left.id.localeCompare(right.id)); + } + + catalog(): Array<{ id: string; clientBundle: string }> { + return this.runtime.catalog(); + } + + async installDirectory(input: { path: string; id?: string }): Promise { + return this.enqueue(async () => { + const directory = path.resolve(input.path); + const manifest = await readPluginManifest(directory); + const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); + if (this.configStore.get().plugins?.[pluginId]) { + throw new Error( + `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, + ); + } + const sources = { + ...this.configStore.get().plugins, + [pluginId]: { source: "directory" as const, path: directory, enabled: true }, + }; + this.configStore.patch({ plugins: sources }); + if (this.canPublish(pluginId)) await this.startConfigured(pluginId); + this.notify(pluginId); + const installed = this.requireItem(pluginId); + if (installed.status === "failed") { + throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); + } + return installed; + }); + } + + async inspectDirectory(configuredPath: string): Promise<{ id: string }> { + return readPluginManifest(path.resolve(configuredPath)); + } + + async reloadPlugin(pluginId: string): Promise { + return this.enqueue(async () => { + const source = this.requireEnabledSource(pluginId); + if (this.configStore.get().pluginsEnabled !== true) { + throw new Error("Plugins are globally disabled"); + } + this.errors.delete(pluginId); + await this.runtime.stopPluginById(pluginId); + await this.startExplicit(pluginId, source.path); + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async enablePlugin(pluginId: string): Promise { + const source = this.requireSource(pluginId); + this.patchSource(pluginId, { ...source, enabled: true }); + this.errors.delete(pluginId); + return this.enqueue(async () => { + const current = this.requireSource(pluginId); + if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) { + await this.startExplicit(pluginId, current.path); + } + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async disablePlugin(pluginId: string): Promise { + const source = this.requireSource(pluginId); + this.patchSource(pluginId, { ...source, enabled: false }); + const stopping = this.runtime.stopPluginById(pluginId); + return this.enqueue(async () => { + await stopping; + this.errors.delete(pluginId); + this.notify(pluginId); + return this.requireItem(pluginId); + }); + } + + async removePlugin(pluginId: string): Promise { + this.requireSource(pluginId); + const stopping = this.runtime.stopPluginById(pluginId); + const sources = { ...this.configStore.get().plugins }; + delete sources[pluginId]; + this.configStore.patch({ plugins: sources }); + await this.enqueue(async () => { + await stopping; + this.errors.delete(pluginId); + this.notify(pluginId); + }); + } + + invokePluginRpc(pluginId: string, method: string, input: unknown): Promise { + return this.runtime.invoke(pluginId, method, input); + } + + async stopAllPlugins(): Promise { + this.globalStartsBlocked = true; + const stopping = this.runtime.stopAll(); + await this.enqueue(async () => { + await stopping; + await this.runtime.stopAll(); + }); + } + + private handleGlobalSwitch(enabled: boolean): void { + if (!enabled) { + this.globalStartsBlocked = true; + const stopping = this.runtime.stopAll(); + for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); + void this.enqueue(async () => { + await stopping; + }).catch((error) => this.logger.error({ err: error }, "Failed to disable plugins")); + return; + } + void this.enqueue(async () => { + this.globalStartsBlocked = false; + for (const [pluginId, source] of Object.entries(this.configStore.get().plugins ?? {})) { + if (source.enabled !== false) await this.startConfigured(pluginId); + this.notify(pluginId); + } + }).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins")); + } + + private async startConfigured(pluginId: string): Promise { + const source = this.configStore.get().plugins?.[pluginId]; + if (!source || source.enabled === false || !this.canPublish(pluginId)) return; + this.errors.delete(pluginId); + try { + await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId)); + } catch (error) { + if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); + } + } + + private async startExplicit(pluginId: string, sourcePath: string): Promise { + try { + await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + } catch (error) { + if (this.canPublish(pluginId)) { + this.recordFailure(pluginId, error); + this.notify(pluginId); + } + throw error; + } + } + + private canPublish(pluginId: string): boolean { + const config = this.configStore.get(); + return ( + !this.globalStartsBlocked && + config.pluginsEnabled === true && + config.plugins?.[pluginId]?.enabled !== false && + config.plugins?.[pluginId] !== undefined + ); + } + + private enqueue(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private recordFailure(pluginId: string, error: unknown): void { + this.errors.set(pluginId, error instanceof Error ? error.message : String(error)); + } + + private requireSource(pluginId: string): PluginSource { + PluginIdSchema.parse(pluginId); + const source = this.configStore.get().plugins?.[pluginId]; + if (!source) throw new Error(`Plugin is not configured: ${pluginId}`); + return source; + } + + private requireEnabledSource(pluginId: string): PluginSource { + const source = this.requireSource(pluginId); + if (source.enabled === false) throw new Error(`Plugin is disabled: ${pluginId}`); + return source; + } + + private patchSource(pluginId: string, source: PluginSource): void { + this.configStore.patch({ + plugins: { ...this.configStore.get().plugins, [pluginId]: source }, + }); + } + + private requireItem(pluginId: string): PluginListItem { + const item = this.listPlugins().find((plugin) => plugin.id === pluginId); + if (!item) throw new Error(`Plugin is not configured: ${pluginId}`); + return item; + } + + private notify(pluginId: string): void { + for (const listener of this.listeners) listener(pluginId); + } +} diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts new file mode 100644 index 0000000000..540f3540ea --- /dev/null +++ b/packages/server/src/server/plugins/manifest.ts @@ -0,0 +1,16 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { PluginIdSchema } from "@getpaseo/protocol/messages"; + +const MANIFEST_FILENAME = "paseo-plugin.json"; +const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict(); + +export type PluginManifest = z.infer; + +export async function readPluginManifest(directory: string): Promise { + const manifestPath = path.join(directory, MANIFEST_FILENAME); + const info = await stat(manifestPath).catch(() => null); + if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`); + return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts new file mode 100644 index 0000000000..fc2b4eee99 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -0,0 +1,10 @@ +export type PluginProcessRequest = + | { type: "initialize"; bundle: string } + | { type: "invoke"; requestId: string; method: string; input: unknown } + | { type: "shutdown" }; + +export type PluginProcessMessage = + | { type: "ready"; methods: string[] } + | { type: "result"; requestId: string; output: unknown } + | { type: "error"; requestId: string; error: string } + | { type: "fatal"; error: string }; diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts new file mode 100644 index 0000000000..8c54183aa0 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -0,0 +1,105 @@ +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import { createRequire } from "node:module"; +import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@paseo/plugin"; + +type RpcHandler = (input: unknown) => unknown | Promise; + +interface RegisteredRpc { + contract: PluginRpcContract; + handler: RpcHandler; +} + +const handlers = new Map(); +let cleanup: (() => void) | null = null; +const nodeRequire = createRequire(import.meta.url); + +function send(message: PluginProcessMessage): void { + process.send?.(message); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function validateMethod(method: string): string { + const normalized = method.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { + throw new Error(`Invalid plugin RPC method: ${method}`); + } + if (handlers.has(normalized)) { + throw new Error(`Duplicate plugin RPC method: ${normalized}`); + } + return normalized; +} + +function register(contract: PluginRpcContract, handler: RpcHandler): void { + if (typeof handler !== "function") { + throw new Error(`Plugin RPC ${contract.name} must provide a handler`); + } + const method = validateMethod(contract.name); + handlers.set(method, { contract: { ...contract, name: method }, handler }); +} + +function runtimeRequire(name: string): unknown { + if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc }; + return nodeRequire(name); +} + +function evaluateBundle(bundle: string): void { + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error("Plugin server bundle must default export a function"); + } + const contributedCleanup = setup({ handle: register }); + if (typeof contributedCleanup !== "function") { + throw new Error("Plugin contribution must return a cleanup function"); + } + cleanup = contributedCleanup; +} + +process.on("message", (message: PluginProcessRequest) => { + if (message.type === "initialize") { + try { + evaluateBundle(message.bundle); + send({ type: "ready", methods: [...handlers.keys()].sort() }); + } catch (error) { + send({ type: "fatal", error: describeError(error) }); + } + return; + } + if (message.type === "shutdown") { + const currentCleanup = cleanup; + cleanup = null; + if (currentCleanup) { + try { + currentCleanup(); + } catch (error) { + console.error("Plugin cleanup failed", error); + } + } + process.disconnect(); + return; + } + const registered = handlers.get(message.method); + if (!registered) { + send({ + type: "error", + requestId: message.requestId, + error: `Unknown RPC method: ${message.method}`, + }); + return; + } + void registered.contract.input + .parseAsync(message.input) + .then(registered.handler) + .then((output) => registered.contract.output.parseAsync(output)) + .then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts new file mode 100644 index 0000000000..8a1c07bf55 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -0,0 +1,319 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { afterEach, describe, expect, it } from "vitest"; +import { PluginRuntime } from "./runtime.js"; + +const temporaryDirectories: string[] = []; + +async function createPlugin(id: string, source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); + temporaryDirectories.push(directory); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8"); + await writeFile(path.join(directory, "index.tsx"), source, "utf8"); + return directory; +} + +function createReloadChild(name: string, events: string[], methods: string[] = []) { + const listeners = new Map void>>(); + const emit = (event: string, message: unknown) => { + for (const listener of listeners.get(event) ?? []) listener(message as never); + }; + return { + connected: true, + killed: false, + send(message: { type: string }, callback?: (error: Error | null) => void) { + callback?.(null); + if (message.type === "initialize") { + events.push(`start:${name}`); + queueMicrotask(() => emit("message", { type: "ready", methods })); + } + if (message.type === "shutdown") { + events.push(`shutdown:${name}`); + this.connected = false; + queueMicrotask(() => { + events.push(`exit:${name}`); + emit("close", null); + }); + } + return true; + }, + kill() { + this.killed = true; + this.connected = false; + queueMicrotask(() => emit("close", null)); + return true; + }, + disconnect() { + this.connected = false; + }, + on(event: string, listener: (message: never) => void) { + const registered = listeners.get(event) ?? []; + registered.push(listener); + listeners.set(event, registered); + return this; + }, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("PluginRuntime", () => { + it("waits for the old subprocess to exit before starting its replacement", async () => { + const directory = await createPlugin( + "reloadable", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const events: string[] = []; + const children = [createReloadChild("old", events), createReloadChild("new", events)]; + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => { + const child = children.shift(); + if (!child) throw new Error("Unexpected extra child"); + return child; + }, + }); + await runtime.startPlugin("configured-id", directory); + + await runtime.stopPluginById("configured-id"); + await runtime.startPlugin("configured-id", directory); + + expect(events).toEqual(["start:old", "shutdown:old", "exit:old", "start:new"]); + await runtime.stopAll(); + }); + + it("rejects pending RPCs when the plugin stops", async () => { + const directory = await createPlugin( + "pending", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild("pending", [], ["wait"]); + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => child, + }); + await runtime.startPlugin("pending", directory); + + const rejection = expect(runtime.invoke("pending", "wait", {})).rejects.toThrow( + "Plugin stopped: pending", + ); + await runtime.stopPluginById("pending"); + + await rejection; + }); + + it("kills a plugin child that fails initialization", async () => { + const directory = await createPlugin( + "broken", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const listeners = new Map void>>(); + const child = { + connected: true, + killed: false, + send(message: { type: string }, callback?: (error: Error | null) => void) { + callback?.(null); + if (message.type === "initialize") { + queueMicrotask(() => { + for (const listener of listeners.get("message") ?? []) { + listener({ type: "fatal", error: "broken plugin" } as never); + } + }); + } + return true; + }, + kill() { + this.killed = true; + this.connected = false; + return true; + }, + disconnect() { + this.connected = false; + }, + on(event: string, listener: (message: never) => void) { + const registered = listeners.get(event) ?? []; + registered.push(listener); + listeners.set(event, registered); + return this; + }, + }; + const runtime = new PluginRuntime(pino({ level: "silent" }), { + spawnChild: () => child, + }); + + await expect(runtime.startPlugin("broken", directory)).rejects.toThrow("broken plugin"); + + expect(child.killed).toBe(true); + await runtime.stopAll(); + }); + + it("rejects a server contribution without cleanup", async () => { + const directory = await createPlugin( + "missing-cleanup", + `export default function contribute(plugin: unknown) { void plugin; }`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await expect(runtime.startPlugin("missing-cleanup", directory)).rejects.toThrow( + "must return a cleanup function", + ); + expect(runtime.catalog()).toEqual([]); + await runtime.stopAll(); + }); + + it("loads the official Linear attachment extension", async () => { + const directory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../plugin-examples/linear", + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("linear", directory); + + expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["linear"]); + expect(runtime.catalog()[0]?.clientBundle).toContain("Attach Linear issue"); + expect(runtime.catalog()[0]?.clientBundle).not.toContain("LINEAR_API_KEY"); + expect(runtime.catalog()[0]?.clientBundle).not.toContain("api.linear.app"); + await runtime.stopAll(); + }); + + it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => { + const directory = await createPlugin( + "hello", + `import React from "react"; +import { platform } from "node:os"; +import { Text } from "react-native"; +import { z } from "zod"; +import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; + +const greetRpc = defineRpc({ + name: "greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string(), platform: z.string() }), +}); + +const attachments = defineAttachmentSource({ + id: "issues", + title: "Example issue", + icon: "CircleDot", + pickerTitle: "Attach example issue", + searchPlaceholder: "Search issues", + search: greetRpc, +}); + +function HelloSurface() { + return Hello from native UI; +} + +export default function contribute(plugin: any) { + plugin.handle(greetRpc, async (input: { name: string }) => ({ + message: "Hello, " + input.name, + platform: platform(), + })); + plugin.addSurface("main", HelloSurface); + plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); + plugin.addAttachmentSource(attachments); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("hello", directory); + + const catalog = runtime.catalog(); + expect(catalog).toHaveLength(1); + expect(catalog[0]?.id).toBe("hello"); + expect(catalog[0]?.clientBundle).toContain("Hello from native UI"); + expect(catalog[0]?.clientBundle).toContain("Attach example issue"); + expect(catalog[0]?.clientBundle).not.toContain("node:os"); + expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]"); + await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({ + message: "Hello, Paseo", + }); + await expect(runtime.invoke("hello", "greet", { name: 7 })).rejects.toThrow(); + + await runtime.stopAll(); + }); + + it("rejects a handler result that does not match its RPC output schema", async () => { + const directory = await createPlugin( + "invalid-output", + `import { z } from "zod"; +import { defineRpc } from "@paseo/plugin"; +const brokenRpc = defineRpc({ + name: "broken", + input: z.object({}), + output: z.object({ value: z.number() }), +}); +export default function contribute(plugin: any) { + plugin.handle(brokenRpc, async () => ({ value: "wrong" })); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("invalid-output", directory); + + await expect(runtime.invoke("invalid-output", "broken", {})).rejects.toThrow(); + await runtime.stopAll(); + }); + + it("uses the config key as runtime identity without comparing the manifest id", async () => { + const directory = await createPlugin( + "actual", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + + await runtime.startPlugin("configured", directory); + + expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["configured"]); + await runtime.stopAll(); + }); + + it("does not publish a plugin when lifecycle intent changes while it starts", async () => { + const directory = await createPlugin( + "blocked", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const events: string[] = []; + const child = createReloadChild("blocked", events); + const runtime = new PluginRuntime(pino({ level: "silent" }), { spawnChild: () => child }); + + await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( + "Plugin start cancelled: blocked", + ); + + expect(runtime.catalog()).toEqual([]); + expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); + }); + + it("reports an unexpected subprocess crash and removes its catalog entry", async () => { + const directory = await createPlugin( + "crashing", + `export default function contribute(plugin: unknown) { + void plugin; + setTimeout(() => process.exit(17), 20); + return () => undefined; +}`, + ); + const runtime = new PluginRuntime(pino({ level: "silent" })); + const crashed = new Promise((resolve) => { + runtime.subscribe((pluginId, error) => { + if (pluginId === "crashing" && error) resolve(error); + }); + }); + await runtime.startPlugin("crashing", directory); + + await expect(crashed).resolves.toBe("Plugin process exited: crashing"); + expect(runtime.catalog()).toEqual([]); + await expect(runtime.invoke("crashing", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + }); +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts new file mode 100644 index 0000000000..c7deab8f14 --- /dev/null +++ b/packages/server/src/server/plugins/runtime.ts @@ -0,0 +1,269 @@ +import { fork } from "node:child_process"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import type pino from "pino"; +import { compilePlugin } from "./compiler.js"; +import { readPluginManifest } from "./manifest.js"; +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; + +const ENTRY_FILENAME = "index.tsx"; +const REQUEST_TIMEOUT_MS = 30_000; + +interface PluginChild { + connected: boolean; + killed: boolean; + send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; + kill(): boolean; + disconnect(): void; + on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +} + +interface PendingInvocation { + resolve: (output: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +interface LoadedPlugin { + id: string; + clientBundle: string; + methods: ReadonlySet; + child: PluginChild; + pending: Map; +} + +interface PluginRuntimeDependencies { + spawnChild?: () => PluginChild; +} + +function resolveWorkerUrl(): URL { + return new URL( + import.meta.url.endsWith(".ts") ? "./plugin-process.ts" : "./plugin-process.js", + import.meta.url, + ); +} + +function resolveWorkerExecArgv(): string[] { + if (!import.meta.url.endsWith(".ts")) return []; + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const importSource = [ + 'import { register } from "node:module";', + 'import { pathToFileURL } from "node:url";', + `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, + ].join(" "); + return [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(importSource)}`, + ]; +} + +function spawnPluginChild(): PluginChild { + return fork(fileURLToPath(resolveWorkerUrl()), [], { + execArgv: resolveWorkerExecArgv(), + serialization: "advanced", + stdio: ["ignore", "ignore", "inherit", "ipc"], + }) as PluginChild; +} + +function terminatePluginChild(child: PluginChild): void { + if (child.connected) child.disconnect(); + if (!child.killed) child.kill(); +} + +function send(child: PluginChild, message: PluginProcessRequest): Promise { + return new Promise((resolve, reject) => { + child.send(message, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function requireRegularFile(filePath: string, label: string): Promise { + const info = await stat(filePath).catch(() => null); + if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`); +} + +export class PluginRuntime { + private readonly plugins = new Map(); + private readonly logger: pino.Logger; + private readonly spawnChild: () => PluginChild; + private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); + + constructor(logger: pino.Logger, dependencies: PluginRuntimeDependencies = {}) { + this.logger = logger.child({ module: "plugins" }); + this.spawnChild = dependencies.spawnChild ?? spawnPluginChild; + } + + subscribe(listener: (pluginId: string, error?: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async startPlugin( + pluginId: string, + configuredPath: string, + canPublish: () => boolean = () => true, + ): Promise { + if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath); + if (!canPublish()) { + await this.stopPlugin(loaded); + throw new Error(`Plugin start cancelled: ${pluginId}`); + } + this.plugins.set(pluginId, loaded); + } + + async stopPluginById(pluginId: string): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) return false; + this.plugins.delete(pluginId); + this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); + await this.stopPlugin(loaded); + return true; + } + + catalog(): Array<{ id: string; clientBundle: string }> { + return [...this.plugins.values()] + .map(({ id, clientBundle }) => ({ id, clientBundle })) + .sort((left, right) => left.id.localeCompare(right.id)); + } + + async invoke(pluginId: string, method: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.methods.has(method)) + throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + loaded.pending.delete(requestId); + reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + }, REQUEST_TIMEOUT_MS); + loaded.pending.set(requestId, { resolve, reject, timeout }); + void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => { + clearTimeout(timeout); + loaded.pending.delete(requestId); + reject(error); + }); + }); + } + + async stopAll(): Promise { + const loaded = [...this.plugins.values()]; + this.plugins.clear(); + for (const plugin of loaded) { + this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); + } + await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); + } + + private async loadDirectoryPlugin( + pluginId: string, + configuredPath: string, + ): Promise { + const directory = path.resolve(configuredPath); + await readPluginManifest(directory); + const entryPath = path.join(directory, ENTRY_FILENAME); + await requireRegularFile(entryPath, "Plugin entry point"); + const bundles = await compilePlugin(entryPath); + const child = this.spawnChild(); + const pending = new Map(); + let methods: string[]; + try { + methods = await new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout( + () => fail(new Error(`Plugin ${pluginId} did not initialize`)), + REQUEST_TIMEOUT_MS, + ); + const fail = (error: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }; + child.on("message", (message) => { + if (message.type === "ready") { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(message.methods); + } else if (message.type === "fatal") { + fail(new Error(message.error)); + } + }); + child.on("close", () => fail(new Error(`Plugin ${pluginId} exited during initialization`))); + void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(fail); + }); + } catch (error) { + terminatePluginChild(child); + throw error; + } + const loaded: LoadedPlugin = { + id: pluginId, + clientBundle: bundles.clientBundle, + methods: new Set(methods), + child, + pending, + }; + child.on("message", (message) => this.handleChildMessage(loaded, message)); + child.on("close", () => this.handleChildClose(loaded)); + this.logger.info({ pluginId, methods }, "Loaded plugin"); + return loaded; + } + + private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type !== "result" && message.type !== "error") return; + const pending = loaded.pending.get(message.requestId); + if (!pending) return; + loaded.pending.delete(message.requestId); + clearTimeout(pending.timeout); + if (message.type === "result") pending.resolve(message.output); + else pending.reject(new Error(message.error)); + } + + private handleChildClose(loaded: LoadedPlugin): void { + if (this.plugins.get(loaded.id) === loaded) { + this.plugins.delete(loaded.id); + this.notify(loaded.id, `Plugin process exited: ${loaded.id}`); + } + this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); + } + + private async stopPlugin(loaded: LoadedPlugin): Promise { + if (loaded.child.killed) return; + let didClose = false; + const closed = new Promise((resolve) => + loaded.child.on("close", () => { + didClose = true; + resolve(); + }), + ); + if (loaded.child.connected) { + await send(loaded.child, { type: "shutdown" }).catch(() => undefined); + } + await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 1_000))]); + if (!didClose) terminatePluginChild(loaded.child); + await closed; + } + + private rejectPending(loaded: LoadedPlugin, message: string): void { + for (const invocation of loaded.pending.values()) { + clearTimeout(invocation.timeout); + invocation.reject(new Error(message)); + } + loaded.pending.clear(); + } + + private notify(pluginId: string, error?: string): void { + for (const listener of this.listeners) listener(pluginId, error); + } +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 19dbb9c0dc..d95c268e54 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -326,6 +326,7 @@ interface SessionForTestOptions { messages?: unknown[]; targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>; binaryMessages?: Uint8Array[]; + pluginRuntime?: SessionOptions["pluginRuntime"]; } function createSessionForTest(options: SessionForTestOptions = {}): Session { @@ -492,6 +493,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { })), onChange: vi.fn(() => () => {}), }), + pluginRuntime: options.pluginRuntime, stt: options.stt ?? null, tts: null, terminalManager: options.terminalManager ?? null, @@ -511,6 +513,71 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { return new Session(sessionOptions); } +test("routes plugin management requests and catalog notifications", async () => { + const messages: SessionOutboundMessage[] = []; + const listeners = new Set<(pluginId: string) => void>(); + const plugin = { + id: "example", + path: "/plugins/example", + enabled: true, + status: "running" as const, + }; + const pluginRuntime: NonNullable = { + listPlugins: () => [plugin], + installDirectory: async () => plugin, + inspectDirectory: async () => ({ id: "example" }), + reloadPlugin: async () => plugin, + enablePlugin: async () => plugin, + disablePlugin: async () => ({ ...plugin, enabled: false, status: "disabled" }), + removePlugin: async () => undefined, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + catalog: () => [{ id: "example", clientBundle: "bundle" }], + invokePluginRpc: async () => ({ ok: true }), + }; + const session = createSessionForTest({ messages, pluginRuntime }); + + await session.handleMessage({ type: "plugin.list.request", requestId: "list" }); + await session.handleMessage({ + type: "plugin.directory.install.request", + requestId: "install", + path: "/plugins/example", + }); + await session.handleMessage({ + type: "plugin.reload.request", + requestId: "reload", + pluginId: "example", + }); + await session.handleMessage({ + type: "plugin.disable.request", + requestId: "disable", + pluginId: "example", + }); + await session.handleMessage({ + type: "plugin.remove.request", + requestId: "remove", + pluginId: "example", + }); + for (const listener of listeners) listener("example"); + + expect(messages.map((message) => message.type)).toEqual([ + "plugin.list.response", + "plugin.directory.install.response", + "plugin.reload.response", + "plugin.disable.response", + "plugin.remove.response", + "status", + ]); + expect(messages.at(-1)).toEqual({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "example" }, + }); + await session.cleanup(); + expect(listeners.size).toBe(0); +}); + describe("session authorization scopes", () => { test("routes named-agent validation through the session source", async () => { const messages: SessionOutboundMessage[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2dc3632e0a..62b78b34bc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -466,6 +466,21 @@ export interface SessionOptions { workspaceGitService: WorkspaceGitService; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; + pluginRuntime?: { + listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; + installDirectory(input: { + path: string; + id?: string; + }): Promise; + inspectDirectory(path: string): Promise<{ id: string }>; + reloadPlugin(pluginId: string): Promise; + enablePlugin(pluginId: string): Promise; + disablePlugin(pluginId: string): Promise; + removePlugin(pluginId: string): Promise; + subscribe(listener: (pluginId: string) => void): () => void; + catalog(): Array<{ id: string; clientBundle: string }>; + invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; + }; mcpBaseUrl?: string | null; stt: Resolvable; sttLanguage?: string; @@ -645,8 +660,10 @@ export class Session { private readonly workspaceRecovery: WorkspaceRecoveryService; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null; + private unsubscribePluginChanges: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; private registryMutationQueue: Promise = Promise.resolve(); private readonly workspacesAwaitingInitialAgent = new Set(); @@ -729,6 +746,7 @@ export class Session { workspaceGitService, workspaceAutoName, daemonConfigStore, + pluginRuntime, stt, sttLanguage, tts, @@ -768,6 +786,8 @@ export class Session { this.paseoHome = paseoHome; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; + this.pluginRuntime = pluginRuntime; + this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -1900,12 +1920,117 @@ export class Session { this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? + this.dispatchPluginDirectoryMessage(msg) ?? + this.dispatchPluginMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchScheduleMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } + private dispatchPluginMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "plugin.list.request") { + this.emit({ + type: "plugin.list.response", + payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] }, + }); + return undefined; + } + if (msg.type === "plugin.reload.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.reloadPlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.reload.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.enable.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.enablePlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.enable.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.disable.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.disablePlugin(msg.pluginId).then((plugin) => { + this.emit({ + type: "plugin.disable.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.remove.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.removePlugin(msg.pluginId).then(() => { + this.emit({ type: "plugin.remove.response", payload: { requestId: msg.requestId } }); + return undefined; + }); + } + if (msg.type === "plugin.catalog.get.request") { + this.emit({ + type: "plugin.catalog.get.response", + payload: { + requestId: msg.requestId, + plugins: this.pluginRuntime?.catalog() ?? [], + }, + }); + return undefined; + } + if (msg.type === "plugin.rpc.invoke.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime + .invokePluginRpc(msg.pluginId, msg.method, msg.input) + .then((output) => { + this.emit({ + type: "plugin.rpc.invoke.response", + payload: { requestId: msg.requestId, output }, + }); + return undefined; + }); + } + return undefined; + } + + private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "plugin.directory.install.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.installDirectory({ path: msg.path, id: msg.id }).then((plugin) => { + this.emit({ + type: "plugin.directory.install.response", + payload: { requestId: msg.requestId, plugin }, + }); + return undefined; + }); + } + if (msg.type === "plugin.directory.inspect.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.inspectDirectory(msg.path).then(({ id }) => { + this.emit({ + type: "plugin.directory.inspect.response", + payload: { requestId: msg.requestId, id }, + }); + return undefined; + }); + } + return undefined; + } + + private subscribeToPluginChanges( + pluginRuntime: SessionOptions["pluginRuntime"], + ): (() => void) | null { + if (!pluginRuntime) return null; + return pluginRuntime.subscribe((pluginId) => { + this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } }); + }); + } + private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": @@ -7267,6 +7392,8 @@ export class Session { } this.unsubscribeProjectMutations?.(); this.unsubscribeProjectMutations = null; + this.unsubscribePluginChanges?.(); + this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; this.agentUpdates.dispose(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 893e5b9c86..6befe44847 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -30,7 +30,12 @@ import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frame import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; -import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; +import { + Session, + type SessionLifecycleIntent, + type SessionOptions, + type SessionRuntimeMetrics, +} from "./session.js"; import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; @@ -604,6 +609,7 @@ export class VoiceAssistantWebSocketServer { private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; private readonly directorySync = new DirectorySyncService(); + private readonly pluginRuntime: SessionOptions["pluginRuntime"]; constructor( server: HTTPServer, @@ -650,6 +656,7 @@ export class VoiceAssistantWebSocketServer { workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), workspaceRuntime?: WorkspaceRuntimeService, providerProbe?: ProviderProbeService, + pluginRuntime?: SessionOptions["pluginRuntime"], ) { this.logger = logger.child({ module: "websocket-server" }); this.workspaceSetupRuntime = workspaceSetupRuntime; @@ -663,6 +670,7 @@ export class VoiceAssistantWebSocketServer { this.daemonRuntimeConfig = daemonRuntimeConfig; this.browserToolsBroker = browserToolsBroker ?? null; this.hubRelationships = hubRelationships ?? null; + this.pluginRuntime = pluginRuntime; this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); @@ -1391,6 +1399,7 @@ export class VoiceAssistantWebSocketServer { workspaceGitService: this.workspaceGitService, workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, + pluginRuntime: this.pluginRuntime, mcpBaseUrl: this.mcpBaseUrl, stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", @@ -1592,6 +1601,9 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. pushTokenRevocation: true, + // COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07. + plugins: true, + pluginManagement: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. diff --git a/plugin-examples/linear/README.md b/plugin-examples/linear/README.md new file mode 100644 index 0000000000..a6480f50c2 --- /dev/null +++ b/plugin-examples/linear/README.md @@ -0,0 +1,33 @@ +# Linear plugin example + +This example adds `Attach Linear issue` to the composer. Search by issue identifier +or title, then send a stable issue snapshot to the agent. + +Set a personal API key in the daemon environment: + +```bash +export LINEAR_API_KEY="lin_api_..." +``` + +Register the extension in `$PASEO_HOME/config.json`: + +```json +{ + "pluginsEnabled": true, + "plugins": { + "linear": { + "source": "directory", + "path": "/absolute/path/to/paseo/plugin-examples/linear" + } + } +} +``` + +Restart the development daemon after changing its environment or plugin configuration. + +The entry point demonstrates the complete attachment-source pattern: + +- define one validated search RPC; +- handle it in the daemon subprocess; +- register a declarative attachment source for the client; +- keep credentials and vendor API calls out of the client bundle. diff --git a/plugin-examples/linear/index.tsx b/plugin-examples/linear/index.tsx new file mode 100644 index 0000000000..25b8ae3355 --- /dev/null +++ b/plugin-examples/linear/index.tsx @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { + defineAttachmentSource, + defineRpc, + type PluginAttachmentSearchPayload, + type PluginContext, +} from "@paseo/plugin"; +import { createLinearIssueSearch } from "./linear"; + +const SearchPayloadSchema = z.object({ + items: z.array( + z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + text: z.string(), + resourceType: z.string(), + }), + ), +}); + +const searchIssues = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: SearchPayloadSchema, +}); + +const issueAttachments = defineAttachmentSource({ + id: "issues", + title: "Linear issue", + icon: "CircleDot", + pickerTitle: "Attach Linear issue", + searchPlaceholder: "Search by identifier or title", + search: searchIssues, +}); + +export default function contribute(plugin: PluginContext) { + plugin.handle(searchIssues, async ({ query }): Promise => { + const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); + return linear.search(query); + }); + plugin.addAttachmentSource(issueAttachments); + return () => undefined; +} diff --git a/plugin-examples/linear/linear.test.ts b/plugin-examples/linear/linear.test.ts new file mode 100644 index 0000000000..01fa002697 --- /dev/null +++ b/plugin-examples/linear/linear.test.ts @@ -0,0 +1,135 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { createLinearIssueSearch } from "./linear"; + +const issue = { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + description: "Let extensions attach external context.", + url: "https://linear.app/acme/issue/ENG-123/plugin-attachments", + priorityLabel: "High", + state: { name: "In Progress" }, + assignee: { name: "Mohamed" }, + project: { name: "Paseo" }, + labels: { nodes: [{ name: "Feature" }] }, +}; + +interface CapturedRequest { + authorization: string | undefined; + body: { query: string; variables: Record }; +} + +async function readRequest(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return { + authorization: request.headers.authorization, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"], + }; +} + +async function withLinearServer( + respond: (request: CapturedRequest, response: ServerResponse) => void, + run: (endpoint: string) => Promise, +): Promise { + const server = createServer((request, response) => { + void readRequest(request).then((captured) => respond(captured, response)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + try { + return await run(`http://127.0.0.1:${address.port}/graphql`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +function sendJson(response: ServerResponse, body: unknown): void { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +describe("Linear issue search", () => { + it("resolves an exact issue identifier and formats its agent snapshot", async () => { + const result = await withLinearServer( + (request, response) => { + expect(request.authorization).toBe("lin_api_test"); + expect(request.body.variables).toEqual({ id: "ENG-123" }); + sendJson(response, { data: { issue } }); + }, + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search("eng-123"); + }, + ); + + expect(result).toEqual({ + items: [ + { + id: "issue-uuid", + identifier: "ENG-123", + title: "Plugin attachments", + subtitle: "In Progress · Mohamed", + url: issue.url, + resourceType: "issue", + text: [ + "Linear issue ENG-123: Plugin attachments", + `URL: ${issue.url}`, + "Status: In Progress", + "Priority: High", + "Assignee: Mohamed", + "Project: Paseo", + "Labels: Feature", + "", + "Let extensions attach external context.", + ].join("\n"), + }, + ], + }); + }); + + it("searches issue titles through a case-insensitive filter", async () => { + const result = await withLinearServer( + (request, response) => { + expect(request.body.variables).toEqual({ + filter: { title: { containsIgnoreCase: "plugin" } }, + }); + sendJson(response, { data: { issues: { nodes: [issue] } } }); + }, + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search(" plugin "); + }, + ); + + expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]); + }); + + it("surfaces GraphQL errors even when HTTP succeeds", async () => { + await expect( + withLinearServer( + (_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }), + async (endpoint) => { + const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint }); + return linear.search("ENG-123"); + }, + ), + ).rejects.toThrow("Token expired"); + }); + + it("requires a daemon-side API key", () => { + expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow( + "Set LINEAR_API_KEY in the daemon environment", + ); + }); +}); diff --git a/plugin-examples/linear/linear.ts b/plugin-examples/linear/linear.ts new file mode 100644 index 0000000000..ede9e1c5f5 --- /dev/null +++ b/plugin-examples/linear/linear.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; + +const LinearIssueSchema = z.object({ + id: z.string(), + identifier: z.string(), + title: z.string(), + description: z.string().nullable(), + url: z.string(), + priorityLabel: z.string(), + state: z.object({ name: z.string() }), + assignee: z.object({ name: z.string() }).nullable(), + project: z.object({ name: z.string() }).nullable(), + labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }), +}); + +const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough(); +const ExactIssueResponseSchema = z.object({ + data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(), + errors: z.array(LinearGraphqlErrorSchema).optional(), +}); +const IssueSearchResponseSchema = z.object({ + data: z + .object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) }) + .nullable() + .optional(), + errors: z.array(LinearGraphqlErrorSchema).optional(), +}); + +const ISSUE_FIELDS = ` + id + identifier + title + description + url + priorityLabel + state { name } + assignee { name } + project { name } + labels { nodes { name } } +`; + +const EXACT_ISSUE_QUERY = ` + query PaseoLinearIssue($id: String!) { + issue(id: $id) { ${ISSUE_FIELDS} } + } +`; + +const SEARCH_ISSUES_QUERY = ` + query PaseoLinearIssues($filter: IssueFilter) { + issues(first: 20, filter: $filter, orderBy: updatedAt) { + nodes { ${ISSUE_FIELDS} } + } + } +`; + +const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i; + +export interface LinearAttachmentItem { + id: string; + identifier: string; + title: string; + subtitle?: string; + url: string; + text: string; + resourceType: string; +} + +export interface LinearIssueSearch { + search(query: string): Promise<{ items: LinearAttachmentItem[] }>; +} + +interface LinearIssueSearchOptions { + apiKey: string; + endpoint?: string; + request?: typeof fetch; +} + +interface GraphqlRequest { + query: string; + variables: Record; +} + +class LinearApiError extends Error { + constructor(message: string) { + super(message); + this.name = "LinearApiError"; + } +} + +function issueSubtitle(issue: z.infer): string | undefined { + const parts = [issue.state.name, issue.assignee?.name].filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +function issueText(issue: z.infer): string { + const labels = issue.labels.nodes.map((label) => label.name).join(", "); + const lines = [ + `Linear issue ${issue.identifier}: ${issue.title}`, + `URL: ${issue.url}`, + `Status: ${issue.state.name}`, + `Priority: ${issue.priorityLabel}`, + ]; + if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`); + if (issue.project) lines.push(`Project: ${issue.project.name}`); + if (labels) lines.push(`Labels: ${labels}`); + lines.push("", issue.description ?? "No description."); + return lines.join("\n"); +} + +function toAttachmentItem(issue: z.infer): LinearAttachmentItem { + const subtitle = issueSubtitle(issue); + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + ...(subtitle ? { subtitle } : {}), + url: issue.url, + text: issueText(issue), + resourceType: "issue", + }; +} + +function describeHttpFailure(status: number): string { + if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY"; + if (status === 429) return "Linear rate limit reached. Try again shortly"; + return `Linear API request failed with HTTP ${status}`; +} + +function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void { + if (!errors || errors.length === 0) return; + throw new LinearApiError(errors.map((error) => error.message).join("; ")); +} + +export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch { + const apiKey = options.apiKey.trim(); + if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment"); + const endpoint = options.endpoint ?? "https://api.linear.app/graphql"; + const request = options.request ?? fetch; + + async function graphql(body: GraphqlRequest): Promise { + const response = await request(endpoint, { + method: "POST", + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status)); + return response.json(); + } + + async function findExact(identifier: string): Promise { + const response = ExactIssueResponseSchema.parse( + await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }), + ); + throwGraphqlErrors(response.errors); + return response.data?.issue ? [toAttachmentItem(response.data.issue)] : []; + } + + async function searchTitles(query: string): Promise { + const filter = query ? { title: { containsIgnoreCase: query } } : null; + const response = IssueSearchResponseSchema.parse( + await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }), + ); + throwGraphqlErrors(response.errors); + if (!response.data) throw new LinearApiError("Linear returned no issue data"); + return response.data.issues.nodes.map(toAttachmentItem); + } + + return { + async search(query: string) { + const normalized = query.trim(); + if (LINEAR_IDENTIFIER.test(normalized)) { + return { items: await findExact(normalized.toUpperCase()) }; + } + const items = await searchTitles(normalized); + return { items }; + }, + }; +} diff --git a/plugin-examples/linear/paseo-plugin.json b/plugin-examples/linear/paseo-plugin.json new file mode 100644 index 0000000000..c8fa978d91 --- /dev/null +++ b/plugin-examples/linear/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "linear" +} diff --git a/plugin-examples/local-plugin/index.tsx b/plugin-examples/local-plugin/index.tsx new file mode 100644 index 0000000000..5f6ca6a481 --- /dev/null +++ b/plugin-examples/local-plugin/index.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { z } from "zod"; +import { defineRpc, type PluginContext, type PluginSurfaceProps, useRpc } from "@paseo/plugin"; + +const incrementRpc = defineRpc({ + name: "increment", + input: z.object({ value: z.number() }), + output: z.object({ value: z.number(), handledBy: z.string() }), +}); + +const ExampleThemeSchema = z.object({ + colors: z.object({ + surface0: z.string(), + foreground: z.string(), + foregroundMuted: z.string(), + accent: z.string(), + accentForeground: z.string(), + statusDanger: z.string(), + }), +}); + +function ExampleSurface({ theme }: PluginSurfaceProps) { + const { colors } = ExampleThemeSchema.parse(theme); + const callIncrement = useRpc(incrementRpc); + const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement }); + const value = data?.value ?? 0; + const styles = useMemo( + () => ({ + screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 }, + title: { color: colors.foreground, fontSize: 24 }, + detail: { color: colors.foregroundMuted }, + button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent }, + buttonText: { color: colors.accentForeground, textAlign: "center" as const }, + error: { color: colors.statusDanger }, + }), + [colors], + ); + const handleIncrement = useCallback(() => { + mutate({ value }); + }, [mutate, value]); + + return ( + + Native plugin surface + {data?.handledBy ?? "The RPC has not run yet."} + + + {isPending ? "Calling daemon…" : `RPC counter: ${value}`} + + + {error ? {error.message} : null} + + ); +} + +export default function contribute(plugin: PluginContext) { + plugin.handle(incrementRpc, async (input) => ({ + value: input.value + 1, + handledBy: "plugin subprocess", + })); + plugin.addSurface("main", ExampleSurface); + plugin.addSidebarItem({ + id: "main", + title: "Local plugin", + icon: "Blocks", + surface: "main", + }); + return () => undefined; +} diff --git a/plugin-examples/local-plugin/paseo-plugin.json b/plugin-examples/local-plugin/paseo-plugin.json new file mode 100644 index 0000000000..d1a1b37435 --- /dev/null +++ b/plugin-examples/local-plugin/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "local-example" +} diff --git a/scripts/check-workspace-runtime-boundaries.mjs b/scripts/check-workspace-runtime-boundaries.mjs index 63aaddb8b6..23aac63939 100644 --- a/scripts/check-workspace-runtime-boundaries.mjs +++ b/scripts/check-workspace-runtime-boundaries.mjs @@ -24,6 +24,7 @@ const migratedOwners = [ ]; const hostModules = new Set(["child_process", "fs", "fs/promises", "module", "which"]); const auditedNonliteralServerLoads = new Set([ + "packages/server/src/server/plugins/plugin-process.ts|runtimeRequire|nodeRequire|load", "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts|resolveRuntimeCommand|moduleRequire|resolve", "packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts|resolveSherpaLoaderEnv|require|resolve", "packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts|loadWithRequire|requireFn|load", diff --git a/scripts/workspace-runtime-boundaries.test.mjs b/scripts/workspace-runtime-boundaries.test.mjs index 8282d92db4..24428468af 100644 --- a/scripts/workspace-runtime-boundaries.test.mjs +++ b/scripts/workspace-runtime-boundaries.test.mjs @@ -328,6 +328,18 @@ test("the AST guard follows direct createRequire property calls", async (t) => { test("audited production loader exceptions are narrow", async (t) => { const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/plugins/plugin-process.ts", + 'import { createRequire } from "node:module";\n' + + 'const nodeRequire = createRequire("package.json");\n' + + "function runtimeRequire(target) {\n" + + " return nodeRequire(target);\n" + + "}\n" + + "function unreviewedPluginLoad(target) {\n" + + " return nodeRequire(target);\n" + + "}\n", + ); await source( root, "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts", @@ -359,6 +371,13 @@ test("audited production loader exceptions are narrow", async (t) => { ); assert.deepEqual(await findWorkspaceBoundaryViolations(root), [ + { + file: "packages/server/src/server/plugins/plugin-process.ts", + import: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/plugins/plugin-process.ts: nonliteral-module-load forbids ", + }, { file: "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts", import: "", diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md new file mode 100644 index 0000000000..c1c77ff65e --- /dev/null +++ b/skills/paseo-plugin/SKILL.md @@ -0,0 +1,56 @@ +--- +name: paseo-plugin +description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, or remove a Paseo plugin. +user-invocable: true +--- + +# Paseo plugins + +Start by scaffolding a strict, typecheckable TSX project: + +```bash +paseo plugin init /absolute/path/to/my-plugin +cd /absolute/path/to/my-plugin +npm install +``` + +The generated directory contains `paseo-plugin.json`, `index.tsx`, `paseo-plugin.d.ts`, +`package.json`, and `tsconfig.json`. The manifest contains one default ID: + +```json +{ "id": "my-plugin" } +``` + +Default-export one contribution function from `index.tsx`. Use `@paseo/plugin` and the host-provided React, React Native, TanStack Query, and Zod dependencies. The function must return cleanup for resources created by plugin code, even when it has nothing to clean up: + +```tsx +import type { PluginContext } from "@paseo/plugin"; +import React from "react"; +import { Text } from "react-native"; + +function MainSurface() { + return Hello from my plugin; +} + +export default function contribute(plugin: PluginContext) { + plugin.addSurface("main", MainSurface); + return () => undefined; +} +``` + +Manage the plugin through the daemon CLI: + +```bash +paseo plugin install /absolute/path/to/plugin +paseo plugin install /absolute/path/to/plugin --id another-runtime-id +paseo plugin reload my-plugin +paseo plugin ls +paseo plugin disable my-plugin +paseo plugin enable my-plugin +paseo plugin remove my-plugin +``` + +After source edits, reload explicitly and use the reported load error to correct the plugin. Use `enable`, `disable`, and `remove` for recovery. Never edit `$PASEO_HOME/config.json` directly. Plugins are trusted code and run without sandboxing. + +Run `npm run typecheck` before every install or reload. The installed packages and local SDK +declarations exist only for static checking; Paseo provides the runtime modules.