From 35d2457d8326b7e4950d5bf5fef6463550d15f67 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 4 Sep 2026 19:56:57 +0200 Subject: [PATCH] Let plugins provide coding agents (#4314) Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation. Closes #3547 Closes #3556 --- docs/plugins.md | 46 + nix/npm-deps.hash | 2 +- package-lock.json | 10 + .../agent-control-registration.tsx | 3 +- packages/app/src/components/agent-list.tsx | 2 +- .../src/components/import-session-sheet.tsx | 14 +- packages/app/src/components/model-browser.tsx | 50 +- .../src/components/provider-icon-name.test.ts | 44 +- .../app/src/components/provider-icon-name.ts | 27 +- .../app/src/components/provider-icons.test.ts | 33 + packages/app/src/components/provider-icons.ts | 26 +- .../schedules/schedule-form-sheet.tsx | 14 +- .../src/components/schedules/schedule-row.tsx | 14 +- .../components/schedules/schedules-table.tsx | 1 + .../composer/agent-controls/model-sheet.tsx | 2 +- packages/app/src/data/push-router.ts | 2 + .../src/hooks/use-providers-snapshot.test.ts | 19 + .../app/src/hooks/use-providers-snapshot.ts | 3 + packages/app/src/panels/agent-panel.tsx | 2 +- packages/app/src/panels/agent-tracks.tsx | 1 + .../src/panels/provider-subagent-panel.tsx | 2 +- .../screens/settings/providers-section.tsx | 5 +- packages/app/src/subagents/track.tsx | 11 +- packages/desktop/e2e/browser-tabs.e2e.mjs | 2 +- packages/plugin/package.json | 11 + .../plugin/src/acp-internal/connection.ts | 1373 ++++++++++++++ packages/plugin/src/acp.test.ts | 985 ++++++++++ packages/plugin/src/acp.ts | 122 ++ packages/plugin/src/contracts.ts | 2 + packages/plugin/src/provider.ts | 1376 ++++++++++++++ packages/plugin/tsconfig.json | 1 + packages/protocol/src/agent-types.ts | 1 + packages/protocol/src/messages.ts | 1 + .../src/provider-snapshot-codec.test.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 1 + .../src/server/agent/plugin-provider.test.ts | 347 ++++ .../src/server/agent/plugin-provider.ts | 1674 +++++++++++++++++ .../src/server/agent/provider-registry.ts | 1 + .../agent/provider-snapshot-manager.test.ts | 73 + .../server/agent/provider-snapshot-manager.ts | 64 +- .../providers/provider-windows-launch.test.ts | 2 +- packages/server/src/server/bootstrap.ts | 9 + .../src/server/plugins/index.posix.test.ts | 53 + packages/server/src/server/plugins/index.ts | 123 +- .../server/plugins/plugin-process-protocol.ts | 173 +- .../src/server/plugins/plugin-process.ts | 208 +- .../src/server/plugins/provider-icon.test.ts | 84 + .../src/server/plugins/provider-icon.ts | 64 + .../src/server/plugins/runtime.posix.test.ts | 870 ++++++++- packages/server/src/server/plugins/runtime.ts | 543 +++++- .../provider-acp-transformer/README.md | 11 + .../provider-acp-transformer/icon.svg | 4 + .../provider-acp-transformer/index.server.ts | 17 + .../paseo-plugin.json | 6 + .../server/vendor-edit.test.ts | 42 + .../server/vendor-edit.ts | 25 + plugin-examples/provider-direct/README.md | 12 + .../client/provider-result.tsx | 32 + plugin-examples/provider-direct/icon.svg | 4 + .../provider-direct/index.client.tsx | 13 + .../provider-direct/index.server.ts | 7 + .../provider-direct/paseo-plugin.json | 3 + .../provider-direct/server/provider.test.ts | 169 ++ .../provider-direct/server/provider.ts | 401 ++++ .../provider-direct/shared/provider-result.ts | 8 + public-docs/plugins/v0.8/index.md | 5 +- public-docs/plugins/v0.8/providers.md | 407 ++++ public-docs/plugins/v0.8/reference.md | 39 +- 68 files changed, 9577 insertions(+), 125 deletions(-) create mode 100644 packages/app/src/components/provider-icons.test.ts create mode 100644 packages/plugin/src/acp-internal/connection.ts create mode 100644 packages/plugin/src/acp.test.ts create mode 100644 packages/plugin/src/acp.ts create mode 100644 packages/plugin/src/provider.ts create mode 100644 packages/server/src/server/agent/plugin-provider.test.ts create mode 100644 packages/server/src/server/agent/plugin-provider.ts create mode 100644 packages/server/src/server/plugins/provider-icon.test.ts create mode 100644 packages/server/src/server/plugins/provider-icon.ts create mode 100644 plugin-examples/provider-acp-transformer/README.md create mode 100644 plugin-examples/provider-acp-transformer/icon.svg create mode 100644 plugin-examples/provider-acp-transformer/index.server.ts create mode 100644 plugin-examples/provider-acp-transformer/paseo-plugin.json create mode 100644 plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts create mode 100644 plugin-examples/provider-acp-transformer/server/vendor-edit.ts create mode 100644 plugin-examples/provider-direct/README.md create mode 100644 plugin-examples/provider-direct/client/provider-result.tsx create mode 100644 plugin-examples/provider-direct/icon.svg create mode 100644 plugin-examples/provider-direct/index.client.tsx create mode 100644 plugin-examples/provider-direct/index.server.ts create mode 100644 plugin-examples/provider-direct/paseo-plugin.json create mode 100644 plugin-examples/provider-direct/server/provider.test.ts create mode 100644 plugin-examples/provider-direct/server/provider.ts create mode 100644 plugin-examples/provider-direct/shared/provider-result.ts create mode 100644 public-docs/plugins/v0.8/providers.md diff --git a/docs/plugins.md b/docs/plugins.md index 2b523a7c1c..03f38b6172 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -165,6 +165,8 @@ See `public-docs/plugins/v0.8/reference.md`. | `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks | | `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks | | `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` | +| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts | +| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks | The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:` import reachable from client code. Shared modules cannot import runtime-owned modules. A relative @@ -253,6 +255,50 @@ optional client-owned agent and workspace navigation; its absence is the compati older clients. Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not receive Expo Router or workspace-layout store access. +## Contribute a provider + +Register a provider from `index.server.ts`. The provider connection is callback-based and owns all +of its sessions; plugin RPC is not part of the provider data path. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import { createProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createProvider() satisfies ProviderRegistration); + return () => {}; +} +``` + +`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state, +configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`. +Route messages, structured commands, steering, and command side effects through `session.prompt`. +Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in +the opaque `providerOptions` config object. + +Agent refresh closes the current provider session and opens it again with current configuration and +persistence. Providers re-read credentials, environment, global configuration, and MCP servers on +`session.open`; there is no provider reload input. + +For an ACP command, register `runAcpProvider({ id, label, command })` from +`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the +whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct` +and `plugin-examples/provider-acp-transformer`. + +Provider-emitted plugin timeline items use the same renderer registration as transformed and +daemon-appended plugin items. The direct example includes both sides. The renderer-only +`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a +provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md) +owns author workflow, lifecycle, testing, and distribution guidance. + +`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. +It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be +self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and +external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are +allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline +SVG or URL. + ## Contribute composer pills Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 4d257077c9..511b1444e3 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M= +sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA= diff --git a/package-lock.json b/package-lock.json index 6e9576aba6..a7daa8db3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38799,6 +38799,7 @@ "name": "@getpaseo/plugin", "version": "0.7.2", "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { @@ -38818,6 +38819,15 @@ "zod": "^4.4.3" } }, + "packages/plugin/node_modules/@agentclientprotocol/sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz", + "integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.7.2", diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index ecc7e8985c..50e61ed9fd 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -96,7 +96,8 @@ export function useAgentControlCommandCenterActions(input: { provider: controls.provider ?? null, labels: buildAgentControlContributionLabels(t), icons: { - provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)), + provider: (provider) => + getCommandCenterIcon(getProviderIcon(provider, controls.serverId)), thinking: getCommandCenterIcon(ThinkingIcon), planMode: getCommandCenterIcon(PlanModeIcon), mode: (modeId) => diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 5083d63916..58023da386 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -232,7 +232,7 @@ function SessionRow({ const projectName = agent.projectPlacement?.projectName ?? ""; const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; const workspaceName = agent.projectPlacement?.workspaceName ?? ""; - const ProviderIcon = getProviderIcon(agent.provider); + const ProviderIcon = getProviderIcon(agent.provider, agent.serverId); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const rangesFor = useCallback( (field: AgentSearchMatch["field"]) => diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 4ae38a9aea..ebfabd08f5 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -290,12 +290,14 @@ function SheetEmptyState({ title }: { title: string }) { } function ImportSessionSheetRow({ + serverId, entry, disabled, importing, folder, onImportSession, }: { + serverId: string | null; entry: FetchRecentProviderSessionEntry; disabled: boolean; importing: boolean; @@ -308,7 +310,7 @@ function ImportSessionSheetRow({ const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); - const ProviderIcon = getProviderIcon(entry.providerId); + const ProviderIcon = getProviderIcon(entry.providerId, serverId); const accessibilityState = useMemo( () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), [disabled], @@ -364,12 +366,14 @@ function ImportSessionSheetRow({ } function SessionRows({ + serverId, entries, disabled, importingSessionKey, resolveFolder, onImportSession, }: { + serverId: string | null; entries: ReadonlyArray; disabled: boolean; importingSessionKey: string | null; @@ -381,6 +385,7 @@ function SessionRows({ {entries.map((entry) => ( (); map.set(ALL_FILTER_VALUE, ); for (const provider of filterProviders) { - const ProviderIcon = getProviderIcon(provider); + const ProviderIcon = getProviderIcon(provider, serverId); map.set(provider, ); } return map; - }, [filterProviders, theme.colors.foregroundMuted]); + }, [filterProviders, serverId, theme.colors.foregroundMuted]); const renderFilterOption = useCallback( ({ @@ -736,7 +741,7 @@ export function ImportSessionSheet({ ) : ( (() => { - const ProviderIcon = getProviderIcon(selectedProvider); + const ProviderIcon = getProviderIcon(selectedProvider, serverId); return ; })() )} @@ -773,6 +778,7 @@ export function ImportSessionSheet({ ) : null} {visibleEntries.length > 0 ? ( { + serverId: string | null; view: ModelBrowserView; providers: ProviderSelectorProvider[]; selectedProvider: string; @@ -209,14 +211,16 @@ type ProviderGlyphTone = "muted" | "foreground"; export function ModelProviderGlyph({ provider, + serverId, size, tone = "muted", }: { provider: string; + serverId?: string | null; size: number; tone?: ProviderGlyphTone; }) { - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; return ; @@ -330,7 +334,12 @@ export function useModelBrowser({ return { title: view.providerLabel, leading: ( - + ), back: singleProviderView ? undefined : { onPress: showAll }, actions: ( @@ -389,6 +398,7 @@ export function useModelBrowser({ ); return { + serverId, providers, selectedProvider, selectedModel, @@ -652,6 +662,7 @@ function ModelRowProfileAction({ function ModelRow({ row, + serverId, isSelected, showProviderLabel = false, onPress, @@ -661,6 +672,7 @@ function ModelRow({ onEditProfiles, }: { row: ProviderSelectionModelRow; + serverId: string | null; isSelected: boolean; showProviderLabel?: boolean; onPress: () => void; @@ -672,8 +684,8 @@ function ModelRow({ const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const leadingSlot = useMemo( - () => , - [row.provider], + () => , + [row.provider, serverId], ); const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; @@ -810,6 +822,7 @@ function ModelRow({ function SelectableModelRow({ row, + serverId, isSelected, showProviderLabel, onSelect, @@ -819,6 +832,7 @@ function SelectableModelRow({ onEditProfiles, }: { row: ProviderSelectionModelRow; + serverId: string | null; isSelected: boolean; showProviderLabel?: boolean; onSelect: (provider: string, modelId: string) => void; @@ -833,6 +847,7 @@ function SelectableModelRow({ return ( void; }) { const { t } = useTranslation(); @@ -988,8 +1005,8 @@ function GroupProviderButton({ ); }, [selection, t]); const leadingSlot = useMemo( - () => , - [provider.id], + () => , + [provider.id, serverId], ); const trailingSlot = useMemo( () => ( @@ -1016,9 +1033,11 @@ function GroupProviderButton({ function GroupedProviderRows({ providers, + serverId, onDrillDown, }: { providers: ProviderSelectorProvider[]; + serverId: string | null; onDrillDown: (providerId: string, providerLabel: string) => void; }) { return ( @@ -1026,7 +1045,7 @@ function GroupedProviderRows({ {providers.map((provider, index) => ( {index > 0 ? : null} - + ))} @@ -1111,6 +1130,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) { function ModelRowList({ rows, + serverId, selectedProvider, selectedModel, onSelect, @@ -1123,6 +1143,7 @@ function ModelRowList({ onEditProfiles, }: { rows: ProviderSelectionModelRow[]; + serverId: string | null; selectedProvider: string; selectedModel: string; onSelect: (provider: string, modelId: string) => void; @@ -1139,6 +1160,7 @@ function ModelRowList({ ({ item }: { item: ProviderSelectionModelRow }) => ( ; provider: ProviderSelectorProvider | null; profiles: AgentProfilePicker | null; @@ -1318,6 +1343,7 @@ function ProviderModelBrowserContent({ } return ( {t("modelSelector.providers")} ) : null} - + ) : null)} {!hasResults ? : null} @@ -1487,6 +1520,7 @@ export function ModelBrowser({ }: ModelBrowserProps) { return ( { it("returns the built-in identifier for known provider ids", () => { @@ -25,6 +25,48 @@ describe("resolveProviderIconName", () => { it("falls back to the bot icon for unknown custom providers", () => { expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" }); }); + + it("resolves a snapshot SVG for a custom provider", () => { + const svg = ''; + + replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]); + + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg }); + }); + + it("replaces each host snapshot without leaking icons across hosts", () => { + const secondSvg = ""; + replaceProviderSnapshotIcons("server-1", [ + { provider: "removed-provider", iconSvg: "" }, + { provider: "snapshot-provider", iconSvg: "" }, + { provider: "claude", iconSvg: "" }, + ]); + replaceProviderSnapshotIcons("server-2", [ + { provider: "snapshot-provider", iconSvg: secondSvg }, + ]); + + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ + kind: "svg", + svg: "", + }); + expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ + kind: "svg", + svg: secondSvg, + }); + expect(resolveProviderIconName("claude", "server-1")).toEqual({ + kind: "builtin", + id: "claude", + }); + + replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]); + + expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" }); + expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" }); + expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({ + kind: "svg", + svg: secondSvg, + }); + }); }); describe("known provider icon names", () => { diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 02cd2ec874..699865fd01 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -2,19 +2,44 @@ import { BUILTIN_PROVIDER_ICON_NAMES, KNOWN_PROVIDER_ICON_NAMES, } from "@getpaseo/protocol/provider-icon-names"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; export type ProviderIconName = | { kind: "builtin"; id: string } | { kind: "catalog"; id: string } + | { kind: "svg"; svg: string } | { kind: "bot" }; const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES); const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES); +const providerSnapshotIconSvgsByServer = new Map>(); -export function resolveProviderIconName(provider: string): ProviderIconName { +export function replaceProviderSnapshotIcons( + serverId: string, + entries: readonly Pick[], +): void { + const icons = new Map(); + for (const entry of entries) { + if (entry.iconSvg) { + icons.set(entry.provider, entry.iconSvg); + } + } + providerSnapshotIconSvgsByServer.set(serverId, icons); +} + +export function resolveProviderIconName( + provider: string, + serverId?: string | null, +): ProviderIconName { if (BUILTIN_PROVIDER_IDS.has(provider)) { return { kind: "builtin", id: provider }; } + const iconSvg = serverId + ? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider) + : undefined; + if (iconSvg) { + return { kind: "svg", svg: iconSvg }; + } if (KNOWN_PROVIDER_IDS.has(provider)) { return { kind: "catalog", id: provider }; } diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts new file mode 100644 index 0000000000..5235218875 --- /dev/null +++ b/packages/app/src/components/provider-icons.test.ts @@ -0,0 +1,33 @@ +import { Bot } from "lucide-react-native"; +import { SvgXml } from "react-native-svg"; +import { describe, expect, it } from "vitest"; +import { replaceProviderSnapshotIcons } from "./provider-icon-name"; +import { getProviderIcon, type ProviderIconComponent } from "./provider-icons"; + +function renderIcon(Component: ProviderIconComponent) { + if (typeof Component !== "function") throw new Error("Expected a function component"); + return (Component as (props: { size: number; color: string }) => unknown)({ + size: 18, + color: "#123456", + }); +} + +describe("getProviderIcon", () => { + it("renders registered snapshot SVG metadata with the requested size and color", () => { + const svg = ''; + replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]); + + const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1")); + + expect(rendered).toMatchObject({ + type: SvgXml, + props: { xml: svg, width: 18, height: 18, color: "#123456" }, + }); + }); + + it("uses the normal Bot fallback without snapshot SVG metadata", () => { + replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]); + + expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot); + }); +}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 92d8c1ca38..6671c95d4d 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -34,17 +34,18 @@ const CATALOG_ICON_SVGS = new Map( ); const catalogIconComponents = new Map(); +const snapshotIconComponents = new Map(); -function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent { - const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) => +function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent { + const SvgProviderIcon: ProviderIconComponent = ({ size, color }) => createElement(SvgXml, { xml: iconSvg, width: size, height: size, color, }); - CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`; - return CatalogProviderIcon; + SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`; + return SvgProviderIcon; } function getCatalogProviderIcon(provider: string): ProviderIconComponent { @@ -56,18 +57,29 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent { if (!iconSvg) { return Bot; } - const icon = createCatalogIcon(provider, iconSvg); + const icon = createSvgIcon(provider, iconSvg); catalogIconComponents.set(provider, icon); return icon; } -export function getProviderIcon(provider: string): ProviderIconComponent { - const name = resolveProviderIconName(provider); +function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent { + const cached = snapshotIconComponents.get(provider); + if (cached?.svg === svg) return cached.component; + const component = createSvgIcon(provider, svg); + snapshotIconComponents.set(provider, { svg, component }); + return component; +} + +export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent { + const name = resolveProviderIconName(provider, serverId); if (name.kind === "builtin") { return BUILTIN_PROVIDER_ICONS[name.id]; } if (name.kind === "catalog") { return getCatalogProviderIcon(name.id); } + if (name.kind === "svg") { + return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg); + } return Bot; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 6b9290632a..2a3cd97c10 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -661,8 +661,8 @@ function ScheduleTargetFields({ [], ); const modelTriggerLeading = useMemo( - () => , - [state.selectedProvider], + () => , + [state.selectedProvider, state.selectedServerId], ); const renderModelTrigger = useCallback( ({ @@ -1019,11 +1019,17 @@ function ThinkingOptionItem({ ); } -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string | null; +}): ReactElement | null { if (!provider) { return null; } - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); return ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index f2799fcf54..07051a54a0 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -59,6 +59,7 @@ export interface ScheduleRowActions { } interface ScheduleRowProps extends ScheduleRowActions { + serverId: string; schedule: ScheduleSummary; /** Client-derived target line (agent title / project / shortened path). */ targetLabel: string; @@ -120,11 +121,17 @@ function buildMeta( /** Small provider glyph. Reads the icon color off a StyleSheet object so the * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string; +}): ReactElement | null { if (!provider) { return null; } - const Icon = getProviderIcon(provider); + const Icon = getProviderIcon(provider, serverId); return ; } @@ -138,6 +145,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement * highlights without reflow. */ export function ScheduleRow({ + serverId, schedule, targetLabel, provider, @@ -189,7 +197,7 @@ export function ScheduleRow({ > - + diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index c591b03d42..d91f95cb72 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -129,6 +129,7 @@ function SchedulesTableRow({ return ( 0 ? getProviderIcon(selectedProvider) : null; + selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null; const ModelIcon = ProviderIcon ?? Bot; const rootHeader = useMemo( () => ({ diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 0af2e8e684..74002bac2e 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -5,6 +5,7 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; @@ -194,6 +195,7 @@ export function applyProvidersSnapshotUpdate(input: { if (input.message.type !== "providers_snapshot_update") { return; } + replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries); const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); input.queryClient.setQueryData(queryKey, { entries: input.message.payload.entries, diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 620668e1fb..08eaa5130f 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -6,6 +6,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { resolveProviderIconName } from "@/components/provider-icon-name"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { fetchProvidersSnapshot, @@ -109,6 +110,24 @@ describe("providersSnapshotQueryKey", () => { }); describe("fetchProvidersSnapshot", () => { + it("registers provider icons from the applied snapshot", async () => { + const svg = ''; + const client = createClient({ + snapshots: [ + providersSnapshot([ + { provider: "snapshot-icon-provider", status: "ready", enabled: true, iconSvg: svg }, + ]), + ], + }); + + await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() }); + + expect(resolveProviderIconName("snapshot-icon-provider", serverId)).toEqual({ + kind: "svg", + svg, + }); + }); + it("sends no cwd for the home scope", async () => { const client = createClient({ snapshots: [providersSnapshot([])] }); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 9dfdae6f37..52fff6e36a 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -21,6 +21,7 @@ import { providersSnapshotQueryRoot, providersSnapshotRequestOptions, } from "@/data/providers-snapshot"; +import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name"; type GetProvidersSnapshotResult = Awaited>; type RefreshProvidersSnapshotResult = Awaited>; @@ -47,8 +48,10 @@ export async function fetchProvidersSnapshot(input: { if (!cached) { throw new ProviderSnapshotCacheMissError(); } + replaceProviderSnapshotIcons(input.serverId, cached.entries); return { ...snapshot, entries: cached.entries }; } + replaceProviderSnapshotIcons(input.serverId, snapshot.entries); if (snapshot.compactSnapshot && snapshot.snapshotHash) { await cache.write({ serverId: input.serverId, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2ce4aaff3d..254271aac3 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -362,7 +362,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = getProviderIcon(provider); + const icon = getProviderIcon(provider, context.serverId); return { label: label ?? "", diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index cf505b63bc..962e095b89 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -128,6 +128,7 @@ export const AgentTracks = memo(function AgentTracks({ ({ }); export interface SubagentsTrackProps { + serverId: string; rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -44,17 +45,18 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; /** Leading and action glyphs share one size so rows keep a single icon column. */ const ROW_ICON_SIZE = 14; -function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { +function buildRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { const data = buildSubagentRowPresentationData(row); return { ...data, tooltip: data.label, modified: false, - icon: getProviderIcon(row.provider), + icon: getProviderIcon(row.provider, serverId), }; } export function SubagentsTrack({ + serverId, rows, onOpenSubagent, onOpenProviderSubagent, @@ -95,6 +97,7 @@ export function SubagentsTrack({ void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -172,6 +176,7 @@ interface SubagentsTrackRowProps { } function SubagentsTrackRow({ + serverId, row, onOpenSubagent, onOpenProviderSubagent, @@ -180,7 +185,7 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useMemo(() => buildRowPresentation(row), [row]); + const presentation = useMemo(() => buildRowPresentation(row, serverId), [row, serverId]); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 734b286ed3..1985d9d7cf 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -120,7 +120,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) { daemon: { listen, relay: { enabled: false }, - mcp: { enabled: true, injectIntoAgents: false }, + mcp: { enabled: true, injectIntoAgents: true }, browserTools: { enabled: true }, cors: { allowedOrigins: ["*"] }, }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 11b88c513d..c71e2c7db6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -18,6 +18,16 @@ "source": "./src/server.ts", "default": "./dist/server.js" }, + "./provider": { + "types": "./dist/provider.d.ts", + "source": "./src/provider.ts", + "default": "./dist/provider.js" + }, + "./acp": { + "types": "./dist/acp.d.ts", + "source": "./src/acp.ts", + "default": "./dist/acp.js" + }, "./react-native": { "types": "./dist/react-native.d.ts", "source": "./src/react-native.ts", @@ -41,6 +51,7 @@ "test": "vitest run" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/plugin/src/acp-internal/connection.ts b/packages/plugin/src/acp-internal/connection.ts new file mode 100644 index 0000000000..eec8ec6830 --- /dev/null +++ b/packages/plugin/src/acp-internal/connection.ts @@ -0,0 +1,1373 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type Client, + type AgentCapabilities, + type ContentBlock, + type NewSessionResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionNotification, + type SessionUpdate, + type ToolCall, + type ToolCallUpdate, + type Stream, +} from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { + AcpConfigAccess, + AcpConfigChange, + AcpToolCallSnapshot, + AcpTransformer, + AcpVendorUpdate, + RunAcpProviderOptions, +} from "../acp.js"; +import { + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + negotiateProviderCapabilities, + requireProviderCapabilities, + type ProviderCatalog, + type ProviderCapability, + type ProviderConfigState, + type ProviderConnectRequest, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, + type ProviderPermissionResponse, + type ProviderPersistence, + type ProviderSessionConfig, + type ProviderSetting, + type ProviderTimelineItem, +} from "../provider.js"; + +const ADAPTER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "session.configure", + "permission", +] as const; + +interface AcpBoundarySession { + runtime: AcpRuntime; + capabilities: readonly ProviderCapability[]; + mutationLane: Promise; +} + +export async function createAcpProviderConnection( + options: RunAcpProviderOptions, + request: ProviderConnectRequest, +): Promise { + if (!request.versions.includes(PROVIDER_PROTOCOL_VERSION)) { + throw new Error(`ACP adapter requires provider protocol ${PROVIDER_PROTOCOL_VERSION}`); + } + const probe = await AcpRuntime.start({ + options, + boundarySessionId: "capability-probe", + env: {}, + emit: () => undefined, + }); + const supportedCapabilities: ProviderCapability[] = [...ADAPTER_CAPABILITIES]; + if (probe.agentCapabilities.promptCapabilities?.image) { + supportedCapabilities.push("prompt.image"); + } + if (probe.agentCapabilities.sessionCapabilities?.list) { + supportedCapabilities.push("session.list"); + } + if (probe.agentCapabilities.loadSession) { + supportedCapabilities.push("session.persistence"); + } + await probe.close(); + const capabilities = negotiateProviderCapabilities(request.capabilities, supportedCapabilities); + const listeners = new Set<(event: ProviderEvent) => void>(); + const sessions = new Map(); + const inFlight = new Set>(); + let closed = false; + let closePromise: Promise | null = null; + const emit = (event: ProviderEvent) => { + if (closed) return; + const parsed = ProviderEventSchema.parse(event); + for (const listener of listeners) listener(parsed); + }; + const state: AcpConnectionState = { + options, + capabilities, + sessions, + emit, + isClosed: () => closed, + }; + + return { + version: PROVIDER_PROTOCOL_VERSION, + capabilities, + async send(input) { + if (closed) throw new Error("ACP provider connection is closed"); + input = ProviderInputSchema.parse(input); + validateAdmission(input, { capabilities, sessions }); + const operation = Promise.resolve().then(async () => { + if (closed) return undefined; + await dispatch(input, state); + return undefined; + }); + const settled = operation.catch((error) => { + emitOperationFailure(input, emit, error); + }); + inFlight.add(settled); + void settled.finally(() => inFlight.delete(settled)); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + if (closePromise) return closePromise; + closed = true; + closePromise = (async () => { + await Promise.all(inFlight); + const active = [...sessions.values()]; + sessions.clear(); + await Promise.all(active.map(({ runtime }) => runtime.closeSession())); + listeners.clear(); + })(); + return closePromise; + }, + }; +} + +interface AcpConnectionState { + options: RunAcpProviderOptions; + capabilities: readonly ProviderCapability[]; + sessions: Map; + emit(event: ProviderEvent): void; + isClosed(): boolean; +} + +function validateAdmission( + input: ProviderInput, + state: Pick, +): void { + if (input.type === "session.open") { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + requireProviderCapabilities(state.capabilities, input); + return; + } + if (!("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + return; + } + const session = requireSession(state as AcpConnectionState, input.sessionId); + requireProviderCapabilities(session.capabilities, input); +} + +function emitOperationFailure( + input: ProviderInput, + emit: (event: ProviderEvent) => void, + error: unknown, +): void { + const failure = { message: describeError(error) }; + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: failure }, + }); + return; + } + if ("requestId" in input) { + emit({ type: "request.failed", requestId: input.requestId, error: failure }); + return; + } + if ("sessionId" in input) { + emit({ type: "session.runtime_failed", sessionId: input.sessionId, error: failure }); + } +} + +async function dispatch(input: ProviderInput, state: AcpConnectionState): Promise { + switch (input.type) { + case "catalog": + await discover(input, state); + return; + case "sessions": + await listSessions(input, state); + return; + case "session.open": + await openSession(input, state); + return; + case "session.prompt": + await requireSession(state, input.sessionId).runtime.prompt(input); + return; + case "session.interrupt": + await requireSession(state, input.sessionId).runtime.interrupt(); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.permission": + requireSession(state, input.sessionId).runtime.respondToPermission( + input.permissionId, + input.response, + ); + return; + case "session.configure": { + const session = requireSession(state, input.sessionId); + await mutateSession(session, () => session.runtime.configure(input.changes)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.close": { + const session = requireSession(state, input.sessionId); + state.sessions.delete(input.sessionId); + await session.runtime.closeSession(); + state.emit({ type: "session.closed", sessionId: input.sessionId }); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.archive": + case "session.unarchive": + case "session.revert": + state.emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: `${input.type} is not supported by this ACP provider` }, + }); + } +} + +async function discover( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: "catalog", + env: {}, + emit: state.emit, + }); + try { + let catalog = await runtime.discover(input.cwd); + const config = runtime.configAccess(); + for (const transformer of state.options.transformers ?? []) { + if (transformer.discover) { + catalog = await transformer.discover(catalog, { sessionId: "catalog", config }); + } + } + state.emit({ type: "catalog", requestId: input.requestId, catalog }); + } finally { + await runtime.closeSession(); + } +} + +async function listSessions( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: "sessions", + env: {}, + emit: state.emit, + }); + try { + const response = await runtime.listSessions(input.cwd); + const selected = input.limit ? response.sessions.slice(0, input.limit) : response.sessions; + state.emit({ + type: "sessions", + requestId: input.requestId, + sessions: selected.map((session) => ({ + persistence: nativePersistence(session.sessionId), + cwd: session.cwd, + title: session.title ?? undefined, + updatedAt: session.updatedAt ?? undefined, + })), + }); + } finally { + await runtime.close(); + } +} + +async function openSession( + input: Extract, + state: AcpConnectionState, +): Promise { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + const runtime = await AcpRuntime.start({ + options: state.options, + boundarySessionId: input.sessionId, + env: input.config.env, + emit: state.emit, + }); + try { + const capabilities = await runtime.open(input, state.capabilities); + if (state.isClosed()) { + await runtime.closeSession(); + return; + } + state.sessions.set(input.sessionId, { + runtime, + capabilities, + mutationLane: Promise.resolve(), + }); + } catch (error) { + await runtime.close(); + throw error; + } +} + +async function mutateSession( + session: AcpBoundarySession, + operation: () => Promise, +): Promise { + const result = session.mutationLane.then(operation); + session.mutationLane = result.catch(() => undefined); + return result; +} + +function requireSession(state: AcpConnectionState, sessionId: string): AcpBoundarySession { + const session = state.sessions.get(sessionId); + if (!session) throw new Error(`Unknown session: ${sessionId}`); + return session; +} + +interface StartRuntimeOptions { + options: RunAcpProviderOptions; + boundarySessionId: string; + env: Readonly>; + emit(event: ProviderEvent): void; +} + +class AcpRuntime { + readonly connection: ClientSideConnection; + agentCapabilities: AgentCapabilities = {}; + nativeSessionId = ""; + private readonly child: ChildProcessWithoutNullStreams | null; + private emit: (event: ProviderEvent) => void; + private readonly messages = new Map(); + private readonly toolCalls = new Map(); + private readonly pendingCompactions = new Set(); + private readonly permissions = new Map< + string, + { request: RequestPermissionRequest; resolve(response: RequestPermissionResponse): void } + >(); + private readonly transformers: readonly AcpTransformer[]; + private configOptions: SessionConfigOption[] = []; + private modes: NewSessionResponse["modes"] = null; + private commandWaiter: (() => void) | null = null; + private messageSequence = 0; + private closing = false; + private processFailed = false; + private configTransaction = false; + private stagedTransformerConfig: ProviderConfigState | null = null; + private readonly closeConnector: () => Promise; + private notificationLane: Promise = Promise.resolve(); + private promptLane: Promise = Promise.resolve(); + private activePrompt: { turnId: string; settled: Promise } | null = null; + + static async start(options: StartRuntimeOptions): Promise { + let child: ChildProcessWithoutNullStreams | null = null; + let spawnFailure: Promise | null = null; + let stream: Stream; + let closeConnector = async () => {}; + if (options.options.command) { + const [executable, ...args] = options.options.command; + child = spawn(executable, args, { + env: { ...process.env, ...options.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); + child.stderr.on("data", () => undefined); + const output = Writable.toWeb(child.stdin) as unknown as Parameters[0]; + const input = Readable.toWeb(child.stdout) as unknown as Parameters[1]; + stream = ndJsonStream(output, input); + } else { + const owned = ownConnectorStream(await options.options.connector()); + stream = owned.stream; + closeConnector = owned.close; + } + const runtime = new AcpRuntime(stream, child, closeConnector, options); + try { + const initialize = withTimeout( + runtime.call( + runtime.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "paseo", version: "1" }, + }), + ), + options.options.acpOptions?.startupTimeoutMs ?? 10_000, + `ACP provider ${options.options.id} did not initialize`, + ); + const initialized = spawnFailure + ? await Promise.race([initialize, spawnFailure]) + : await initialize; + runtime.agentCapabilities = initialized.agentCapabilities ?? {}; + return runtime; + } catch (error) { + await runtime.close(); + throw error; + } + } + + private constructor( + stream: Stream, + child: ChildProcessWithoutNullStreams | null, + closeConnector: () => Promise, + private readonly options: StartRuntimeOptions, + ) { + this.child = child; + this.closeConnector = closeConnector; + this.emit = options.emit; + this.transformers = options.options.transformers ?? []; + const client: Client = { + requestPermission: (request) => this.requestPermission(request), + sessionUpdate: (notification) => + this.enqueueNotification(() => this.sessionUpdate(notification)), + extNotification: (method, params) => + this.enqueueNotification(() => this.vendorNotification(method, params)), + }; + this.connection = new ClientSideConnection(() => client, stream); + if (!child) void this.connection.closed.then(() => this.handleUnexpectedTransportClose()); + child?.on("error", (error) => { + this.processFailed = true; + this.settlePendingWork(); + if (this.closing || !this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(error) }, + }); + }); + child?.once("close", (code, signal) => { + if (this.closing || this.processFailed || !this.nativeSessionId) return; + this.processFailed = true; + this.settlePendingWork(); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: `ACP process exited (${signal ?? code ?? "unknown"})` }, + }); + }); + } + + async open( + input: Extract, + connectionCapabilities: readonly ProviderCapability[], + ): Promise { + const mcpServers = toAcpMcpServers(input.config); + const nativeSessionId = readNativeSessionId(input.persistence); + const metadata = { + _paseo: { + systemPrompt: input.config.systemPrompt, + providerOptions: input.config.providerOptions, + toolPolicy: input.config.toolPolicy, + persist: input.config.persist, + }, + }; + let response: Pick; + if (nativeSessionId) { + response = await this.call( + this.connection.loadSession({ + sessionId: nativeSessionId, + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + this.nativeSessionId = nativeSessionId; + } else { + const newSession = await this.call( + this.connection.newSession({ + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + response = newSession; + this.nativeSessionId = newSession.sessionId; + } + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + await this.applyInitialConfig(input.config); + const sessionCapabilities = connectionCapabilities.filter( + (capability) => + capability !== "session.configure" || + this.modes !== null || + this.configOptions.length > 0 || + this.transformers.some((transformer) => transformer.configure !== undefined), + ); + this.emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: sessionCapabilities, + restoration: "core", + persistence: nativePersistence(this.nativeSessionId), + title: input.config.title, + cwd: input.config.cwd, + }); + this.emitConfig(); + if (this.options.options.acpOptions?.waitForInitialCommands) { + await this.waitForInitialCommands(); + } + this.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return sessionCapabilities; + } + + async discover(cwd = process.cwd()): Promise { + const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + this.nativeSessionId = response.sessionId; + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + return toProviderCatalog(response.modes, response.configOptions ?? []); + } + + prompt(input: Extract): Promise { + const admission = this.promptLane.then(() => this.admitPrompt(input)); + this.promptLane = admission.catch(() => undefined); + return admission; + } + + private async admitPrompt( + input: Extract, + ): Promise { + const active = this.activePrompt; + if (active) { + await this.interrupt(); + await active.settled; + } + const prompt = toAcpPrompt(input.prompt); + const turnId = `acp:${input.prompt.clientMessageId}`; + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "user_message", + id: input.prompt.clientMessageId, + text: prompt + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + clientMessageId: input.prompt.clientMessageId, + }, + }); + this.emit({ + type: "session.prompt_result", + sessionId: this.options.boundarySessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "started", + }); + const settled = this.call( + this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), + ).then( + (response): void => { + const state = response.stopReason === "cancelled" ? "canceled" : "completed"; + this.terminalizeTransientItems(state); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state, + }); + return undefined; + }, + (error): void => { + this.terminalizeTransientItems("failed"); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "failed", + error: { message: describeError(error) }, + }); + return undefined; + }, + ); + this.activePrompt = { turnId, settled }; + void settled.finally(() => { + if (this.activePrompt?.turnId === turnId) this.activePrompt = null; + }); + } + + interrupt(): Promise { + return this.call(this.connection.cancel({ sessionId: this.nativeSessionId })); + } + + listSessions(cwd?: string) { + return this.call(this.connection.listSessions({ cwd })); + } + + async drainNotifications(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await this.notificationLane; + } + + respondToPermission( + permissionId: string, + response: Extract["response"], + ): void { + const pending = this.permissions.get(permissionId); + if (!pending) throw new Error(`Unknown ACP permission: ${permissionId}`); + const selected = selectPermissionOption(pending.request.options, response); + if (response.selectedActionId !== undefined && !selected) { + throw new Error( + `ACP permission action '${response.selectedActionId}' does not exist or does not match '${response.behavior}' behavior`, + ); + } + this.permissions.delete(permissionId); + pending.resolve({ + outcome: selected + ? { outcome: "selected", optionId: selected.optionId } + : { outcome: "cancelled" }, + }); + this.emit({ + type: "session.permission_resolved", + sessionId: this.options.boundarySessionId, + permissionId, + }); + } + + async configure( + changes: Extract["changes"], + ): Promise { + const ordered: AcpConfigChange[] = []; + if (changes.model !== undefined) ordered.push({ target: "model", value: changes.model }); + if (changes.mode !== undefined) ordered.push({ target: "mode", value: changes.mode }); + if (changes.thinkingOption !== undefined) { + ordered.push({ target: "thinking", value: changes.thinkingOption }); + } + for (const [id, value] of Object.entries(changes.settings ?? {})) { + ordered.push({ target: "setting", id, value }); + } + const previousModes = this.modes ? structuredClone(this.modes) : null; + const previousOptions = structuredClone(this.configOptions); + this.configTransaction = true; + this.stagedTransformerConfig = null; + let committedConfig: ProviderConfigState | null = null; + try { + for (const change of ordered) await this.applyConfigChange(change); + committedConfig = + this.stagedTransformerConfig ?? toProviderConfigState(this.modes, this.configOptions); + } catch (error) { + try { + await this.restoreConfig(previousModes, previousOptions); + } catch (rollbackError) { + const failure = new AggregateError( + [error, rollbackError], + `ACP configuration failed and rollback could not restore the previous state`, + ); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(failure) }, + }); + await this.close(); + throw failure; + } + this.modes = previousModes; + this.configOptions = previousOptions; + this.stagedTransformerConfig = null; + throw error; + } finally { + this.configTransaction = false; + } + this.stagedTransformerConfig = null; + this.emitConfigSnapshot(committedConfig); + } + + async closeSession(): Promise { + if (this.nativeSessionId) { + await withTimeout( + this.call(this.connection.closeSession({ sessionId: this.nativeSessionId })), + 1_000, + `ACP session ${this.nativeSessionId} did not close`, + ).catch(() => undefined); + } + await this.close(); + } + + async close(): Promise { + if (this.closing) return; + this.closing = true; + this.settlePendingWork(); + await this.closeConnector(); + const child = this.child; + if (!child || this.processFailed) return; + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise((resolve) => child.once("close", () => resolve())); + child.kill("SIGTERM"); + if (await settlesWithin(closed, 1_000)) return; + child.kill("SIGKILL"); + if (!(await settlesWithin(closed, 1_000))) { + throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); + } + } + + private async applyInitialConfig(config: ProviderSessionConfig): Promise { + if (config.model) await this.applyConfigChange({ target: "model", value: config.model }); + if (config.mode) await this.applyConfigChange({ target: "mode", value: config.mode }); + if (config.thinkingOption) { + await this.applyConfigChange({ target: "thinking", value: config.thinkingOption }); + } + for (const [id, value] of Object.entries(config.settings)) { + await this.applyConfigChange({ target: "setting", id, value }); + } + } + + private async applyConfigChange(change: AcpConfigChange): Promise { + const access = this.configAccess(); + for (const transformer of this.transformers) { + if ( + (await transformer.configure?.(change, { + sessionId: this.options.boundarySessionId, + config: access, + })) === "handled" + ) { + return; + } + } + if (change.target === "mode") { + if (!change.value || !this.modes) { + throw new Error("ACP session does not expose mode configuration"); + } + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: change.value, + }), + ); + this.modes = { ...this.modes, currentModeId: change.value }; + return; + } + let category: "model" | "thought_level" | null = null; + if (change.target === "model") category = "model"; + if (change.target === "thinking") category = "thought_level"; + const configId = + change.target === "setting" + ? change.id + : this.configOptions.find((option) => option.category === category)?.id; + if (!configId) { + throw new Error(`ACP session does not expose ${change.target} configuration`); + } + if (change.value === null) { + throw new Error(`ACP configuration ${configId} cannot be cleared`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId, + ...(typeof change.value === "boolean" + ? { type: "boolean" as const, value: change.value } + : { value: String(change.value) }), + }), + ); + this.configOptions = response.configOptions; + } + + configAccess(): AcpConfigAccess { + return { + read: async () => configValues(this.configOptions), + set: async (id, value) => { + if (typeof value !== "string" && typeof value !== "boolean") { + throw new Error(`ACP configuration ${id} accepts only string or boolean values`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + this.configOptions = response.configOptions; + }, + }; + } + + private emitConfig(): void { + if (this.configTransaction) return; + this.emitConfigSnapshot(toProviderConfigState(this.modes, this.configOptions)); + } + + private emitConfigSnapshot(config: ProviderConfigState): void { + this.emit({ + type: "session.config", + sessionId: this.options.boundarySessionId, + config, + }); + } + + private async restoreConfig( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], + ): Promise { + if (modes?.currentModeId && this.modes?.currentModeId !== modes.currentModeId) { + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: modes.currentModeId, + }), + ); + } + const currentValues = configValues(this.configOptions); + for (const option of options) { + const value = option.currentValue; + if (currentValues[option.id] === value) continue; + await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: option.id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + } + } + + private requestPermission(request: RequestPermissionRequest): Promise { + const permissionId = `permission:${request.toolCall.toolCallId}`; + return new Promise((resolve) => { + this.permissions.set(permissionId, { request, resolve }); + this.emit({ + type: "session.permission", + sessionId: this.options.boundarySessionId, + request: { + id: permissionId, + name: request.toolCall.name ?? request.toolCall.title ?? "Tool", + kind: "tool", + title: request.toolCall.title ?? undefined, + input: jsonRecord(request.toolCall.rawInput), + actions: request.options.map((option) => ({ + id: option.optionId, + label: option.name, + behavior: option.kind.startsWith("allow") ? "allow" : "deny", + })), + }, + }); + }); + } + + private sessionUpdate(notification: SessionNotification): void { + if (notification.sessionId !== this.nativeSessionId) return; + this.reduceUpdate(notification.update); + } + + private reduceUpdate(update: SessionUpdate): void { + if ( + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "user_message_chunk" + ) { + if (update.sessionUpdate === "user_message_chunk") return; + if (update.content.type !== "text") return; + const fallbackId = `${update.sessionUpdate}:${++this.messageSequence}`; + const id = update.messageId ?? fallbackId; + const text = `${this.messages.get(id) ?? ""}${update.content.text}`; + this.messages.set(id, text); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: update.sessionUpdate === "agent_message_chunk" ? "assistant_message" : "reasoning", + id, + text, + ...(update.sessionUpdate === "agent_message_chunk" + ? { messageId: update.messageId ?? undefined } + : {}), + }, + }); + return; + } + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + this.reduceToolCall(update); + return; + } + this.reduceStateUpdate(update); + } + + private reduceStateUpdate(update: SessionUpdate): void { + if (update.sessionUpdate === "plan") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "todo", + id: "acp-plan", + items: update.entries.map((entry, index) => ({ + id: String(index), + text: entry.content, + completed: entry.status === "completed", + status: entry.status, + })), + }, + }); + return; + } + if (update.sessionUpdate === "available_commands_update") { + const commands = update.availableCommands.map((command) => ({ + name: command.name, + description: command.description, + argumentHint: command.input?.hint, + })); + this.emit({ type: "session.commands", sessionId: this.options.boundarySessionId, commands }); + this.commandWaiter?.(); + this.commandWaiter = null; + return; + } + if (update.sessionUpdate === "current_mode_update") { + if (this.modes) this.modes = { ...this.modes, currentModeId: update.currentModeId }; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "config_option_update") { + this.configOptions = update.configOptions; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "usage_update") { + this.emit({ + type: "session.usage", + sessionId: this.options.boundarySessionId, + usage: { + contextWindowUsedTokens: update.used, + contextWindowMaxTokens: update.size, + totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined, + }, + }); + return; + } + if (update.sessionUpdate === "compaction_update") { + if (update.status === "completed") this.pendingCompactions.delete(update.compactionId); + else this.pendingCompactions.add(update.compactionId); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "compaction", + id: update.compactionId, + status: update.status === "completed" ? "completed" : "loading", + }, + }); + } + } + + private terminalizeTransientItems(state: "completed" | "failed" | "canceled"): void { + for (const [id, snapshot] of this.toolCalls) { + if (snapshot.status !== "pending" && snapshot.status !== "in_progress") continue; + const completed: AcpToolCallSnapshot = { + ...snapshot, + status: state === "completed" ? "completed" : "failed", + }; + this.toolCalls.set(id, completed); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(completed), + }); + } + for (const id of this.pendingCompactions) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { type: "compaction", id, status: "completed" }, + }); + } + this.pendingCompactions.clear(); + } + + private reduceToolCall(update: ToolCall | ToolCallUpdate): void { + const current = this.toolCalls.get(update.toolCallId); + let snapshot = mergeToolCallSnapshot(update, current); + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) + snapshot = transformer.toolCall?.(snapshot, context) ?? snapshot; + this.toolCalls.set(snapshot.id, snapshot); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(snapshot), + }); + } + + private vendorNotification(method: string, params: Record): void { + const notification = { method, params: jsonValue(params) }; + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) { + const transformed = transformer.notification?.(notification, context); + if (!transformed) continue; + const updates = Array.isArray(transformed) ? transformed : [transformed]; + for (const update of updates) this.emitVendorUpdate(update); + } + } + + private emitVendorUpdate(update: AcpVendorUpdate): void { + if (update.type === "commands") { + this.emit({ + type: "session.commands", + sessionId: this.options.boundarySessionId, + commands: [...update.commands], + }); + } else if (update.type === "config") { + if (this.configTransaction) this.stagedTransformerConfig = update.config; + else this.emitConfigSnapshot(update.config); + } else if (update.type === "timeline") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: update.item, + }); + } else { + this.emit({ + type: "session.notice", + sessionId: this.options.boundarySessionId, + notice: update.notice, + }); + } + } + + private call(operation: Promise): Promise { + if (this.connection.signal.aborted) { + return Promise.reject(new Error("ACP transport is closed")); + } + return Promise.race([ + operation, + this.connection.closed.then(() => { + throw new Error("ACP transport closed unexpectedly"); + }), + ]); + } + + private enqueueNotification(operation: () => void): Promise { + const result = this.notificationLane.then(operation); + this.notificationLane = result.catch(() => undefined); + return result; + } + + private handleUnexpectedTransportClose(): void { + if (this.closing || this.processFailed) return; + this.processFailed = true; + this.settlePendingWork(); + if (!this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: "ACP transport closed unexpectedly" }, + }); + } + + private settlePendingWork(): void { + for (const permission of this.permissions.values()) { + permission.resolve({ outcome: { outcome: "cancelled" } }); + } + this.permissions.clear(); + this.commandWaiter?.(); + this.commandWaiter = null; + } + + private waitForInitialCommands(): Promise { + const timeoutMs = this.options.options.acpOptions?.initialCommandsTimeoutMs ?? 1_000; + const commands = new Promise((resolve) => { + this.commandWaiter = resolve; + }); + const timeout = new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }); + return Promise.race([commands, timeout]).finally(() => { + this.commandWaiter = null; + }); + } +} + +function selectPermissionOption( + options: RequestPermissionRequest["options"], + response: ProviderPermissionResponse, +): RequestPermissionRequest["options"][number] | null { + if (response.selectedActionId !== undefined) { + return ( + options.find( + (option) => + option.optionId === response.selectedActionId && + permissionOptionBehavior(option) === response.behavior, + ) ?? null + ); + } + return options.find((option) => permissionOptionBehavior(option) === response.behavior) ?? null; +} + +function permissionOptionBehavior( + option: RequestPermissionRequest["options"][number], +): ProviderPermissionResponse["behavior"] { + return option.kind.startsWith("allow") ? "allow" : "deny"; +} + +function ownConnectorStream(source: Stream): { + stream: Stream; + close(): Promise; +} { + const reader = source.readable.getReader(); + const writer = source.writable.getWriter(); + let closed = false; + const stream: Stream = { + readable: new ReadableStream({ + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + } catch (error) { + controller.error(error); + } + }, + cancel: (reason) => reader.cancel(reason), + }), + writable: new WritableStream({ + write: (message) => writer.write(message), + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }), + }; + return { + stream, + async close() { + if (closed) return; + closed = true; + await Promise.allSettled([ + reader.cancel(new Error("Paseo closed the ACP connector")), + writer.close(), + ]); + }, + }; +} + +function mergeToolCallSnapshot( + update: ToolCall | ToolCallUpdate, + current: AcpToolCallSnapshot | undefined, +): AcpToolCallSnapshot { + return { + id: update.toolCallId, + name: firstText(update.name, current?.name), + title: firstText(update.title, current?.title, update.name) ?? "Tool call", + kind: firstText(update.kind, current?.kind), + status: firstDefined(update.status, current?.status, "pending") ?? "pending", + input: jsonValue(firstDefined(update.rawInput, current?.input, null)), + output: jsonValue(firstDefined(update.rawOutput, current?.output, null)), + locations: update.locations?.map((location) => location.path) ?? current?.locations ?? [], + }; +} + +function firstDefined(...values: Array): Value | undefined { + return values.find((value) => value !== undefined); +} + +function firstText(...values: Array): string | undefined { + return values.find((value): value is string => typeof value === "string"); +} + +function toAcpMcpServers(config: ProviderSessionConfig) { + return Object.entries(config.mcpServers).map(([name, server]) => { + if (server.type === "stdio") { + return { + name, + command: server.command, + args: server.args ?? [], + env: Object.entries(server.env ?? {}).map(([key, value]) => ({ name: key, value })), + }; + } + return { + type: server.type, + name, + url: server.url, + headers: Object.entries(server.headers ?? {}).map(([key, value]) => ({ name: key, value })), + }; + }); +} + +function toAcpPrompt( + prompt: Extract["prompt"], +): ContentBlock[] { + if (prompt.input.type === "command") { + const suffix = prompt.input.arguments ? ` ${prompt.input.arguments}` : ""; + return [{ type: "text", text: `/${prompt.input.name}${suffix}` }]; + } + return prompt.input.content.map((content) => { + if (content.type === "text" || content.type === "image") return content; + return { type: "text", text: JSON.stringify(content) }; + }); +} + +function nativePersistence(sessionId: string): ProviderPersistence { + return { version: 1, data: { sessionId } }; +} + +function readNativeSessionId(persistence: ProviderPersistence | undefined): string | null { + if ( + !persistence || + typeof persistence.data !== "object" || + persistence.data === null || + Array.isArray(persistence.data) + ) + return null; + return typeof persistence.data.sessionId === "string" ? persistence.data.sessionId : null; +} + +function toProviderConfigState( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderConfigState { + const model = options.find((option) => option.category === "model"); + const thinking = options.find((option) => option.category === "thought_level"); + return { + model: selectedValue(model), + mode: modes?.currentModeId, + thinkingOption: selectedValue(thinking), + models: selectOptions(model).map((option) => ({ id: option.value, label: option.label })), + modes: + modes?.availableModes.map((mode) => ({ + id: mode.id, + label: mode.name, + description: mode.description ?? undefined, + })) ?? [], + thinkingOptions: selectOptions(thinking).map((option) => ({ + id: option.value, + label: option.label, + })), + settings: options + .filter((option) => option.category !== "model" && option.category !== "thought_level") + .map(toProviderSetting), + }; +} + +function toProviderCatalog( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderCatalog { + const state = toProviderConfigState(modes, options); + return { + models: state.models, + modes: state.modes, + thinkingOptions: state.thinkingOptions, + defaultModel: state.model, + defaultMode: state.mode, + defaultThinkingOption: state.thinkingOption, + }; +} + +function toProviderSetting(option: SessionConfigOption): ProviderSetting { + if (option.type === "boolean") { + return { + type: "toggle", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + }; + } + return { + type: "select", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + options: selectOptions(option), + }; +} + +function selectOptions( + option: SessionConfigOption | undefined, +): Array<{ label: string; value: string }> { + if (!option || option.type === "boolean") return []; + return option.options.flatMap((candidate) => + "group" in candidate + ? candidate.options.map((nested) => ({ label: nested.name, value: nested.value })) + : [{ label: candidate.name, value: candidate.value }], + ); +} + +function selectedValue(option: SessionConfigOption | undefined): string | undefined { + return option && option.type !== "boolean" ? option.currentValue : undefined; +} + +function configValues( + options: readonly SessionConfigOption[], +): Readonly>> { + return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); +} + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { + const input = jsonRecord(snapshot.input); + const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; + const detail = isEdit + ? { + type: "edit" as const, + filePath: + typeof input.filePath === "string" ? input.filePath : (snapshot.locations[0] ?? ""), + oldString: typeof input.oldString === "string" ? input.oldString : undefined, + newString: typeof input.newString === "string" ? input.newString : undefined, + unifiedDiff: typeof input.unifiedDiff === "string" ? input.unifiedDiff : undefined, + } + : { type: "unknown" as const, input: snapshot.input, output: snapshot.output }; + const status = + snapshot.status === "in_progress" || snapshot.status === "pending" + ? "running" + : snapshot.status; + const base = { + type: "tool_call" as const, + id: snapshot.id, + callId: snapshot.id, + name: snapshot.name ?? snapshot.title, + detail, + }; + if (status === "failed") return { ...base, status, error: snapshot.output }; + return { ...base, status, error: null }; +} + +function jsonValue(value: unknown) { + return z.json().parse(value); +} + +function jsonRecord(value: unknown): Record> { + const parsed = jsonValue(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return parsed; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + return undefined; + }, + (error) => { + clearTimeout(timeout); + reject(error); + return undefined; + }, + ); + }); +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +} diff --git a/packages/plugin/src/acp.test.ts b/packages/plugin/src/acp.test.ts new file mode 100644 index 0000000000..f8099c9a9d --- /dev/null +++ b/packages/plugin/src/acp.test.ts @@ -0,0 +1,985 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { AgentSideConnection, PROTOCOL_VERSION, type Agent } from "@agentclientprotocol/sdk"; +import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAcpProvider, type AcpStreamMessage } from "./acp.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function fakeAcp(source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-acp-test-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "agent.cjs"); + await writeFile(executable, source, "utf8"); + return executable; +} + +async function connect( + executable: string, + capabilities: string[], + transformers?: Parameters[0]["transformers"], +): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> { + const registration = runAcpProvider({ + id: "test-acp", + label: "Test ACP", + command: [process.execPath, executable], + transformers, + }); + const connection = await registration.connect({ versions: [1], capabilities }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + return { connection, events }; +} + +function openInput( + settings: Record = {}, +): Extract { + return { + type: "session.open", + requestId: "open-1", + sessionId: "session-1", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings, persist: true }, + history: "skip", + }; +} + +async function waitForEvent( + events: ProviderEvent[], + predicate: (event: ProviderEvent) => boolean, +): Promise { + await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy(); + return events.find(predicate)!; +} + +type AcpRequestMessage = Extract; + +interface ConnectorInstance { + readonly requests: AcpRequestMessage[]; + readsCanceled: boolean; + writesClosed: boolean; + closeFromAgent(): void; + request(method: string, params: unknown): Promise; + respond(request: AcpRequestMessage, result: unknown): void; + notify(method: string, params: unknown): void; +} + +interface ConnectorResponse { + resolve(value: unknown): void; + reject(error: Error): void; +} + +interface ConnectorHarnessOptions { + capabilities?: Record; + configOptions?: unknown[]; + handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean; + handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean; +} + +function countRequests(instance: ConnectorInstance, method: string): number { + return instance.requests.filter((request) => request.method === method).length; +} + +function receiveConnectorMessage( + instance: ConnectorInstance, + message: AcpStreamMessage, + options?: ConnectorHarnessOptions, +): void { + if (!("method" in message)) return; + if ("id" in message) instance.requests.push(message as AcpRequestMessage); + if (options?.handleMessage?.(instance, message)) return; + if (!("id" in message)) return; + const request = message as AcpRequestMessage; + if (options?.handleRequest?.(instance, request)) return; + respondToConnectorRequest(instance, request, options); +} + +function respondToConnectorRequest( + instance: ConnectorInstance, + request: AcpRequestMessage, + options?: ConnectorHarnessOptions, +): void { + if (request.method === "initialize") { + instance.respond(request, { + protocolVersion: request.params + ? (request.params as { protocolVersion: number }).protocolVersion + : PROTOCOL_VERSION, + agentCapabilities: options?.capabilities ?? {}, + }); + } else if (request.method === "session/new" || request.method === "session/load") { + instance.respond(request, { + sessionId: "connector-session", + modes: null, + configOptions: options?.configOptions ?? [], + }); + } else if (request.method === "session/prompt") { + instance.respond(request, { stopReason: "end_turn" }); + } else if (request.method === "session/close") { + instance.respond(request, {}); + } else if (request.method === "session/set_config_option") { + const params = request.params as { configId: string; value: string | boolean }; + instance.respond(request, { + configOptions: (options?.configOptions ?? []).map((option) => + (option as { id?: string }).id === params.configId + ? Object.assign({}, option, { currentValue: params.value }) + : option, + ), + }); + } +} + +function connectorHarness(options?: ConnectorHarnessOptions) { + const instances: ConnectorInstance[] = []; + const connector = () => { + let controller!: ReadableStreamDefaultController; + let requestSequence = 0; + const responses = new Map(); + const instance: ConnectorInstance = { + requests: [], + readsCanceled: false, + writesClosed: false, + closeFromAgent: () => controller.close(), + request: (method, params) => { + const response = new Promise((resolve, reject) => { + requestSequence += 1; + responses.set(requestSequence, { resolve, reject }); + controller.enqueue({ jsonrpc: "2.0", id: requestSequence, method, params }); + }); + void response.catch(() => undefined); + return response; + }, + respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }), + notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }), + }; + instances.push(instance); + const readable = new ReadableStream({ + start(value) { + controller = value; + }, + cancel() { + instance.readsCanceled = true; + }, + }); + const writable = new WritableStream({ + write(message) { + if (!("method" in message)) { + if (typeof message.id !== "number") return; + const response = responses.get(message.id); + if (!response) return; + responses.delete(message.id); + if ("error" in message) { + response.reject( + new Error( + message.error.data === undefined + ? message.error.message + : `${message.error.message}: ${JSON.stringify(message.error.data)}`, + ), + ); + } else response.resolve(message.result); + return; + } + receiveConnectorMessage(instance, message, options); + }, + close() { + instance.writesClosed = true; + }, + abort() { + instance.writesClosed = true; + }, + }); + return { readable, writable }; + }; + return { connector, instances }; +} + +const basicAgent = `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") setTimeout(() => send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }), 150); + else if (message.method === "session/prompt") send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); +});`; + +describe("runAcpProvider", () => { + it("rejects connection cleanly when the ACP executable does not exist", async () => { + const registration = runAcpProvider({ + id: "missing-acp", + label: "Missing ACP", + command: [path.join(tmpdir(), `missing-acp-${process.pid}`)], + acpOptions: { startupTimeoutMs: 250 }, + }); + + await expect( + registration.connect({ versions: [1], capabilities: ["prompt.message"] }), + ).rejects.toThrow(/ENOENT|spawn/u); + }); + + it("accepts an official ACP SDK stream connector instead of a command", async () => { + const connector = () => { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agent: Agent = { + initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }), + newSession: async () => ({ sessionId: "sdk-session", modes: null, configOptions: [] }), + prompt: async () => ({ stopReason: "end_turn" }), + cancel: async () => undefined, + }; + const agentConnection = new AgentSideConnection(() => agent, { + writable: agentToClient.writable, + readable: clientToAgent.readable, + }); + void agentConnection; + return { + writable: clientToAgent.writable, + readable: agentToClient.readable, + }; + }; + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + + await waitForEvent(events, (event) => event.type === "session.ready"); + expect(connection.capabilities).toEqual(["prompt.message"]); + await connection.close(); + }); + + it("maps permission behavior to the first matching ACP option", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const allow = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-allow", + title: "Allow tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-allow", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-allow", + response: { behavior: "allow" }, + }); + await expect(allow).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-always" }, + }); + + const deny = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-deny", + title: "Deny tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-always", name: "Always reject", kind: "reject_always" }, + { optionId: "reject-once", name: "Reject once", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => event.type === "session.permission" && event.request.id === "permission:tool-deny", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-deny", + response: { behavior: "deny" }, + }); + await expect(deny).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "reject-always" }, + }); + await connection.close(); + }); + + it("rejects an explicit ACP permission option with the wrong behavior", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const permission = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-explicit", + title: "Explicit tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-explicit", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "missing" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && event.error.message.includes("'missing'"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "reject-once" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && + event.error.message.includes("'reject-once'") && + event.error.message.includes("does not match 'allow' behavior"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "allow-once" }, + }); + await expect(permission).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-once" }, + }); + await connection.close(); + }); + + it("closes both connector directions for the probe and live session", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "owned-acp", + label: "Owned ACP", + connector: harness.connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + expect(harness.instances).toHaveLength(1); + expect(harness.instances[0]).toMatchObject({ readsCanceled: true, writesClosed: true }); + + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + + expect(harness.instances).toHaveLength(2); + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + }); + + it("publishes runtime failure on unexpected connector EOF", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "eof-acp", + label: "EOF ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + harness.instances[1]!.closeFromAgent(); + + await expect( + waitForEvent(events, (event) => event.type === "session.runtime_failed"), + ).resolves.toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("closed") }, + }); + await connection.close(); + }); + + it("settles an active auto prompt before admitting its auto replacement", async () => { + const order: string[] = []; + let firstPrompt: AcpRequestMessage | null = null; + let activePrompts = 0; + let maximumActivePrompts = 0; + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message)) return false; + const request = "id" in message ? (message as AcpRequestMessage) : null; + if (message.method === "session/prompt" && request) { + const promptNumber = firstPrompt ? "second" : "first"; + order.push(`prompt:${promptNumber}`); + activePrompts += 1; + maximumActivePrompts = Math.max(maximumActivePrompts, activePrompts); + if (!firstPrompt) { + firstPrompt = request; + } else { + activePrompts -= 1; + instance.respond(request, { stopReason: "end_turn" }); + } + return true; + } + if (message.method === "session/cancel") { + order.push("cancel"); + activePrompts -= 1; + instance.respond(firstPrompt!, { stopReason: "cancelled" }); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "overlap-acp", + label: "Overlap ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await expect.poll(() => countRequests(harness.instances[1]!, "session/prompt")).toBe(1); + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "second", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "second" }] }, + }, + }); + + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === "acp:second" && + event.state === "completed", + ); + expect(order).toEqual(["prompt:first", "cancel", "prompt:second"]); + expect(maximumActivePrompts).toBe(1); + const firstTerminal = events.findIndex( + (event) => + event.type === "session.turn" && event.turnId === "acp:first" && event.state === "canceled", + ); + const secondAdmission = events.findIndex( + (event) => event.type === "session.prompt_result" && event.clientMessageId === "second", + ); + expect(firstTerminal).toBeGreaterThan(-1); + expect(firstTerminal).toBeLessThan(secondAdmission); + await connection.close(); + }); + + it("serializes configuration mutations", async () => { + let releaseMutation: (() => void) | null = null; + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "a", + options: [ + { name: "A", value: "a" }, + { name: "B", value: "b" }, + ], + }, + ]; + const harness = connectorHarness({ + capabilities: { loadSession: true }, + configOptions, + handleRequest(instance, request) { + if (request.method !== "session/set_config_option") return false; + const value = (request.params as { value: string }).value; + if (value !== "first") return false; + releaseMutation = () => + instance.respond(request, { + configOptions: configOptions.map((option) => + Object.assign({}, option, { currentValue: value }), + ), + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "lane-acp", + label: "Lane ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.configure"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-first", + sessionId: "session-1", + changes: { model: "first" }, + }); + await connection.send({ + type: "session.configure", + requestId: "configure-second", + sessionId: "session-1", + changes: { model: "second" }, + }); + await expect + .poll(() => countRequests(harness.instances[1]!, "session/set_config_option")) + .toBe(1); + releaseMutation!(); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "configure-second", + ); + expect( + events + .filter( + (event) => event.type === "request.completed" && event.requestId.startsWith("configure-"), + ) + .map((event) => (event as Extract).requestId), + ).toEqual(["configure-first", "configure-second"]); + + await connection.close(); + }); + + it("does not claim exact permission policy, listing, image, or configuration support", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.configure", + "session.list", + "permission.tool_policy", + ]); + + expect(connection.capabilities).toEqual(["prompt.message", "session.configure"]); + await connection.send(openInput()); + const opened = await waitForEvent(events, (event) => event.type === "session.opened"); + expect(opened).toMatchObject({ capabilities: ["prompt.message"] }); + await waitForEvent(events, (event) => event.type === "session.ready"); + await expect( + connection.send({ + type: "session.configure", + requestId: "configure-unsupported", + sessionId: "session-1", + changes: { model: "not-advertised" }, + }), + ).rejects.toThrow("session.configure"); + await connection.close(); + }); + + it("derives optional connection capabilities from ACP initialization", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { + protocolVersion: message.params.protocolVersion, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true }, + sessionCapabilities: { list: {} }, + }, + } }); +});`); + const { connection } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + + expect(connection.capabilities).toEqual([ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + await connection.close(); + }); + + it("acknowledges admission before session creation completes", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + let ready = false; + const admission = connection.send(openInput()).then(() => { + ready = true; + return undefined; + }); + + await admission; + expect(ready).toBe(true); + expect(events).toEqual([]); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("rejects provider inputs after the ACP connection closes", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "closed-acp", + label: "Closed ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + + await connection.close(); + await expect(connection.send({ type: "catalog", requestId: "too-late" })).rejects.toThrow( + "ACP provider connection is closed", + ); + expect(harness.instances).toHaveLength(1); + }); + + it("waits for delayed session creation and closes its connector after connection close", async () => { + let delayedNew: AcpRequestMessage | null = null; + const harness = connectorHarness({ + handleRequest(_instance, request) { + if (request.method !== "session/new") return false; + delayedNew = request; + return true; + }, + }); + const registration = runAcpProvider({ + id: "delayed-open-acp", + label: "Delayed open ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await expect.poll(() => delayedNew).not.toBeNull(); + + let closeSettled = false; + const closing = connection.close().then(() => { + closeSettled = true; + return undefined; + }); + await Promise.resolve(); + expect(closeSettled).toBe(false); + harness.instances[1]!.respond(delayedNew!, { + sessionId: "late-session", + modes: null, + configOptions: [], + }); + await closing; + + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + expect(events).toEqual([]); + }); + + it("refuses unsupported steering before sending an ACP prompt", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "steer-1", + delivery: "steer", + input: { type: "message", content: [{ type: "text", text: "change course" }] }, + }, + }), + ).rejects.toThrow("prompt.steer"); + expect(events.some((event) => event.type === "session.prompt_result")).toBe(false); + await connection.close(); + }); + + it("keeps configuration observable state atomic when a later field fails", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "verbosity", name: "Verbosity", type: "select", currentValue: "quiet", options: [{ name: "Quiet", value: "quiet" }, { name: "Loud", value: "loud" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "verbosity") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "verbosity rejected" } }); + else if (message.method === "session/set_config_option") { model = message.params.value; send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); } +});`); + const { connection, events } = await connect(executable, [ + "prompt.message", + "session.configure", + ]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-1", + sessionId: "session-1", + changes: { model: "b", settings: { verbosity: "loud" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "configure-1", + ); + const configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ config: { model: "a" } }); + await connection.close(); + }); + + it("commits transformer config notifications once and discards them on rollback", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "late", name: "Late", type: "select", currentValue: "ok", options: [{ name: "Ok", value: "ok" }, { name: "Fail", value: "fail" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } }); + else if (message.method === "session/set_config_option") { + model = message.params.value; + send({ jsonrpc: "2.0", method: "vendor/config", params: { model } }); + send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); + } +});`); + const transformer = { + notification(notification: { method: string; params: unknown }) { + if (notification.method !== "vendor/config") return null; + const params = notification.params as { model: string }; + return { + type: "config" as const, + config: { + model: params.model, + models: [{ id: params.model, label: `Transformer ${params.model}` }], + modes: [], + thinkingOptions: [], + settings: [], + }, + }; + }, + }; + const { connection, events } = await connect( + executable, + ["prompt.message", "session.configure"], + [transformer], + ); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + const initialConfigCount = events.filter((event) => event.type === "session.config").length; + + await connection.send({ + type: "session.configure", + requestId: "transformer-success", + sessionId: "session-1", + changes: { model: "b" }, + }); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "transformer-success", + ); + let configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ + config: { model: "b", models: [{ label: "Transformer b" }] }, + }); + + await connection.send({ + type: "session.configure", + requestId: "transformer-failure", + sessionId: "session-1", + changes: { model: "a", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "transformer-failure", + ); + configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ config: { model: "b" } }); + await connection.close(); + }); + + it("gives discovery transformers the discovered session configuration facade", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "catalog-native", modes: null, configOptions: [{ id: "flavor", name: "Flavor", type: "select", currentValue: "mint", options: [{ name: "Mint", value: "mint" }] }] } }); +});`); + let discovered: Readonly> | null = null; + const { connection, events } = await connect( + executable, + [], + [ + { + async discover(catalog, context) { + discovered = await context.config.read(); + return catalog; + }, + }, + ], + ); + + await connection.send({ type: "catalog", requestId: "catalog-1" }); + await waitForEvent(events, (event) => event.type === "catalog"); + expect(discovered).toEqual({ flavor: "mint" }); + await connection.close(); + }); + + it("drains heavy ACP stderr while initializing", async () => { + const executable = await fakeAcp( + `process.stderr.write("x".repeat(2 * 1024 * 1024));\n${basicAgent}`, + ); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("publishes runtime failure when the ACP process dies", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + setTimeout(() => process.exit(17), 20); + } +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const failure = await waitForEvent(events, (event) => event.type === "session.runtime_failed"); + expect(failure).toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("17") }, + }); + await connection.close(); + }); + + it("escalates shutdown when an ACP process ignores SIGTERM", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +process.on("SIGTERM", () => {}); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const startedAt = Date.now(); + await connection.close(); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(Date.now() - startedAt).toBeLessThan(2_500); + }); +}); diff --git a/packages/plugin/src/acp.ts b/packages/plugin/src/acp.ts new file mode 100644 index 0000000000..f737034a44 --- /dev/null +++ b/packages/plugin/src/acp.ts @@ -0,0 +1,122 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { + ProviderCatalog, + ProviderCommand, + ProviderConfigState, + ProviderNotice, + ProviderRegistration, + ProviderTimelineItem, +} from "./provider.js"; +import { createAcpProviderConnection } from "./acp-internal/connection.js"; + +interface RunAcpProviderBaseOptions { + id: string; + label: string; + description?: string; + icon?: string; + acpOptions?: AcpOptions; + transformers?: readonly AcpTransformer[]; +} + +export type RunAcpProviderOptions = RunAcpProviderBaseOptions & + ( + | { command: readonly [string, ...string[]]; connector?: never } + | { connector: AcpConnector; command?: never } + ); + +export type AcpStreamMessage = + | { jsonrpc: "2.0"; id: string | number | null; method: string; params?: unknown } + | { jsonrpc: "2.0"; method: string; params?: unknown } + | { jsonrpc: "2.0"; id: string | number | null; result: unknown } + | { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; + }; + +export interface AcpStream { + writable: WritableStream; + readable: ReadableStream; +} + +export type AcpConnector = () => AcpStream | Promise; + +export interface AcpOptions { + protocol?: "auto" | "v1"; + startupTimeoutMs?: number; + waitForInitialCommands?: boolean; + initialCommandsTimeoutMs?: number; +} + +export interface AcpContext { + sessionId: string; +} + +export interface AcpConfigAccess { + read(): Promise>>; + set(id: string, value: JsonValue): Promise; +} + +export interface AcpDiscoveryContext extends AcpContext { + config: AcpConfigAccess; +} + +export interface AcpConfigureContext extends AcpContext { + config: AcpConfigAccess; +} + +export type AcpConfigChange = + | { target: "model"; value: string | null } + | { target: "mode"; value: string | null } + | { target: "thinking"; value: string | null } + | { target: "setting"; id: string; value: JsonValue }; + +export interface AcpToolCallSnapshot { + id: string; + name?: string; + title: string; + kind?: string; + status: "pending" | "in_progress" | "completed" | "failed"; + input: JsonValue; + output: JsonValue; + locations: readonly string[]; +} + +export type AcpVendorUpdate = + | { type: "commands"; commands: readonly ProviderCommand[] } + | { type: "config"; config: ProviderConfigState } + | { type: "timeline"; item: ProviderTimelineItem } + | { type: "notice"; notice: ProviderNotice }; + +export interface AcpTransformer { + discover?( + catalog: ProviderCatalog, + context: AcpDiscoveryContext, + ): Promise | ProviderCatalog; + configure?(change: AcpConfigChange, context: AcpConfigureContext): Promise<"handled" | "pass">; + notification?( + notification: { method: string; params: JsonValue }, + context: AcpContext, + ): AcpVendorUpdate | readonly AcpVendorUpdate[] | null; + toolCall?(toolCall: AcpToolCallSnapshot, context: AcpContext): AcpToolCallSnapshot; +} + +export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistration { + const hasCommand = options.command !== undefined; + const hasConnector = options.connector !== undefined; + if (hasCommand === hasConnector) { + throw new Error("ACP provider requires exactly one command or SDK connector"); + } + if (options.command && (options.command.length === 0 || options.command[0].trim().length === 0)) { + throw new Error("ACP provider command must contain an executable"); + } + return { + id: options.id, + label: options.label, + description: options.description, + icon: options.icon, + connect(request) { + return createAcpProviderConnection(options, request); + }, + }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index dbe5116161..96152aa590 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -3,6 +3,7 @@ import type { PaseoApi } from "@getpaseo/client"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginRpcContract } from "./rpc.js"; +import type { ProviderRegistration } from "./provider.js"; export interface PluginTheme { readonly colors: { @@ -317,6 +318,7 @@ export interface PluginServerContext { context: PluginHandlerContext, ) => ZodInput | Promise>, ): void; + registerProvider(provider: ProviderRegistration): void; } export type PluginCleanup = () => void | Promise; diff --git a/packages/plugin/src/provider.ts b/packages/plugin/src/provider.ts new file mode 100644 index 0000000000..3051a89bb0 --- /dev/null +++ b/packages/plugin/src/provider.ts @@ -0,0 +1,1376 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; + +export const PROVIDER_PROTOCOL_VERSION = 1 as const; + +export const PROVIDER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "prompt.image", + "prompt.output_schema", + "prompt.steer", + "session.archive", + "session.configure", + "session.list", + "session.persistence", + "session.revert.both", + "session.revert.conversation", + "session.revert.files", + "session.subsession", + "session.unarchive", + "permission", + "permission.tool_policy", + "timeline.plugin", +] as const; + +export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; + +export interface ProviderRegistration { + id: string; + label: string; + description?: string; + /** Plugin-directory-relative path to a self-contained SVG file. */ + icon?: string; + connect(request: ProviderConnectRequest): Promise; +} + +export interface ProviderConnectRequest { + versions: readonly number[]; + capabilities: readonly string[]; +} + +export interface ProviderConnection { + readonly version: number; + readonly capabilities: readonly string[]; + send(input: ProviderInput): Promise; + onEvent(listener: (event: ProviderEvent) => void): () => void; + close(): Promise; +} + +export interface ProviderPersistence { + version: number; + data: JsonValue; +} + +export type ProviderMcpServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + alwaysLoad?: boolean; + } + | { + type: "http" | "sse"; + url: string; + headers?: Record; + alwaysLoad?: boolean; + }; + +export interface ProviderToolPolicy { + preapproved: Array<{ kind: "mcp"; server: string; tool: string }>; +} + +export interface ProviderSessionConfig { + cwd: string; + env: Readonly>; + systemPrompt?: string; + mcpServers: Readonly>; + toolPolicy?: ProviderToolPolicy; + model?: string; + mode?: string; + thinkingOption?: string; + settings: Readonly>; + providerOptions?: Readonly>; + title?: string; + persist: boolean; +} + +export interface ProviderConfigChanges { + model?: string | null; + mode?: string | null; + thinkingOption?: string | null; + settings?: Readonly>; +} + +export interface ProviderPrompt { + clientMessageId: string; + delivery: "auto" | "steer"; + input: + | { type: "message"; content: ProviderContent[] } + | { type: "command"; name: string; arguments: string }; + outputSchema?: JsonValue; + clearPendingPermissions?: boolean; +} + +interface ProviderForgeChangeRequestAttachment { + type: "forge_change_request"; + mimeType: "application/paseo-forge-change-request"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderForgeIssueAttachment { + type: "forge_issue"; + mimeType: "application/paseo-forge-issue"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; +} + +interface ProviderGitHubPrAttachment { + type: "github_pr"; + mimeType: "application/github-pr"; + number: number; + title: string; + url: string; + body?: string | null; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderGitHubIssueAttachment { + type: "github_issue"; + mimeType: "application/github-issue"; + number: number; + title: string; + url: string; + body?: string | null; +} + +interface ProviderTextAttachment { + type: "text"; + mimeType: "text/plain"; + contextKind?: string; + title?: string | null; + text: string; + externalResource?: { + provider: string; + providerLabel: string; + resourceType: string; + id: string; + identifier: string; + title: string; + url: string; + }; +} + +interface ProviderReviewAttachment { + type: "review"; + mimeType: "application/paseo-review"; + cwd: string; + mode: "uncommitted" | "base"; + baseRef?: string | null; + comments: Array<{ + filePath: string; + side: "old" | "new"; + lineNumber: number; + body: string; + context: { + hunkHeader: string; + targetLine: ProviderReviewContextLine; + lines: ProviderReviewContextLine[]; + }; + }>; +} + +interface ProviderReviewContextLine { + oldLineNumber: number | null; + newLineNumber: number | null; + type: "add" | "remove" | "context"; + content: string; +} + +export type ProviderContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + | ProviderForgeChangeRequestAttachment + | ProviderForgeIssueAttachment + | ProviderGitHubPrAttachment + | ProviderGitHubIssueAttachment + | ProviderTextAttachment + | ProviderReviewAttachment + | { + type: "uploaded_file"; + id: string; + fileName: string; + mimeType: string; + size: number; + path: string; + }; + +export type ProviderInput = + | { type: "catalog"; requestId: string; cwd?: string } + | { type: "sessions"; requestId: string; query?: string; cwd?: string; limit?: number } + | { + type: "session.open"; + requestId: string; + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + } + | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } + | { type: "session.interrupt"; requestId: string; sessionId: string } + | { + type: "session.permission"; + sessionId: string; + permissionId: string; + response: ProviderPermissionResponse; + } + | { + type: "session.configure"; + requestId: string; + sessionId: string; + changes: ProviderConfigChanges; + } + | { + type: "session.revert"; + requestId: string; + sessionId: string; + token: JsonValue; + scope: "conversation" | "files" | "both"; + } + | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.close"; requestId: string; sessionId: string }; + +export interface ProviderThinkingOption { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; +} + +export interface ProviderModel { + id: string; + aliases?: string[]; + isSelectable?: boolean; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; + contextWindowMaxTokens?: number; + thinkingOptions?: ProviderThinkingOption[]; + defaultThinkingOptionId?: string; +} + +export interface ProviderMode { + id: string; + label: string; + description?: string; + icon?: string; + colorTier?: string; + isUnattended?: boolean; +} + +export type ProviderSetting = + | { + type: "toggle"; + id: string; + label: string; + description?: string; + value: boolean; + } + | { + type: "select"; + id: string; + label: string; + description?: string; + value: string | null; + options: ReadonlyArray<{ label: string; value: string }>; + }; + +export interface ProviderConfigState { + model?: string; + mode?: string; + thinkingOption?: string; + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions: readonly ProviderThinkingOption[]; + settings: readonly ProviderSetting[]; +} + +export interface ProviderCatalog { + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions?: readonly ProviderThinkingOption[]; + defaultModel?: string; + defaultMode?: string; + defaultThinkingOption?: string; +} + +export interface ProviderSessionSummary { + persistence: ProviderPersistence; + cwd: string; + title?: string; + description?: string; + updatedAt?: string; +} + +export interface ProviderCommand { + name: string; + description: string; + argumentHint?: string; +} + +export interface ProviderError { + message: string; + code?: string; + diagnostic?: string; +} + +export interface ProviderNotice { + id: string; + severity: "info" | "warning" | "error"; + title: string; + description?: string; + dismissed?: boolean; +} + +export interface ProviderPermissionAction { + id: string; + label: string; + behavior: "allow" | "deny"; + variant?: "primary" | "secondary" | "danger"; + intent?: "implement" | "implement_resume" | "dismiss"; +} + +export interface ProviderPermissionRequest { + id: string; + name: string; + kind: "tool" | "plan" | "question" | "mode" | "other"; + title?: string; + description?: string; + input?: Readonly>; + detail?: ProviderToolCallDetail; + suggestions?: Array>; + actions?: ProviderPermissionAction[]; + metadata?: Readonly>; +} + +export type ProviderPermissionResponse = + | { + behavior: "allow"; + selectedActionId?: string; + updatedInput?: Record; + updatedPermissions?: Array>; + } + | { + behavior: "deny"; + selectedActionId?: string; + message?: string; + interrupt?: boolean; + }; + +export interface ProviderUsage { + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + totalCostUsd?: number; + contextWindowMaxTokens?: number; + contextWindowUsedTokens?: number; +} + +interface ProviderTimelineIdentity { + id: string; + revertToken?: JsonValue; +} + +export type ProviderToolCallDetail = + | { type: "shell"; command: string; cwd?: string; output?: string; exitCode?: number | null } + | { type: "read"; filePath: string; content?: string; offset?: number; limit?: number } + | { + type: "edit"; + filePath: string; + oldString?: string; + newString?: string; + unifiedDiff?: string; + } + | { type: "write"; filePath: string; content?: string } + | { + type: "search"; + query: string; + toolName?: "search" | "grep" | "glob" | "web_search"; + content?: string; + filePaths?: string[]; + webResults?: Array<{ title: string; url: string }>; + annotations?: string[]; + numFiles?: number; + numMatches?: number; + durationMs?: number; + durationSeconds?: number; + truncated?: boolean; + mode?: "content" | "files_with_matches" | "count"; + } + | { + type: "fetch"; + url: string; + prompt?: string; + result?: string; + code?: number; + codeText?: string; + bytes?: number; + durationMs?: number; + } + | { + type: "worktree_setup"; + worktreePath: string; + branchName: string; + log: string; + commands: Array<{ + index: number; + command: string; + cwd: string; + log: string; + status: "running" | "completed" | "failed"; + exitCode: number | null; + durationMs?: number; + }>; + truncated?: boolean; + } + | { + type: "sub_agent"; + subAgentType?: string; + description?: string; + childSessionId?: string; + log: string; + actions?: Array<{ index: number; toolName: string; summary?: string }>; + } + | { + type: "plain_text"; + label?: string; + text?: string; + icon?: + | "wrench" + | "square_terminal" + | "eye" + | "pencil" + | "search" + | "bot" + | "sparkles" + | "brain" + | "mic_vocal"; + } + | { type: "plan"; text: string } + | { type: "unknown"; input: JsonValue; output: JsonValue }; + +type ProviderToolCallItem = ProviderTimelineIdentity & { + type: "tool_call"; + callId: string; + name: string; + detail: ProviderToolCallDetail; + metadata?: Readonly>; +} & ( + | { status: "running" | "completed" | "canceled"; error: null } + | { status: "failed"; error: JsonValue } + ); + +export type ProviderTimelineItem = + | (ProviderTimelineIdentity & { + type: "user_message"; + text: string; + messageId?: string; + clientMessageId?: string; + }) + | (ProviderTimelineIdentity & { type: "assistant_message"; text: string; messageId?: string }) + | (ProviderTimelineIdentity & { type: "reasoning"; text: string }) + | ProviderToolCallItem + | (ProviderTimelineIdentity & { + type: "todo"; + items: Array<{ + text: string; + completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; + }>; + }) + | (ProviderTimelineIdentity & { type: "error"; message: string }) + | (ProviderTimelineIdentity & { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + }) + | (ProviderTimelineIdentity & { + type: "compaction"; + status: "loading" | "completed"; + trigger?: "auto" | "manual"; + preTokens?: number; + }) + | (ProviderTimelineIdentity & { + type: "plugin"; + pluginId: string; + kind: string; + version: number; + data: JsonValue; + }); + +export type ProviderEvent = + | { type: "catalog"; requestId: string; catalog: ProviderCatalog } + | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } + | { type: "request.completed"; requestId: string } + | { type: "request.failed"; requestId: string; error: ProviderError } + | { + type: "session.opened"; + requestId?: string; + sessionId: string; + parentSessionId?: string; + capabilities: readonly string[]; + restoration: "core" | "parent"; + persistence?: ProviderPersistence; + title?: string; + description?: string; + cwd: string; + } + | { type: "session.ready"; requestId?: string; sessionId: string } + | { type: "session.closed"; sessionId: string; error?: ProviderError } + | { type: "session.runtime_failed"; sessionId: string; error: ProviderError } + | { type: "session.persistence"; sessionId: string; persistence: ProviderPersistence } + | { + type: "session.prompt_result"; + sessionId: string; + clientMessageId: string; + result: + | { type: "turn"; turnId: string } + | { type: "steer"; turnId: string } + | { type: "completed" } + | { type: "failed"; error: ProviderError }; + } + | { + type: "session.turn"; + sessionId: string; + turnId: string; + state: "started" | "completed" | "failed" | "canceled"; + error?: ProviderError; + } + | { type: "session.usage"; sessionId: string; turnId?: string; usage: ProviderUsage } + | { type: "session.config"; sessionId: string; config: ProviderConfigState } + | { type: "session.commands"; sessionId: string; commands: ProviderCommand[] } + | { type: "session.permission"; sessionId: string; request: ProviderPermissionRequest } + | { type: "session.permission_resolved"; sessionId: string; permissionId: string } + | { type: "session.notice"; sessionId: string; notice: ProviderNotice } + | { type: "timeline.item"; sessionId: string; item: ProviderTimelineItem; timestamp?: string }; + +export function negotiateProviderCapabilities( + offered: readonly string[], + supported: readonly string[], +): readonly ProviderCapability[] { + const known = new Set(PROVIDER_CAPABILITIES); + const supportedCapabilities = new Set(supported.filter((capability) => known.has(capability))); + return offered.filter( + (capability, index): capability is ProviderCapability => + isProviderCapability(capability) && + supportedCapabilities.has(capability) && + offered.indexOf(capability) === index, + ); +} + +export function isProviderCapability(capability: string): capability is ProviderCapability { + return new Set(PROVIDER_CAPABILITIES).has(capability); +} + +function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCapability[] { + const capabilities: ProviderCapability[] = []; + if (prompt.delivery === "steer") capabilities.push("prompt.steer"); + if (prompt.input.type === "command") capabilities.push("prompt.command"); + else { + capabilities.push("prompt.message"); + if (prompt.input.content.some((part) => part.type === "image")) { + capabilities.push("prompt.image"); + } + } + if (prompt.outputSchema !== undefined) capabilities.push("prompt.output_schema"); + return capabilities; +} + +export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { + switch (input.type) { + case "catalog": + case "session.interrupt": + case "session.close": + return []; + case "sessions": + return ["session.list"]; + case "session.open": { + const capabilities: ProviderCapability[] = []; + if (input.persistence) capabilities.push("session.persistence"); + if (input.config.toolPolicy) capabilities.push("permission.tool_policy"); + return capabilities; + } + case "session.prompt": + return requiredPromptCapabilities(input.prompt); + case "session.permission": + return ["permission"]; + case "session.configure": + return ["session.configure"]; + case "session.revert": + return [`session.revert.${input.scope}`]; + case "session.archive": + return ["session.archive"]; + case "session.unarchive": + return ["session.unarchive"]; + } +} + +export function requireProviderCapabilities( + capabilities: readonly string[], + input: ProviderInput, +): void { + for (const capability of requiredProviderCapabilities(input)) { + if (!capabilities.includes(capability)) { + throw new Error(`Provider does not support ${capability}`); + } + } +} + +const idSchema = z.string().min(1); +const jsonObjectSchema = z.record(z.string(), z.json()); +const providerErrorSchema = z + .object({ message: z.string(), code: z.string().optional(), diagnostic: z.string().optional() }) + .strip(); +const persistenceSchema = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strip(); +const mcpServerSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("stdio"), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("http"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("sse"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), +]); +const toolPolicySchema = z + .object({ + preapproved: z.array( + z.object({ kind: z.literal("mcp"), server: idSchema, tool: idSchema }).strict(), + ), + }) + .strict(); +const providerPermissionResponseSchema: z.ZodType = + z.discriminatedUnion("behavior", [ + z + .object({ + behavior: z.literal("allow"), + selectedActionId: z.string().optional(), + updatedInput: jsonObjectSchema.optional(), + updatedPermissions: z.array(jsonObjectSchema).optional(), + }) + .strip(), + z + .object({ + behavior: z.literal("deny"), + selectedActionId: z.string().optional(), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }) + .strip(), + ]); +const sessionConfigSchema = z + .object({ + cwd: z.string(), + env: z.record(z.string(), z.string()), + systemPrompt: z.string().optional(), + mcpServers: z.record(z.string(), mcpServerSchema), + toolPolicy: toolPolicySchema.optional(), + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + settings: jsonObjectSchema, + providerOptions: jsonObjectSchema.optional(), + title: z.string().optional(), + persist: z.boolean(), + }) + .strict(); +const externalResourceSchema = z + .object({ + provider: z.string(), + providerLabel: z.string(), + resourceType: z.string(), + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + }) + .strip(); +const reviewContextLineSchema = z + .object({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), + }) + .strip(); +const providerContentSchema: z.ZodType = z.union([ + z.object({ type: z.literal("text"), text: z.string() }).strict(), + z.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() }).strict(), + z + .object({ + type: z.literal("forge_change_request"), + mimeType: z.literal("application/paseo-forge-change-request"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("forge_issue"), + mimeType: z.literal("application/paseo-forge-issue"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_pr"), + mimeType: z.literal("application/github-pr"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_issue"), + mimeType: z.literal("application/github-issue"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + contextKind: z.string().optional(), + title: z.string().nullable().optional(), + text: z.string(), + externalResource: externalResourceSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z + .object({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z + .object({ + hunkHeader: z.string(), + targetLine: reviewContextLineSchema, + lines: z.array(reviewContextLineSchema), + }) + .strip(), + }) + .strip(), + ), + }) + .strip(), + z + .object({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), + }) + .strip(), +]); +const promptSchema = z + .object({ + clientMessageId: idSchema, + delivery: z.enum(["auto", "steer"]), + input: z.union([ + z.object({ type: z.literal("message"), content: z.array(providerContentSchema) }).strict(), + z.object({ type: z.literal("command"), name: idSchema, arguments: z.string() }).strict(), + ]), + outputSchema: z.json().optional(), + clearPendingPermissions: z.boolean().optional(), + }) + .strict(); +const configChangesSchema = z + .object({ + model: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + thinkingOption: z.string().nullable().optional(), + settings: jsonObjectSchema.optional(), + }) + .strict(); + +export const ProviderInputSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("catalog"), requestId: idSchema, cwd: z.string().optional() }) + .strict(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + query: z.string().optional(), + cwd: z.string().optional(), + limit: z.number().int().positive().optional(), + }) + .strict(), + z + .object({ + type: z.literal("session.open"), + requestId: idSchema, + sessionId: idSchema, + config: sessionConfigSchema, + persistence: persistenceSchema.optional(), + history: z.enum(["replay", "skip"]), + }) + .strict(), + z + .object({ type: z.literal("session.prompt"), sessionId: idSchema, prompt: promptSchema }) + .strict(), + z + .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) + .strict(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + permissionId: idSchema, + response: providerPermissionResponseSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.configure"), + requestId: idSchema, + sessionId: idSchema, + changes: configChangesSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.revert"), + requestId: idSchema, + sessionId: idSchema, + token: z.json(), + scope: z.enum(["conversation", "files", "both"]), + }) + .strict(), + z + .object({ + type: z.literal("session.archive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.unarchive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), +]); + +const modeSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + colorTier: z.string().optional(), + isUnattended: z.boolean().optional(), + }) + .strip(); +const selectOptionSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); +const modelSchema = z + .object({ + id: idSchema, + aliases: z.array(z.string()).optional(), + isSelectable: z.boolean().optional(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + contextWindowMaxTokens: z.number().optional(), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), + }) + .strip(); +const settingSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("toggle"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.boolean(), + }) + .strip(), + z + .object({ + type: z.literal("select"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.string().nullable(), + options: z.array(z.object({ label: z.string(), value: z.string() }).strip()), + }) + .strip(), +]); +const configStateSchema = z + .object({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema), + settings: z.array(settingSchema), + }) + .strip(); +const catalogSchema = z + .object({ + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultModel: z.string().optional(), + defaultMode: z.string().optional(), + defaultThinkingOption: z.string().optional(), + }) + .strip(); +const noticeSchema = z + .object({ + id: idSchema, + severity: z.enum(["info", "warning", "error"]), + title: z.string(), + description: z.string().optional(), + dismissed: z.boolean().optional(), + }) + .strip(); +const commandSchema = z + .object({ name: idSchema, description: z.string(), argumentHint: z.string().optional() }) + .strip(); +const usageSchema = z + .object({ + inputTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalCostUsd: z.number().optional(), + contextWindowMaxTokens: z.number().optional(), + contextWindowUsedTokens: z.number().optional(), + }) + .strip(); +const worktreeCommandSchema = z + .object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: z.string(), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }) + .strip(); +const toolCallDetailSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("shell"), + command: z.string(), + cwd: z.string().optional(), + output: z.string().optional(), + exitCode: z.number().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("read"), + filePath: z.string(), + content: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("edit"), + filePath: z.string(), + oldString: z.string().optional(), + newString: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .strip(), + z + .object({ type: z.literal("write"), filePath: z.string(), content: z.string().optional() }) + .strip(), + z + .object({ + type: z.literal("search"), + query: z.string(), + toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(), + content: z.string().optional(), + filePaths: z.array(z.string()).optional(), + webResults: z.array(z.object({ title: z.string(), url: z.string() }).strip()).optional(), + annotations: z.array(z.string()).optional(), + numFiles: z.number().optional(), + numMatches: z.number().optional(), + durationMs: z.number().optional(), + durationSeconds: z.number().optional(), + truncated: z.boolean().optional(), + mode: z.enum(["content", "files_with_matches", "count"]).optional(), + }) + .strip(), + z + .object({ + type: z.literal("fetch"), + url: z.string(), + prompt: z.string().optional(), + result: z.string().optional(), + code: z.number().optional(), + codeText: z.string().optional(), + bytes: z.number().optional(), + durationMs: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array(worktreeCommandSchema), + truncated: z.boolean().optional(), + }) + .strip(), + z + .object({ + type: z.literal("sub_agent"), + subAgentType: z.string().optional(), + description: z.string().optional(), + childSessionId: z.string().optional(), + log: z.string(), + actions: z + .array( + z + .object({ + index: z.number().int().positive(), + toolName: z.string(), + summary: z.string().optional(), + }) + .strip(), + ) + .optional(), + }) + .strip(), + z + .object({ + type: z.literal("plain_text"), + label: z.string().optional(), + text: z.string().optional(), + icon: z + .enum([ + "wrench", + "square_terminal", + "eye", + "pencil", + "search", + "bot", + "sparkles", + "brain", + "mic_vocal", + ]) + .optional(), + }) + .strip(), + z.object({ type: z.literal("plan"), text: z.string() }).strip(), + z.object({ type: z.literal("unknown"), input: z.json(), output: z.json() }).strip(), +]); +const timelineIdentityShape = { id: idSchema, revertToken: z.json().optional() }; +const toolCallBaseShape = { + ...timelineIdentityShape, + type: z.literal("tool_call"), + callId: idSchema, + name: z.string(), + detail: toolCallDetailSchema, + metadata: jsonObjectSchema.optional(), +}; +const timelineItemSchema: z.ZodType = z.union([ + z + .object({ + ...timelineIdentityShape, + type: z.literal("user_message"), + text: z.string(), + messageId: z.string().optional(), + clientMessageId: z.string().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("assistant_message"), + text: z.string(), + messageId: z.string().optional(), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("reasoning"), text: z.string() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("running"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("completed"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("failed"), error: z.json() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("canceled"), error: z.null() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("todo"), + items: z.array( + z + .object({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("error"), message: z.string() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("notification"), + level: z.enum(["info", "warning", "error"]), + message: z.string(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("plugin"), + pluginId: idSchema, + kind: z.string(), + version: z.number(), + data: z.json(), + }) + .strip(), +]); +const permissionActionSchema = z + .object({ + id: idSchema, + label: z.string(), + behavior: z.enum(["allow", "deny"]), + variant: z.enum(["primary", "secondary", "danger"]).optional(), + intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), + }) + .strip(); +const providerPermissionRequestSchema: z.ZodType = z + .object({ + id: idSchema, + name: z.string(), + kind: z.enum(["tool", "plan", "question", "mode", "other"]), + title: z.string().optional(), + description: z.string().optional(), + input: jsonObjectSchema.optional(), + detail: toolCallDetailSchema.optional(), + suggestions: z.array(jsonObjectSchema).optional(), + actions: z.array(permissionActionSchema).optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); + +export const ProviderEventSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ type: z.literal("catalog"), requestId: idSchema, catalog: catalogSchema }).strip(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + sessions: z.array( + z + .object({ + persistence: persistenceSchema, + cwd: z.string(), + title: z.string().optional(), + description: z.string().optional(), + updatedAt: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), + z + .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) + .strip(), + z + .object({ + type: z.literal("session.opened"), + requestId: idSchema.optional(), + sessionId: idSchema, + parentSessionId: idSchema.optional(), + capabilities: z.array(z.string()), + restoration: z.enum(["core", "parent"]), + persistence: persistenceSchema.optional(), + title: z.string().optional(), + description: z.string().optional(), + cwd: z.string(), + }) + .strip(), + z + .object({ + type: z.literal("session.ready"), + requestId: idSchema.optional(), + sessionId: idSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.closed"), + sessionId: idSchema, + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.runtime_failed"), + sessionId: idSchema, + error: providerErrorSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.persistence"), + sessionId: idSchema, + persistence: persistenceSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.prompt_result"), + sessionId: idSchema, + clientMessageId: idSchema, + result: z.union([ + z.object({ type: z.literal("turn"), turnId: idSchema }).strip(), + z.object({ type: z.literal("steer"), turnId: idSchema }).strip(), + z.object({ type: z.literal("completed") }).strip(), + z.object({ type: z.literal("failed"), error: providerErrorSchema }).strip(), + ]), + }) + .strip(), + z + .object({ + type: z.literal("session.turn"), + sessionId: idSchema, + turnId: idSchema, + state: z.enum(["started", "completed", "failed", "canceled"]), + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.usage"), + sessionId: idSchema, + turnId: idSchema.optional(), + usage: usageSchema, + }) + .strip(), + z + .object({ type: z.literal("session.config"), sessionId: idSchema, config: configStateSchema }) + .strip(), + z + .object({ + type: z.literal("session.commands"), + sessionId: idSchema, + commands: z.array(commandSchema), + }) + .strip(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + request: providerPermissionRequestSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.permission_resolved"), + sessionId: idSchema, + permissionId: idSchema, + }) + .strip(), + z + .object({ type: z.literal("session.notice"), sessionId: idSchema, notice: noticeSchema }) + .strip(), + z + .object({ + type: z.literal("timeline.item"), + sessionId: idSchema, + item: timelineItemSchema, + timestamp: z.string().optional(), + }) + .strip(), +]); diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index b6c46d8b3d..536cb17750 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2023", "DOM"], + "types": ["node"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 57e09f2eca..83932d3506 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -113,6 +113,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d6134c95a5..cabd2eb09e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -377,6 +377,7 @@ export const ProviderSnapshotEntrySchema = z.object({ fetchedAt: z.string().optional(), label: z.string().optional(), description: z.string().optional(), + iconSvg: z.string().optional(), defaultModeId: z.string().nullable().optional(), }); diff --git a/packages/protocol/src/provider-snapshot-codec.test.ts b/packages/protocol/src/provider-snapshot-codec.test.ts index 2921260851..f1f245c9b7 100644 --- a/packages/protocol/src/provider-snapshot-codec.test.ts +++ b/packages/protocol/src/provider-snapshot-codec.test.ts @@ -18,6 +18,7 @@ function providerEntry(): ProviderSnapshotEntry { provider: "pi", status: "ready", enabled: true, + iconSvg: '', models: [ { provider: "pi", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 0a4b565e39..433cd7a090 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -125,6 +125,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts new file mode 100644 index 0000000000..4c8abd2357 --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -0,0 +1,347 @@ +import type { + ProviderConnection, + ProviderEvent, + ProviderInput, + ProviderRegistration, +} from "@getpaseo/plugin/provider"; +import { describe, expect, test } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "./agent-sdk-types.js"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; + +const CAPABILITIES = [ + "prompt.message", + "session.configure", + "session.persistence", + "session.subsession", + "permission", +] as const; + +interface ProviderHarnessOptions { + capabilities?: ProviderConnection["capabilities"]; + completeTurn?: boolean; +} + +function createProviderHarness(options: ProviderHarnessOptions = {}) { + let listener: ((event: ProviderEvent) => void) | null = null; + let closeCount = 0; + const inputs: ProviderInput[] = []; + const emit = (event: ProviderEvent) => listener?.(event); + const capabilities = options.capabilities ?? CAPABILITIES; + + const connection: ProviderConnection = { + version: 1, + capabilities, + async send(input) { + inputs.push(input); + if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultModel: "plugin-model", + defaultMode: "build", + defaultThinkingOption: "deep", + }, + }); + return; + } + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "select", + id: "voice", + label: "Voice", + value: "direct", + options: [{ label: "Direct", value: "direct" }], + }, + ], + }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + title: "Plugin child", + cwd: input.config.cwd, + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Child result" }, + }); + emit({ + type: "session.turn", + sessionId: "child-1", + turnId: "child-turn", + state: "completed", + }); + emit({ type: "session.ready", sessionId: "child-1" }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "started", + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hel" }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hello" }, + }); + emit({ + type: "session.permission", + sessionId: input.sessionId, + request: { id: "permission-1", name: "write", kind: "tool" }, + }); + if (options.completeTurn !== false) { + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "completed", + }); + } + return; + } + if (input.type === "session.permission") { + emit({ + type: "session.permission_resolved", + sessionId: input.sessionId, + permissionId: input.permissionId, + }); + return; + } + if (input.type === "session.configure") { + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: input.changes.model ?? "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [], + }, + }); + emit({ type: "request.completed", requestId: input.requestId }); + return; + } + if (input.type === "session.close") { + emit({ type: "session.closed", sessionId: input.sessionId }); + } + if ("requestId" in input) { + emit({ type: "request.completed", requestId: input.requestId }); + } + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() { + closeCount += 1; + }, + }; + + const registration: ProviderRegistration = { + id: "plugin-direct", + label: "Plugin direct", + async connect() { + return connection; + }, + }; + + return { registration, inputs, closeCount: () => closeCount }; +} + +function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) { + return events.filter((event) => event.type === type); +} + +describe("PluginAgentClientRegistry", () => { + test("gates persistence operations on negotiated provider capabilities", async () => { + const harness = createProviderHarness({ capabilities: ["session.persistence"] }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + + await expect(client.isAvailable()).resolves.toBe(true); + expect(client.capabilities).toMatchObject({ + supportsSessionPersistence: true, + supportsSessionListing: false, + }); + await expect(client.listImportableSessions?.()).resolves.toEqual([]); + + const persistence = { + provider: harness.registration.id, + sessionId: 'plugin:{"version":1,"data":{"token":"root"}}', + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }; + await expect(client.archiveNativeSession?.(persistence)).resolves.toBeUndefined(); + await expect(client.unarchiveNativeSession?.(persistence)).resolves.toBeUndefined(); + expect(harness.inputs).toEqual([]); + await registry.shutdown(); + }); + + test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { + const harness = createProviderHarness({ completeTurn: false }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const run = session.run("hello", { clientMessageId: "active-message" }); + void run.catch(() => undefined); + await expect.poll(() => eventsOfType(events, "turn_started")).toHaveLength(1); + + registry.replace([]); + + await expect + .poll(() => eventsOfType(events, "turn_failed")) + .toEqual([ + expect.objectContaining({ + type: "turn_failed", + provider: harness.registration.id, + turnId: "turn-1", + error: "Provider connection closed", + }), + ]); + await expect(run).rejects.toThrow("Provider connection closed"); + await expect.poll(harness.closeCount).toBe(1); + + registry.replace([]); + expect(eventsOfType(events, "turn_failed")).toHaveLength(1); + }); + + test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]; + expect(client).toBeDefined(); + + await expect(client!.fetchCatalog({ scope: "global", force: false })).resolves.toMatchObject({ + models: [ + { + provider: "plugin-direct", + id: "plugin-model", + isDefault: true, + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultThinkingOptionId: "deep", + }, + ], + modes: [{ id: "build" }], + defaultModeId: "build", + }); + + const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" }); + expect(session.features).toEqual([ + expect.objectContaining({ + type: "select", + id: "voice", + options: [{ id: "direct", label: "Direct", value: "direct" }], + }), + ]); + expect(session.describePersistence()).toMatchObject({ + provider: "plugin-direct", + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline", id: "child-1" }), + }), + ); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "upsert", id: "child-1", status: "completed" }), + }), + ); + + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "client-message" }), + ).resolves.toEqual({ turnId: "turn-1" }); + expect( + events + .filter((event) => event.type === "timeline") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", text: "Hel", messageId: "answer" }, + { type: "assistant_message", text: "lo", messageId: "answer" }, + ]); + expect(session.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "permission-1", provider: "plugin-direct" }), + ]); + + await session.respondToPermission("permission-1", { behavior: "allow" }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-1", + resolution: { behavior: "allow" }, + }), + ); + await session.setModel?.("plugin-model"); + expect(await session.getRuntimeInfo()).toMatchObject({ + model: "plugin-model", + modeId: "build", + }); + + unsubscribe(); + await session.close(); + registry.replace([]); + await expect.poll(harness.closeCount).toBe(1); + expect(harness.inputs.map((input) => input.type)).toContain("session.close"); + }); +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts new file mode 100644 index 0000000000..c2eb5e08de --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -0,0 +1,1674 @@ +import { randomUUID } from "node:crypto"; +import type { Logger } from "pino"; +import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; +import { + PROVIDER_CAPABILITIES, + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + requireProviderCapabilities, + type ProviderCapability, + type ProviderConfigChanges, + type ProviderConfigState, + type ProviderConnection, + type ProviderError, + type ProviderEvent, + type ProviderInput, + type ProviderPersistence, + type ProviderPrompt, + type ProviderRegistration, + type ProviderSessionConfig, + type ProviderContent, + type ProviderTimelineItem, +} from "@getpaseo/plugin/provider"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentCreateSessionOptions, + AgentFeature, + AgentLaunchContext, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentSlashCommand, + AgentStreamEvent, + AgentTimelineItem, + FetchCatalogOptions, + ImportableProviderSession, + ImportedProviderSession, + ImportProviderSessionContext, + ImportProviderSessionInput, + ListImportableSessionsOptions, + ProviderCatalog, + ProviderRefreshContext, + SteerActiveTurnOptions, + SteerResult, +} from "./agent-sdk-types.js"; +import { + isDefaultAgentCreateConfigUnattended, + resolveDefaultAgentCreateConfig, +} from "./create-agent-mode.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { runProviderTurn } from "./providers/provider-runner.js"; + +interface Deferred { + promise: Promise; + resolve(value: Value): void; + reject(error: Error): void; +} + +interface PendingRequest { + kind: ProviderInput["type"]; + sessionId?: string; + resolve(event: ProviderEvent): void; + reject(error: Error): void; +} + +interface PendingOpenDescendantState { + readonly providerSessionIds: Set; + readonly events: ProviderEvent[]; +} + +interface OpenProviderSessionInput { + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +function providerError(error: ProviderError): Error { + return Object.assign(new Error(error.message), { + code: error.code, + diagnostic: error.diagnostic, + }); +} + +class ProviderRuntime { + private connection: ProviderConnection | null = null; + private connecting: Promise | null = null; + private closed = false; + private generation = 0; + private closePromise: Promise | null = null; + private unsubscribe: (() => void) | null = null; + private readonly sessions = new Map(); + private readonly providerSessions = new Map(); + private readonly sessionListeners = new Set< + ( + session: ProviderRuntimeSession, + event: Extract, + ) => void + >(); + private readonly requests = new Map(); + private readonly sessionRequests = new Map(); + private readonly pendingOpenDescendants = new Map(); + private readonly pendingOpenDescendantsByRoot = new Map< + ProviderRuntimeSession, + PendingOpenDescendantState + >(); + + constructor(private readonly registration: ProviderRegistration) {} + + get negotiatedCapabilities(): readonly string[] { + return this.connection?.capabilities ?? []; + } + + async isAvailable(): Promise { + await this.getConnection(); + return true; + } + + async catalog( + cwd?: string, + signal?: AbortSignal, + ): Promise["catalog"]> { + const event = await this.complete({ type: "catalog", requestId: randomUUID(), cwd }, signal); + if (event.type !== "catalog") throw new Error("Provider returned an invalid catalog response"); + return event.catalog; + } + + async listSessions( + input: { + query?: string; + cwd?: string; + limit?: number; + } = {}, + ): Promise["sessions"]> { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.list")) return []; + const event = await this.complete({ type: "sessions", requestId: randomUUID(), ...input }); + if (event.type !== "sessions") + throw new Error("Provider returned an invalid sessions response"); + return event.sessions; + } + + onSessionOpened( + listener: ( + session: ProviderRuntimeSession, + event: Extract, + ) => void, + ): () => void { + this.sessionListeners.add(listener); + return () => this.sessionListeners.delete(listener); + } + + async openSession(input: OpenProviderSessionInput): Promise { + if (this.sessions.has(input.sessionId)) { + throw new Error(`Provider session already exists: ${input.sessionId}`); + } + const connection = await this.getConnection(); + requireProviderCapabilities(connection.capabilities, { + type: "session.open", + requestId: "capability-check", + ...input, + }); + const session = new ProviderRuntimeSession( + this, + connection, + input.sessionId, + input.sessionId, + "core", + ); + this.sessions.set(input.sessionId, session); + this.providerSessions.set(input.sessionId, session); + const requestId = randomUUID(); + const ready = session.beginOpen(requestId); + this.sessionRequests.set(requestId, input.sessionId); + try { + await connection.send({ + type: "session.open", + requestId, + sessionId: input.sessionId, + config: input.config, + persistence: input.persistence, + history: input.history, + }); + await ready; + return session; + } catch (error) { + this.sessionRequests.delete(requestId); + this.discardPendingOpenDescendants(session); + this.sessions.delete(input.sessionId); + this.providerSessions.delete(input.sessionId); + throw error; + } + } + + async archive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.archive")) return; + await this.complete({ type: "session.archive", requestId: randomUUID(), persistence }); + } + + async unarchive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.unarchive")) return; + await this.complete({ type: "session.unarchive", requestId: randomUUID(), persistence }); + } + + close(): Promise { + if (this.closePromise) return this.closePromise; + this.closed = true; + this.generation += 1; + const connection = this.connection; + const connecting = connection ? null : this.connecting; + this.connection = null; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const session of this.sessions.values()) session.connectionClosed(); + this.sessions.clear(); + this.providerSessions.clear(); + this.pendingOpenDescendants.clear(); + this.pendingOpenDescendantsByRoot.clear(); + this.sessionListeners.clear(); + for (const request of this.requests.values()) request.reject(new Error("Provider closed")); + this.requests.clear(); + this.closePromise = Promise.all([ + connection?.close(), + connecting?.then( + () => undefined, + () => undefined, + ), + ]).then(() => undefined); + return this.closePromise; + } + + async complete( + input: ProviderInput & { requestId: string }, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const connection = await this.getConnection(); + signal?.throwIfAborted(); + const pending = deferred(); + this.requests.set(input.requestId, { + kind: input.type, + sessionId: + "sessionId" in input + ? (this.providerSessions.get(input.sessionId)?.id ?? input.sessionId) + : undefined, + resolve: pending.resolve, + reject: pending.reject, + }); + const abort = () => { + if (!this.requests.delete(input.requestId)) return; + pending.reject( + signal?.reason instanceof Error ? signal.reason : new Error("Provider request aborted"), + ); + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + await connection.send(input); + return await pending.promise; + } catch (error) { + this.requests.delete(input.requestId); + throw error; + } finally { + signal?.removeEventListener("abort", abort); + } + } + + removeSession(sessionId: string, providerSessionId: string): void { + this.sessions.delete(sessionId); + this.providerSessions.delete(providerSessionId); + } + + private getConnection(): Promise { + if (this.closed) return Promise.reject(new Error("Provider runtime is closed")); + if (this.connection) return Promise.resolve(this.connection); + if (this.connecting) return this.connecting; + const generation = this.generation; + const connecting = this.establishConnection(generation); + this.connecting = connecting; + connecting.then( + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + ); + return connecting; + } + + private async establishConnection(generation: number): Promise { + const rawConnection = await this.registration.connect({ + versions: [PROVIDER_PROTOCOL_VERSION], + capabilities: PROVIDER_CAPABILITIES, + }); + if (this.closed || generation !== this.generation) { + await rawConnection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + try { + this.validateConnection(rawConnection); + } catch (error) { + await rawConnection.close().catch(() => undefined); + throw error; + } + const connection = normalizeConnection(rawConnection); + const unsubscribe = connection.onEvent((event) => this.accept(event)); + if (this.closed || generation !== this.generation) { + unsubscribe(); + await connection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + this.unsubscribe = unsubscribe; + this.connection = connection; + return connection; + } + + private validateConnection(connection: ProviderConnection): void { + if (connection.version !== PROVIDER_PROTOCOL_VERSION) { + throw new Error(`Provider selected unsupported version ${connection.version}`); + } + } + + private accept(event: ProviderEvent): void { + if (event.type === "request.failed") { + this.failRequest(event); + return; + } + if ( + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" + ) { + this.finishRequest(event); + return; + } + this.finishSessionReadyRequest(event); + if (this.holdPendingOpenDescendant(event)) return; + if (this.acceptProviderChild(event)) return; + if ("sessionId" in event) { + const session = this.providerSessions.get(event.sessionId); + if (!session) return; + event = { + ...event, + sessionId: session.id, + ...(event.type === "session.opened" && event.parentSessionId + ? { parentSessionId: this.providerSessions.get(event.parentSessionId)?.id } + : {}), + } as ProviderEvent; + } + if (event.type === "session.runtime_failed") { + this.failSessionRequests(event.sessionId, providerError(event.error)); + } else if (event.type === "session.closed") { + this.settleClosedSessionRequests( + event.sessionId, + new Error(event.error?.message ?? `Provider session ${event.sessionId} closed`), + event, + ); + } + if ("sessionId" in event) { + const session = this.sessions.get(event.sessionId); + session?.accept(event); + if (event.type === "session.closed" && session) { + this.removeSession(session.id, session.providerId); + } + } + } + + private finishSessionReadyRequest(event: ProviderEvent): void { + if (event.type !== "session.ready" || !event.requestId) return; + this.sessionRequests.delete(event.requestId); + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } + + private acceptProviderChild(event: ProviderEvent): boolean { + if (event.type !== "session.opened" || this.providerSessions.has(event.sessionId)) return false; + const parent = event.parentSessionId + ? this.providerSessions.get(event.parentSessionId) + : undefined; + if (!parent) return true; + const sessionId = randomUUID(); + const session = new ProviderRuntimeSession( + this, + this.connection!, + sessionId, + event.sessionId, + event.restoration, + ); + this.sessions.set(sessionId, session); + this.providerSessions.set(event.sessionId, session); + const mappedEvent = { ...event, sessionId, parentSessionId: parent.id }; + session.accept(mappedEvent); + for (const listener of this.sessionListeners) listener(session, mappedEvent); + return true; + } + + private holdPendingOpenDescendant(event: ProviderEvent): boolean { + if (!("sessionId" in event)) return false; + let state = this.pendingOpenDescendants.get(event.sessionId); + if (event.type === "session.opened" && event.parentSessionId) { + const parent = this.providerSessions.get(event.parentSessionId); + state = + this.pendingOpenDescendants.get(event.parentSessionId) ?? + (parent?.hasPendingOpen() ? this.pendingOpenDescendantStateFor(parent) : undefined); + if (state) { + state.providerSessionIds.add(event.sessionId); + this.pendingOpenDescendants.set(event.sessionId, state); + } + } + if (!state) return false; + state.events.push(event); + return true; + } + + private pendingOpenDescendantStateFor(root: ProviderRuntimeSession): PendingOpenDescendantState { + const existing = this.pendingOpenDescendantsByRoot.get(root); + if (existing) return existing; + const state: PendingOpenDescendantState = { providerSessionIds: new Set(), events: [] }; + this.pendingOpenDescendantsByRoot.set(root, state); + return state; + } + + releasePendingOpenDescendants(root: ProviderRuntimeSession): void { + const state = this.takePendingOpenDescendants(root); + if (!state) return; + for (const event of state.events) this.accept(event); + } + + discardPendingOpenDescendants(root: ProviderRuntimeSession): void { + this.takePendingOpenDescendants(root); + } + + private takePendingOpenDescendants( + root: ProviderRuntimeSession, + ): PendingOpenDescendantState | undefined { + const state = this.pendingOpenDescendantsByRoot.get(root); + if (!state) return undefined; + this.pendingOpenDescendantsByRoot.delete(root); + for (const providerSessionId of state.providerSessionIds) { + if (this.pendingOpenDescendants.get(providerSessionId) === state) { + this.pendingOpenDescendants.delete(providerSessionId); + } + } + return state; + } + + private failSessionRequests(sessionId: string, error: Error): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + request.reject(error); + } + for (const [requestId, pendingSessionId] of this.sessionRequests) { + if (pendingSessionId === sessionId) this.sessionRequests.delete(requestId); + } + } + + private settleClosedSessionRequests( + sessionId: string, + error: Error, + event: Extract, + ): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + if (request.kind === "session.close") request.resolve(event); + else request.reject(error); + } + } + + private failRequest(event: Extract): void { + const request = this.requests.get(event.requestId); + if (request) { + this.requests.delete(event.requestId); + request.reject(providerError(event.error)); + } + if (request?.sessionId) this.sessions.get(request.sessionId)?.requestFailed(event); + const sessionId = this.sessionRequests.get(event.requestId); + if (!sessionId) return; + this.sessionRequests.delete(event.requestId); + this.sessions.get(sessionId)?.requestFailed(event); + } + + private finishRequest( + event: Extract, + ): void { + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } +} + +class ProviderRuntimeSession { + readonly history: ProviderEvent[] = []; + private readonly listeners = new Set<(event: ProviderEvent) => void>(); + private capabilities: readonly string[] = []; + private openRequest: { requestId: string; deferred: Deferred } | null = null; + private readonly prompts = new Map< + string, + Deferred> + >(); + private terminal = false; + config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; + commands: Array<{ name: string; description: string; argumentHint?: string }> = []; + persistence: ProviderPersistence | null = null; + + constructor( + private readonly runtime: ProviderRuntime, + private readonly connection: ProviderConnection, + readonly id: string, + private readonly providerSessionId: string, + readonly restoration: "core" | "parent", + ) {} + + get providerId(): string { + return this.providerSessionId; + } + + get negotiatedCapabilities(): readonly string[] { + return this.capabilities; + } + + onEvent(listener: (event: ProviderEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async prompt( + prompt: ProviderPrompt, + ): Promise["result"]> { + requireProviderCapabilities(this.capabilities, { + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + const pending = deferred>(); + this.prompts.set(prompt.clientMessageId, pending); + try { + await this.connection.send({ + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + return (await pending.promise).result; + } finally { + this.prompts.delete(prompt.clientMessageId); + } + } + + async configure(changes: ProviderConfigChanges): Promise { + const input: Extract = { + type: "session.configure", + requestId: randomUUID(), + sessionId: this.providerSessionId, + changes, + }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async respondToPermission( + permissionId: string, + response: Extract["response"], + ): Promise { + const input: Extract = { + type: "session.permission", + sessionId: this.providerSessionId, + permissionId, + response, + }; + requireProviderCapabilities(this.capabilities, input); + await this.connection.send(input); + } + + async revert(token: ProviderInput & { type: "session.revert" }): Promise { + const input = { ...token, sessionId: this.providerSessionId }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async interrupt(): Promise { + await this.runtime.complete({ + type: "session.interrupt", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } + + async close(): Promise { + if (this.restoration === "parent") { + this.runtime.removeSession(this.id, this.providerSessionId); + return; + } + try { + await this.runtime.complete({ + type: "session.close", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } finally { + this.runtime.removeSession(this.id, this.providerSessionId); + } + } + + beginOpen(requestId: string): Promise { + const pending = deferred(); + this.openRequest = { requestId, deferred: pending }; + return pending.promise; + } + + requestFailed(event: Extract): void { + if (this.openRequest?.requestId === event.requestId) { + this.openRequest.deferred.reject(providerError(event.error)); + this.openRequest = null; + this.runtime.discardPendingOpenDescendants(this); + } + } + + accept(event: ProviderEvent): void { + if (event.type === "session.opened") { + event = { ...event, capabilities: this.normalizeSessionCapabilities(event.capabilities) }; + } + if (event.type === "session.opened") { + this.capabilities = [...event.capabilities]; + this.persistence = this.restoration === "core" ? (event.persistence ?? null) : null; + return; + } + const openRequest = this.openRequest; + if ( + event.type === "session.ready" && + openRequest && + openRequest.requestId === event.requestId + ) { + this.openRequest = null; + this.runtime.releasePendingOpenDescendants(this); + openRequest.deferred.resolve(); + return; + } + if (event.type === "session.prompt_result") { + this.prompts.get(event.clientMessageId)?.resolve(event); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + if (this.terminal) return; + this.terminal = true; + this.publish(event); + this.rejectPending(new Error(event.error?.message ?? `Provider session ${this.id} closed`)); + return; + } + this.publish(event); + } + + hasPendingOpen(): boolean { + return this.openRequest !== null; + } + + connectionClosed(error = new Error("Provider connection closed")): void { + if (!this.terminal) { + this.terminal = true; + this.publish({ + type: "session.runtime_failed", + sessionId: this.id, + error: { message: error.message }, + }); + } + this.rejectPending(error); + } + + private rejectPending(error: Error): void { + const openRequest = this.openRequest; + this.openRequest = null; + openRequest?.deferred.reject(error); + this.runtime.discardPendingOpenDescendants(this); + for (const prompt of this.prompts.values()) prompt.reject(error); + this.prompts.clear(); + } + + private normalizeSessionCapabilities(capabilities: readonly string[]): ProviderCapability[] { + const known = PROVIDER_CAPABILITIES.filter((capability) => capabilities.includes(capability)); + for (const capability of known) { + if (!this.connection.capabilities.includes(capability)) { + throw new Error(`Provider session selected unoffered capability ${capability}`); + } + } + return known; + } + + private publish(event: ProviderEvent): void { + if (event.type === "session.config") this.config = event.config; + if (event.type === "session.commands") this.commands = [...event.commands]; + if (event.type === "session.persistence" && this.restoration === "core") { + this.persistence = event.persistence; + } + this.history.push(event); + for (const listener of this.listeners) listener(event); + } +} + +function normalizeConnection(connection: ProviderConnection): ProviderConnection { + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + connection.capabilities.includes(capability), + ); + return { + version: connection.version, + capabilities, + send: (input) => connection.send(ProviderInputSchema.parse(input)), + onEvent: (listener) => + connection.onEvent((event) => listener(ProviderEventSchema.parse(event))), + close: () => connection.close(), + }; +} + +interface AdaptedPluginProvider { + registration: ProviderRegistration; + client: PluginAgentClient; + definition: ProviderDefinition; +} + +/** Owns the complete plugin-provider adaptation behind the existing core provider boundary. */ +export class PluginAgentClientRegistry { + private readonly providers = new Map(); + + constructor(private readonly logger: Logger) {} + + replace(registrations: readonly ProviderRegistration[]): void { + const incoming = new Map(); + for (const registration of registrations) { + if (incoming.has(registration.id)) { + throw new Error(`Duplicate plugin provider ID: ${registration.id}`); + } + incoming.set(registration.id, registration); + } + + for (const [id, adapted] of this.providers) { + if (incoming.get(id) === adapted.registration) continue; + this.providers.delete(id); + void adapted.client.shutdown().catch((error) => { + this.logger.warn({ err: error, provider: id }, "Failed to stop plugin provider"); + }); + } + + for (const [id, registration] of incoming) { + if (this.providers.has(id)) continue; + const client = new PluginAgentClient(registration); + this.providers.set(id, { + registration, + client, + definition: createPluginProviderDefinition(registration, client), + }); + } + } + + definitions(): Record { + return Object.fromEntries( + [...this.providers].map(([id, provider]) => [id, provider.definition]), + ); + } + + clients(): Record { + return Object.fromEntries([...this.providers].map(([id, provider]) => [id, provider.client])); + } + + has(provider: string): boolean { + return this.providers.has(provider); + } + + async shutdown(): Promise { + const providers = [...this.providers.values()]; + this.providers.clear(); + await Promise.all(providers.map(({ client }) => client.shutdown())); + } +} + +const PluginProviderOptionsSchema: z.ZodType = z.record(z.string(), z.json()); + +function createPluginProviderDefinition( + registration: ProviderRegistration, + client: PluginAgentClient, +): ProviderDefinition { + return { + id: registration.id, + label: registration.label, + description: registration.description ?? `Plugin provider ${registration.label}`, + iconSvg: registration.icon, + defaultModeId: null, + modes: [], + enabled: true, + derivedFromProviderId: null, + optionsSchema: PluginProviderOptionsSchema, + supportsExactMcpPreapproval: true, + validateOptions: (options) => + options === undefined ? undefined : PluginProviderOptionsSchema.parse(options), + applyOptions: (config, options) => ({ ...config, providerOptions: options }), + applyToolPolicy: (config, toolPolicy) => ({ ...config, toolPolicy }), + createClient: () => client, + resolveCreateConfig: resolveDefaultAgentCreateConfig, + isCreateConfigUnattended: isDefaultAgentCreateConfigUnattended, + fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context), + }; +} + +interface PendingChild { + session: ProviderRuntimeSession; + opened: Extract; +} + +class PluginAgentClient implements AgentClient { + readonly provider: string; + private readonly runtime: ProviderRuntime; + private readonly rootsBySession = new Map(); + private readonly pendingChildren: PendingChild[] = []; + + constructor(registration: ProviderRegistration) { + this.provider = registration.id; + this.runtime = new ProviderRuntime(registration); + this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.runtime.negotiatedCapabilities); + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + return await this.openSession({ + config, + launchContext, + history: "skip", + persist: options?.persistSession !== false, + }); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + if (!overrides?.cwd) { + throw new Error(`Plugin provider '${this.provider}' requires cwd to resume a session`); + } + return await this.openSession({ + config: { ...overrides, provider: this.provider, cwd: overrides.cwd }, + launchContext, + persistence: decodePersistence(handle), + history: "replay", + persist: true, + }); + } + + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise { + const catalog = await this.runtime.catalog( + options.scope === "workspace" ? options.cwd : undefined, + context?.signal, + ); + return { + models: catalog.models.map((model) => + mapModel( + this.provider, + model, + catalog.defaultModel, + catalog.thinkingOptions, + catalog.defaultThinkingOption, + ), + ), + modes: catalog.modes.map((mode) => ({ ...mode })), + defaultModeId: catalog.defaultMode, + }; + } + + async isAvailable(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return await this.runtime.isAvailable(); + } + + async listImportableSessions( + options: ListImportableSessionsOptions = {}, + ): Promise { + const sessions = await this.runtime.listSessions({ + query: options.query, + cwd: options.cwd, + limit: options.limit, + }); + return sessions.map((session) => ({ + providerHandleId: encodePersistence(session.persistence), + cwd: session.cwd, + title: session.title ?? null, + firstPromptPreview: null, + lastPromptPreview: session.description ?? null, + lastActivityAt: parseProviderDate(session.updatedAt), + })); + } + + async importSession( + input: ImportProviderSessionInput, + context: ImportProviderSessionContext, + ): Promise { + const session = await this.openSession({ + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + launchContext: context.launchContext, + persistence: decodePersistenceId(input.providerHandleId), + history: "replay", + persist: true, + }); + const persistence = session.describePersistence(); + if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); + return { + session, + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + persistence, + timeline: session.timelineHistory(), + providerSubagentEvents: session.subagentHistory(), + }; + } + + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.archive(decodePersistence(handle)); + } + + async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.unarchive(decodePersistence(handle)); + } + + async shutdown(): Promise { + await this.runtime.close(); + } + + private async openSession(input: { + config: AgentSessionConfig; + launchContext?: AgentLaunchContext; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + persist: boolean; + }): Promise { + const sessionId = randomUUID(); + const bridge = await this.runtime.openSession({ + sessionId, + config: mapSessionConfig(input.config, input.launchContext, input.persist), + persistence: input.persistence, + history: input.history, + }); + const session = new PluginAgentSession(this.provider, bridge, () => { + this.rootsBySession.delete(bridge.id); + }); + this.rootsBySession.set(bridge.id, session); + this.attachPendingChildren(); + return session; + } + + private acceptChild( + session: ProviderRuntimeSession, + opened: Extract, + ): void { + const root = opened.parentSessionId + ? this.rootsBySession.get(opened.parentSessionId) + : undefined; + if (!root) { + this.pendingChildren.push({ session, opened }); + return; + } + root.attachChild(session, opened); + this.rootsBySession.set(session.id, root); + this.attachPendingChildren(); + } + + private attachPendingChildren(): void { + let attached = true; + while (attached) { + attached = false; + for (let index = this.pendingChildren.length - 1; index >= 0; index -= 1) { + const pending = this.pendingChildren[index]; + const root = pending.opened.parentSessionId + ? this.rootsBySession.get(pending.opened.parentSessionId) + : undefined; + if (!root) continue; + this.pendingChildren.splice(index, 1); + root.attachChild(pending.session, pending.opened); + this.rootsBySession.set(pending.session.id, root); + attached = true; + } + } + } +} + +class PluginAgentSession implements AgentSession { + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private readonly pendingPermissions = new Map(); + private readonly permissionResponses = new Map(); + private readonly revertTokens = new Map(); + private readonly timelineSnapshots = new Map(); + private readonly childUnsubscribes = new Map void>(); + private readonly childSnapshots = new Map>(); + private unsubscribe: (() => void) | null = null; + private currentTurnId: string | null = null; + private closed = false; + + constructor( + readonly provider: string, + private readonly bridge: ProviderRuntimeSession, + private readonly onClose: () => void, + ) { + for (const event of bridge.history) this.accept(event, false); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + } + + get id(): string { + return this.describePersistence()?.sessionId ?? this.bridge.id; + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.bridge.negotiatedCapabilities); + } + + get features(): AgentFeature[] { + return this.bridge.config.settings.map((setting) => + setting.type === "toggle" + ? { ...setting } + : { + ...setting, + options: setting.options.map((option) => ({ ...option, id: option.value })), + }, + ); + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + return await runProviderTurn({ + prompt, + runOptions: options, + startTurn: (nextPrompt, nextOptions) => this.startTurn(nextPrompt, nextOptions), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.id, + }); + } + + async startTurn( + prompt: AgentPromptInput, + options: AgentRunOptions = {}, + ): Promise<{ turnId: string }> { + const clientMessageId = options.clientMessageId ?? randomUUID(); + const result = await this.bridge.prompt({ + clientMessageId, + delivery: "auto", + input: mapPromptInput(prompt, this.bridge.commands), + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + }); + if (result.type === "failed") throw providerError(result.error); + if (result.type === "turn" || result.type === "steer") return { turnId: result.turnId }; + + const turnId = `command-${clientMessageId}`; + this.publish({ type: "turn_started", provider: this.provider, turnId }); + this.publish({ type: "turn_completed", provider: this.provider, turnId }); + return { turnId }; + } + + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + const result = await this.bridge.prompt({ + clientMessageId: options.clientMessageId ?? randomUUID(), + delivery: "steer", + input: { type: "message", content: mapPromptContent(prompt) }, + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + clearPendingPermissions: options.clearPendingPermissions, + }); + return result.type === "steer" && result.turnId === options.expectedTurnId + ? { status: "accepted" } + : { status: "unavailable" }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + for (const event of this.history) yield event; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.bridge.config.model ?? null, + modeId: this.bridge.config.mode ?? null, + thinkingOptionId: this.bridge.config.thinkingOption ?? null, + }; + } + + async getAvailableModes(): Promise { + return this.bridge.config.modes.map((mode) => ({ ...mode })); + } + + async getCurrentMode(): Promise { + return this.bridge.config.mode ?? null; + } + + async setMode(modeId: string): Promise { + await this.bridge.configure({ mode: modeId }); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return [...this.pendingPermissions.values()]; + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + this.permissionResponses.set(requestId, response); + try { + await this.bridge.respondToPermission( + requestId, + toJsonValue(response, "permission response") as Extract< + ProviderInput, + { type: "session.permission" } + >["response"], + ); + } catch (error) { + this.permissionResponses.delete(requestId); + throw error; + } + } + + describePersistence(): AgentPersistenceHandle | null { + return this.bridge.persistence + ? persistenceHandle(this.provider, this.bridge.persistence) + : null; + } + + async interrupt(): Promise { + await this.bridge.interrupt(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); + this.childUnsubscribes.clear(); + this.listeners.clear(); + this.onClose(); + await this.bridge.close(); + } + + async listCommands(): Promise { + return this.bridge.commands.map((command) => ({ + ...command, + argumentHint: command.argumentHint ?? "", + kind: "command", + })); + } + + async setModel(modelId: string | null): Promise { + await this.bridge.configure({ model: modelId }); + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + await this.bridge.configure({ thinkingOption: thinkingOptionId }); + } + + async setFeature(featureId: string, value: unknown): Promise { + await this.bridge.configure({ + settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, + }); + } + + async revertConversation(input: { messageId: string }): Promise { + await this.revert(input.messageId, "conversation"); + } + + async revertFiles(input: { messageId: string }): Promise { + await this.revert(input.messageId, "files"); + } + + async revertBoth(input: { messageId: string }): Promise { + await this.revert(input.messageId, "both"); + } + + timelineHistory(): ImportedProviderSession["timeline"] { + return this.history.flatMap((event) => + event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], + ); + } + + subagentHistory(): NonNullable { + return this.history.filter( + (event): event is Extract => + event.type === "provider_subagent", + ); + } + + attachChild( + child: ProviderRuntimeSession, + opened: Extract, + ): void { + const childId = child.providerId; + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + title: opened.title ?? null, + description: opened.description ?? null, + status: "running", + cwd: opened.cwd, + }, + }); + const snapshots = new Map(); + this.childSnapshots.set(childId, snapshots); + for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); + this.childUnsubscribes.set( + child.id, + child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + ); + } + + private accept(event: ProviderEvent, live: boolean): void { + const translated = this.translate(event); + for (const next of translated) { + this.history.push(next); + if (live) this.emit(next); + } + } + + private translate(event: ProviderEvent): AgentStreamEvent[] { + switch (event.type) { + case "session.turn": + return [this.translateTurn(event)]; + case "session.usage": + return [ + { + type: "usage_updated", + provider: this.provider, + usage: { ...event.usage }, + turnId: event.turnId, + }, + ]; + case "timeline.item": + return this.translateTimeline(event); + case "session.permission": + return [this.translatePermission(event)]; + case "session.permission_resolved": + return [this.translatePermissionResolution(event)]; + case "session.config": + return this.translateConfig(event); + case "session.notice": + return this.translateNotice(event); + case "session.runtime_failed": + return [this.translateFailure(event.error)]; + case "session.closed": + return event.error ? [this.translateFailure(event.error)] : []; + default: + return []; + } + } + + private translateTurn(event: Extract): AgentStreamEvent { + if (event.state === "started") { + this.currentTurnId = event.turnId; + return { type: "turn_started", provider: this.provider, turnId: event.turnId }; + } + if (this.currentTurnId === event.turnId) this.currentTurnId = null; + if (event.state === "completed") { + return { type: "turn_completed", provider: this.provider, turnId: event.turnId }; + } + if (event.state === "canceled") { + return { + type: "turn_canceled", + provider: this.provider, + turnId: event.turnId, + reason: event.error?.message ?? "Canceled by provider", + }; + } + return { + type: "turn_failed", + provider: this.provider, + turnId: event.turnId, + error: event.error?.message ?? "Provider turn failed", + code: event.error?.code, + diagnostic: event.error?.diagnostic, + }; + } + + private translateTimeline( + event: Extract, + ): AgentStreamEvent[] { + const item = mapTimelineItem(event.item, this.timelineSnapshots, this.revertTokens); + return item + ? [ + { + type: "timeline", + provider: this.provider, + item, + turnId: this.currentTurnId ?? undefined, + timestamp: event.timestamp, + }, + ] + : []; + } + + private translatePermission( + event: Extract, + ): AgentStreamEvent { + const request: AgentPermissionRequest = { ...event.request, provider: this.provider }; + this.pendingPermissions.set(request.id, request); + return { + type: "permission_requested", + provider: this.provider, + request, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translatePermissionResolution( + event: Extract, + ): AgentStreamEvent { + this.pendingPermissions.delete(event.permissionId); + const resolution = this.permissionResponses.get(event.permissionId) ?? { + behavior: "deny" as const, + message: "Resolved by provider", + }; + this.permissionResponses.delete(event.permissionId); + return { + type: "permission_resolved", + provider: this.provider, + requestId: event.permissionId, + resolution, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translateConfig( + event: Extract, + ): AgentStreamEvent[] { + return [ + { + type: "mode_changed", + provider: this.provider, + currentModeId: event.config.mode ?? null, + availableModes: event.config.modes.map((mode) => ({ ...mode })), + }, + { + type: "model_changed", + provider: this.provider, + runtimeInfo: { + provider: this.provider, + sessionId: this.id, + model: event.config.model ?? null, + modeId: event.config.mode ?? null, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + }, + { + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + ]; + } + + private translateNotice( + event: Extract, + ): AgentStreamEvent[] { + if (event.notice.dismissed) return []; + return [ + { + type: "timeline", + provider: this.provider, + item: { + type: "notification", + level: event.notice.severity, + message: [event.notice.title, event.notice.description].filter(Boolean).join("\n"), + }, + turnId: this.currentTurnId ?? undefined, + }, + ]; + } + + private translateFailure(error: ProviderError): AgentStreamEvent { + return { + type: "turn_failed", + provider: this.provider, + turnId: this.currentTurnId ?? undefined, + error: error.message, + code: error.code, + diagnostic: error.diagnostic, + }; + } + + private acceptChildEvent( + childId: string, + event: ProviderEvent, + snapshots: Map, + ): void { + if (event.type === "timeline.item") { + const item = mapTimelineItem(event.item, snapshots); + if (item) { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "timeline", id: childId, item, timestamp: event.timestamp }, + }); + } + return; + } + if (event.type === "session.turn" && event.state !== "started") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "upsert", id: childId, status: event.state }, + }); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed", + }, + }); + } + } + + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const token = this.revertTokens.get(messageId); + if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); + await this.bridge.revert({ + type: "session.revert", + requestId: randomUUID(), + sessionId: this.bridge.providerId, + token, + scope, + }); + } + + private publish(event: AgentStreamEvent): void { + this.history.push(event); + this.emit(event); + } + + private emit(event: AgentStreamEvent): void { + for (const listener of this.listeners) listener(event); + } +} + +function agentCapabilities(capabilities: readonly string[]): AgentCapabilityFlags { + const supports = (capability: ProviderCapability) => capabilities.includes(capability); + return { + supportsStreaming: true, + supportsSessionPersistence: supports("session.persistence"), + supportsSessionListing: supports("session.list"), + supportsDynamicModes: supports("session.configure"), + supportsMcpServers: true, + supportsNativePaseoTools: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: supports("session.revert.conversation"), + supportsRewindFiles: supports("session.revert.files"), + supportsRewindBoth: supports("session.revert.both"), + }; +} + +function mapModel( + provider: string, + model: Extract["catalog"]["models"][number], + defaultModel: string | undefined, + catalogThinkingOptions: Extract["catalog"]["thinkingOptions"], + catalogDefaultThinkingOption: string | undefined, +): AgentModelDefinition { + const thinkingOptions = model.thinkingOptions ?? catalogThinkingOptions; + return { + ...model, + provider, + isDefault: model.isDefault ?? model.id === defaultModel, + metadata: model.metadata ? { ...model.metadata } : undefined, + thinkingOptions: thinkingOptions?.map((option) => Object.assign({}, option)), + defaultThinkingOptionId: model.defaultThinkingOptionId ?? catalogDefaultThinkingOption, + }; +} + +function mapSessionConfig( + config: AgentSessionConfig, + launchContext: AgentLaunchContext | undefined, + persist: boolean, +): ProviderSessionConfig { + return { + cwd: config.cwd, + env: { ...launchContext?.env }, + systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: { ...config.mcpServers }, + toolPolicy: config.toolPolicy + ? { preapproved: config.toolPolicy.preapproved.map((grant) => ({ ...grant })) } + : undefined, + model: config.model, + mode: config.modeId, + thinkingOption: config.thinkingOptionId, + settings: toJsonObject(config.featureValues ?? {}, "provider settings"), + providerOptions: config.providerOptions + ? toJsonObject(config.providerOptions, "provider options") + : undefined, + title: config.title ?? undefined, + persist, + }; +} + +function combineSystemPrompts( + systemPrompt: string | undefined, + daemonPrompt: string | undefined, +): string | undefined { + const parts = [systemPrompt, daemonPrompt].filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +function mapPromptInput( + prompt: AgentPromptInput, + commands: readonly { name: string }[], +): ProviderPrompt["input"] { + if (typeof prompt === "string" && prompt.startsWith("/")) { + const separator = prompt.indexOf(" "); + const name = prompt.slice(1, separator === -1 ? undefined : separator); + if (commands.some((command) => command.name === name)) { + return { + type: "command", + name, + arguments: separator === -1 ? "" : prompt.slice(separator + 1), + }; + } + } + return { type: "message", content: mapPromptContent(prompt) }; +} + +function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] { + const content = typeof prompt === "string" ? [{ type: "text" as const, text: prompt }] : prompt; + return toJsonValue(content, "prompt content") as ProviderContent[]; +} + +function mapTimelineItem( + item: ProviderTimelineItem, + snapshots: Map, + revertTokens?: Map, +): AgentTimelineItem | null { + const previous = snapshots.get(item.id); + snapshots.set(item.id, item); + const { id, revertToken, ...withoutIdentity } = item; + if (revertToken !== undefined && revertTokens) { + revertTokens.set(id, revertToken); + if (item.type === "user_message" || item.type === "assistant_message") { + revertTokens.set(item.messageId ?? id, revertToken); + } + } + + if (item.type === "assistant_message" || item.type === "reasoning") { + const previousText = previous?.type === item.type ? previous.text : ""; + const text = item.text.startsWith(previousText) + ? item.text.slice(previousText.length) + : item.text; + if (text.length === 0) return null; + return item.type === "assistant_message" + ? { type: "assistant_message", text, messageId: item.messageId ?? id } + : { type: "reasoning", text }; + } + if (item.type === "user_message") { + if (previous?.type === "user_message" && previous.text === item.text) return null; + return { + type: "user_message", + text: item.text, + messageId: item.messageId ?? id, + clientMessageId: item.clientMessageId, + }; + } + if (item.type === "plugin") { + return { + type: "plugin", + id, + pluginId: item.pluginId, + kind: item.kind, + version: item.version, + data: item.data, + }; + } + return withoutIdentity as AgentTimelineItem; +} + +function persistenceHandle( + provider: string, + persistence: ProviderPersistence, +): AgentPersistenceHandle { + return { + provider, + sessionId: encodePersistence(persistence), + metadata: { pluginProviderPersistence: persistence }, + }; +} + +function encodePersistence(persistence: ProviderPersistence): string { + return `plugin:${JSON.stringify(persistence)}`; +} + +function decodePersistence(handle: AgentPersistenceHandle): ProviderPersistence { + const fromMetadata = handle.metadata?.pluginProviderPersistence; + if (fromMetadata !== undefined) { + return ProviderPersistenceSchema.parse(fromMetadata); + } + return decodePersistenceId(handle.sessionId); +} + +const ProviderPersistenceSchema: z.ZodType = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strict(); + +function decodePersistenceId(id: string): ProviderPersistence { + if (!id.startsWith("plugin:")) throw new Error("Invalid plugin provider persistence handle"); + return ProviderPersistenceSchema.parse(JSON.parse(id.slice("plugin:".length))); +} + +function parseProviderDate(value: string | undefined): Date { + const timestamp = value ? Date.parse(value) : Number.NaN; + return new Date(Number.isFinite(timestamp) ? timestamp : 0); +} + +function toJsonObject(value: unknown, label: string): Record { + const encoded = toJsonValue(value, label); + if (typeof encoded !== "object" || encoded === null || Array.isArray(encoded)) { + throw new Error(`${label} must be a JSON object`); + } + return encoded as Record; +} + +function toJsonValue(value: unknown, label: string): JsonValue { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`); + return JSON.parse(encoded) as JsonValue; +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index c65db34b2d..5c9231f095 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -70,6 +70,7 @@ export type { AgentProviderDefinition }; export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { + iconSvg?: string; enabled: boolean; /** * The id of another *registered* provider this one extends (e.g. a Z.AI diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 620d1c4960..21f8f51626 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { @@ -105,6 +106,29 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + test("carries a plugin provider icon in snapshot metadata", () => { + const iconSvg = ''; + const registration: ProviderRegistration = { + id: "icon-provider", + label: "Icon Provider", + icon: iconSvg, + async connect() { + throw new Error("not opened by this test"); + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + + try { + manager.replacePluginProviders([registration]); + + expect(manager.getSnapshot("/tmp/project")).toContainEqual( + expect.objectContaining({ provider: "icon-provider", iconSvg }), + ); + } finally { + manager.destroy(); + } + }); + test("validates complete Hub agent configurations through the current provider contract", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), @@ -1708,6 +1732,55 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); + test("registers and unregisters plugin providers without rebuilding built-in clients", async () => { + let listener: ((event: ProviderEvent) => void) | null = null; + const registration: ProviderRegistration = { + id: "plugin-provider", + label: "Plugin provider", + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: "plugin-model", label: "Plugin model" }], modes: [] }, + }); + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + const state = manager.replacePluginProviders([registration]); + expect(state.clients[registration.id]?.provider).toBe(registration.id); + await expect( + manager.getProvider({ provider: registration.id, wait: true }), + ).resolves.toMatchObject({ + provider: registration.id, + source: "custom", + status: "ready", + models: [{ provider: registration.id, id: "plugin-model" }], + }); + + const withoutPlugin = manager.replacePluginProviders([]); + expect(manager.hasProvider(registration.id)).toBe(false); + expect(withoutPlugin.clients[registration.id]).toBeUndefined(); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => { const resolved = resolveSnapshotCwd("C:"); if (process.platform === "win32") { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index de7a69be39..5b60f68cca 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -45,6 +45,8 @@ import { type AgentConfigurationValidationInput, validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; @@ -219,9 +221,13 @@ export class ProviderSnapshotManager { private providerRegistry: Record; private providerClients: Record; private readonly ownedClients = new Set(); + private readonly pluginProviders: PluginAgentClientRegistry; constructor(options: ProviderSnapshotManagerOptions) { this.logger = options.logger; + this.pluginProviders = new PluginAgentClientRegistry( + options.logger.child({ module: "plugin-providers" }), + ); this.workspaceGitService = options.workspaceGitService; this.managedProcesses = options.managedProcesses; this.openCodeBridge = options.openCodeBridge; @@ -236,7 +242,10 @@ export class ProviderSnapshotManager { this.refreshTimeoutMs, ); this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } @@ -322,6 +331,42 @@ export class ProviderSnapshotManager { return { providerDefinitions, clients }; } + replacePluginProviders( + registrations: readonly ProviderRegistration[], + ): AgentManagerProviderState { + for (const registration of registrations) { + if ( + (this.providerRegistry[registration.id] || this.extraClients[registration.id]) && + !this.pluginProviders.has(registration.id) + ) { + throw new Error( + `Plugin provider '${registration.id}' conflicts with a configured provider`, + ); + } + } + this.pluginProviders.replace(registrations); + this.providerRegistry = this.buildRegistry(); + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; + for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); + + for (const cwd of this.snapshots.keys()) { + this.providerLoads.delete(cwd); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.emitChange(cwd); + const target = + cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY + ? createGlobalSnapshotTarget() + : createWorkspaceSnapshotTarget(cwd); + const providers = this.resolveProvidersToWarm(cwd); + if (providers.length > 0) void this.warmUp(target, providers); + } + + return this.getAgentManagerProviderState(); + } + private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { const existing = this.providerClients[provider]; if (existing) { @@ -503,7 +548,10 @@ export class ProviderSnapshotManager { // startup-derived runtime settings here would retain removed command/env fields. if (options.replace) this.runtimeSettings = undefined; this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + this.providerClients = { + ...this.extraClients, + ...this.pluginProviders.clients(), + } as Record; for (const cwd of this.snapshots.keys()) { this.providerLoads.delete(cwd); @@ -597,6 +645,13 @@ export class ProviderSnapshotManager { isDev: this.isDev, }); + for (const [provider, definition] of Object.entries(this.pluginProviders.definitions())) { + if (registry[provider]) { + throw new Error(`Plugin provider '${provider}' conflicts with a configured provider`); + } + registry[provider] = definition; + } + for (const [provider, client] of Object.entries(this.extraClients) as Array< [AgentProvider, AgentClient] >) { @@ -680,6 +735,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition.label, description: definition.description, + iconSvg: definition.iconSvg, defaultModeId: definition.defaultModeId, error: toErrorMessage(error), }; @@ -712,6 +768,7 @@ export class ProviderSnapshotManager { } private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] { + if (this.pluginProviders.has(provider)) return "custom"; const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider); return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin"; } @@ -727,6 +784,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition?.label, description: definition?.description, + iconSvg: definition?.iconSvg, defaultModeId: definition?.defaultModeId ?? null, }); } @@ -746,6 +804,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition?.label, description: definition?.description, + iconSvg: definition?.iconSvg, defaultModeId: definition?.defaultModeId ?? null, }; @@ -914,6 +973,7 @@ export class ProviderSnapshotManager { source: this.getProviderSource(provider), label: definition.label, description: definition.description, + iconSvg: definition.iconSvg, defaultModeId: definition.defaultModeId, }; const setEntry = (entry: ProviderSnapshotEntry) => { diff --git a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts index 93ea5e526c..1f48d7edfe 100644 --- a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts +++ b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts @@ -90,7 +90,7 @@ if (${JSON.stringify(launchMode)} === "piRuntime") { }); process.stdin.on("end", () => process.exit(0)); } else { - console.log("ARGV_OK"); + process.stdout.write("ARGV_OK\\n", () => process.exit(0)); } `, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 18def1cbc3..1cfea1c99d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -922,6 +922,13 @@ export async function createPaseoDaemon( resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers), logger, }); + const syncPluginProviders = () => { + agentManager.updateProviderRegistry( + providerSnapshotManager.replacePluginProviders(pluginRuntime.getProviderRegistrations()), + ); + }; + const unsubscribePluginProviders = + pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -1745,6 +1752,7 @@ export async function createPaseoDaemon( speechService.start(); scriptHealthMonitor.start(); } catch (error) { + unsubscribePluginProviders(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1758,6 +1766,7 @@ export async function createPaseoDaemon( const stop = async () => { await pluginRuntime.stopAllPlugins(); + unsubscribePluginProviders(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index e90663fb72..5851a26cfa 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -155,6 +155,59 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { } describe("PluginService", () => { + it("resolves a provider icon path to sanitized inline SVG", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-icon", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + icon: "icon.svg", + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const iconSvg = ''; + await writeFile(path.join(directory, "icon.svg"), iconSvg); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", icon: iconSvg }, + ]); + }); + + it("publishes provider registrations only while their plugin is running", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-lifecycle", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", label: "Plugin agent" }, + ]); + + await service.disablePlugin("provider-lifecycle"); + expect(service.getProviderRegistrations()).toEqual([]); + }); + it("retains logs when disabled and clears them only when removed", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index df987c903b..e12b8d7386 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { stat } from "node:fs/promises"; import type pino from "pino"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; import { PluginIdSchema, type PluginLogEntry, @@ -10,17 +11,24 @@ import { type PluginSourceUpdateItem, } from "@getpaseo/protocol/messages"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; +import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; +import { readPluginProviderIcon } from "./provider-icon.js"; + +const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { catalog(): Array<{ id: string; clientBundle: string }>; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; + getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; + connectProvider: PluginRuntime["connectProvider"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; stopPluginById(pluginId: string): Promise; @@ -49,6 +57,9 @@ export class PluginService { private readonly logger: pino.Logger; private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); + private readonly providers = new Map(); + private readonly providerIdsByPlugin = new Map(); + private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; private started = false; @@ -63,6 +74,7 @@ export class PluginService { this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion); this.managedSources = dependencies.managedSources ?? null; this.runtime.subscribe((pluginId, error) => { + this.removeProviderRegistrations(pluginId); if (error) this.errors.set(pluginId, error); this.notify(pluginId); }); @@ -77,6 +89,15 @@ export class PluginService { this.runtime.bindPaseoSessionHost(sessionHost); } + getProviderRegistrations(): readonly ProviderRegistration[] { + return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); + } + + subscribeProviderRegistrations(listener: () => void): () => void { + this.providerListeners.add(listener); + return () => this.providerListeners.delete(listener); + } + async start(): Promise { if (this.started) return; this.started = true; @@ -252,7 +273,7 @@ export class PluginService { throw new Error("Plugins are globally disabled"); } this.errors.delete(pluginId); - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await this.startExplicit(pluginId, source.path); this.notify(pluginId); return this.requireItem(pluginId); @@ -276,7 +297,7 @@ export class PluginService { async disablePlugin(pluginId: string): Promise { const source = this.requireSource(pluginId); this.patchSource(pluginId, { ...source, enabled: false }); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; this.errors.delete(pluginId); @@ -287,7 +308,7 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); const sources = { ...this.configStore.get().plugins }; delete sources[pluginId]; this.configStore.patch({ plugins: sources }); @@ -306,17 +327,17 @@ export class PluginService { async stopAllPlugins(): Promise { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopAll(); await this.enqueue(async () => { await stopping; - await this.runtime.stopAll(); + await this.stopAll(); }); } private handleGlobalSwitch(enabled: boolean): void { if (!enabled) { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopAll(); for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); void this.enqueue(async () => { await stopping; @@ -337,7 +358,7 @@ export class PluginService { if (!source || source.enabled === false || !this.canPublish(pluginId)) return; this.errors.delete(pluginId); try { - await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, source.path); } catch (error) { if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); } @@ -345,7 +366,7 @@ export class PluginService { private async startExplicit(pluginId: string, sourcePath: string): Promise { try { - await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, sourcePath); } catch (error) { if (this.canPublish(pluginId)) { this.recordFailure(pluginId, error); @@ -365,6 +386,88 @@ export class PluginService { ); } + private async startPlugin(pluginId: string, sourcePath: string): Promise { + await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + try { + await this.publishProviderRegistrations(pluginId, sourcePath); + } catch (error) { + try { + this.removeProviderRegistrations(pluginId); + } finally { + await this.runtime.stopPluginById(pluginId); + } + throw error; + } + } + + private stopPlugin(pluginId: string): Promise { + this.removeProviderRegistrations(pluginId); + return this.runtime.stopPluginById(pluginId); + } + + private async stopAll(): Promise { + for (const pluginId of this.providerIdsByPlugin.keys()) { + this.removeProviderRegistrations(pluginId); + } + await this.runtime.stopAll(); + } + + private async publishProviderRegistrations( + pluginId: string, + pluginDirectory: string, + ): Promise { + const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? []; + const configuredIds = new Set(Object.keys(this.configStore.get().providers)); + for (const provider of metadata) { + if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`); + } + if (configuredIds.has(provider.id)) { + throw new Error( + `Plugin ${pluginId} cannot register configured provider ID "${provider.id}"`, + ); + } + if (this.providers.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`); + } + } + const registrations = await Promise.all( + metadata.map( + async (provider): Promise => ({ + id: provider.id, + label: provider.label, + description: provider.description, + icon: provider.iconPath + ? await readPluginProviderIcon(pluginDirectory, provider.iconPath) + : undefined, + connect: (request) => this.runtime.connectProvider(pluginId, provider.id, request), + }), + ), + ); + const ids = registrations.map((provider) => provider.id); + for (const provider of registrations) { + this.providers.set(provider.id, { + ...provider, + }); + } + if (ids.length > 0) { + this.providerIdsByPlugin.set(pluginId, ids); + this.notifyProviderRegistrations(); + } + } + + private removeProviderRegistrations(pluginId: string): void { + const ids = this.providerIdsByPlugin.get(pluginId); + if (!ids) return; + this.providerIdsByPlugin.delete(pluginId); + for (const id of ids) this.providers.delete(id); + this.notifyProviderRegistrations(); + } + + private notifyProviderRegistrations(): void { + for (const listener of this.providerListeners) listener(); + } + private enqueue(operation: () => Promise): Promise { const result = this.lifecycle.then(operation, operation); this.lifecycle = result.then( @@ -413,7 +516,7 @@ export class PluginService { const isGloballyEnabled = this.configStore.get().pluginsEnabled === true; const shouldActivate = isEnabled && isGloballyEnabled; if (shouldActivate) { - if (isRunning) await this.runtime.stopPluginById(pluginId); + if (isRunning) await this.stopPlugin(pluginId); try { await this.startExplicit(pluginId, candidate.directory); } catch (error) { @@ -432,7 +535,7 @@ export class PluginService { const activatedSource = this.configStore.get().plugins?.[pluginId]; if (!activatedSource) { - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index c9d61336c0..463eec8631 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,14 +1,185 @@ +import type { + ProviderConnectRequest, + ProviderEvent, + ProviderInput, +} from "@getpaseo/plugin/provider"; +import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider"; +import { z } from "zod"; + +export interface PluginProviderMetadata { + id: string; + label: string; + description?: string; + iconPath?: string; +} + export type PluginProcessRequest = | { type: "initialize"; pluginId: string; bundle: string; appVersion: string } | { type: "invoke"; requestId: string; method: string; input: unknown } + | { + type: "provider.connect"; + providerId: string; + connectionId: string; + request: ProviderConnectRequest; + } + | { + type: "provider.send"; + connectionId: string; + acceptanceId: string; + input: ProviderInput; + } + | { type: "provider.close"; connectionId: string } | { type: "shutdown" } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; export type PluginProcessMessage = - | { type: "ready"; methods: string[] } + | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } | { type: "fatal"; error: string } + | { + type: "provider.connected"; + connectionId: string; + version: number; + capabilities: readonly string[]; + } + | { type: "provider.connect_failed"; connectionId: string; error: string } + | { type: "provider.accepted"; connectionId: string; acceptanceId: string } + | { + type: "provider.rejected"; + connectionId: string; + acceptanceId: string; + error: string; + } + | { type: "provider.event"; connectionId: string; event: ProviderEvent } + | { type: "provider.closed"; connectionId: string; error?: string } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; + +const providerMetadataSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + iconPath: z.string().optional(), + }) + .strict(); +const providerConnectRequestSchema = z + .object({ + versions: z.array(z.number().int().positive()), + capabilities: z.array(z.string()), + }) + .strict(); +const frameFields = { + data: z.union([z.string(), z.instanceof(Uint8Array)]), + isBinary: z.boolean(), +}; + +export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z + .object({ + type: z.literal("initialize"), + pluginId: z.string().min(1), + bundle: z.string(), + appVersion: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("invoke"), + requestId: z.string().min(1), + method: z.string().min(1), + input: z.unknown(), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect"), + providerId: z.string().min(1), + connectionId: z.string().min(1), + request: providerConnectRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.send"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + input: ProviderInputSchema, + }) + .strict(), + z.object({ type: z.literal("provider.close"), connectionId: z.string().min(1) }).strict(), + z.object({ type: z.literal("shutdown") }).strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); + +export const PluginProcessMessageSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z + .object({ + type: z.literal("ready"), + methods: z.array(z.string()), + providers: z.array(providerMetadataSchema), + }) + .strict(), + z + .object({ type: z.literal("result"), requestId: z.string().min(1), output: z.unknown() }) + .strict(), + z + .object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() }) + .strict(), + z.object({ type: z.literal("fatal"), error: z.string() }).strict(), + z + .object({ + type: z.literal("provider.connected"), + connectionId: z.string().min(1), + version: z.number().int().positive(), + capabilities: z.array(z.string()), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect_failed"), + connectionId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.accepted"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + }) + .strict(), + z + .object({ + type: z.literal("provider.rejected"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.event"), + connectionId: z.string().min(1), + event: ProviderEventSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.closed"), + connectionId: z.string().min(1), + error: z.string().optional(), + }) + .strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1b4082df4d..c03db034b6 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,6 +1,15 @@ -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import { + PluginProcessRequestSchema, + type PluginProcessMessage, + type PluginProcessRequest, +} from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; +import { + ProviderEventSchema, + type ProviderConnection, + type ProviderRegistration, +} from "@getpaseo/plugin/provider"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -20,6 +29,12 @@ interface RegisteredRpc { } const handlers = new Map(); +const providers = new Map(); +const providerConnections = new Map< + string, + { connection: ProviderConnection; unsubscribe: () => void } +>(); +const pendingProviderConnections = new Map(); let cleanup: (() => void | Promise) | null = null; let daemonClient: DaemonClient | null = null; let paseo: PaseoApi | null = null; @@ -44,6 +59,12 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; +} + function validateMethod(method: string): string { const normalized = method.trim(); if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { @@ -63,6 +84,106 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void { handlers.set(method, { contract: { ...contract, name: method }, handler }); } +function registerProvider(provider: ProviderRegistration): void { + const id = provider.id.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(id)) { + throw new Error(`Invalid plugin provider ID: ${provider.id}`); + } + if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`); + if (typeof provider.connect !== "function") { + throw new Error(`Plugin provider ${id} must implement connect()`); + } + if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); + providers.set(id, { ...provider, id }); +} + +function providerMetadata(provider: ProviderRegistration) { + return { + id: provider.id, + label: provider.label, + description: provider.description, + iconPath: provider.icon, + }; +} + +async function connectProvider( + message: Extract, +): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); + if ( + providerConnections.has(message.connectionId) || + pendingProviderConnections.has(message.connectionId) + ) { + throw new Error(`Duplicate provider connection: ${message.connectionId}`); + } + const pending = { tombstoned: false }; + pendingProviderConnections.set(message.connectionId, pending); + let connection: ProviderConnection; + try { + connection = await provider.connect(message.request); + } catch (error) { + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) return; + throw error; + } + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) { + await connection.close().catch(() => undefined); + return; + } + let unsubscribe = () => {}; + unsubscribe = connection.onEvent((event) => { + try { + send({ + type: "provider.event", + connectionId: message.connectionId, + event: ProviderEventSchema.parse(jsonTransportValue(event)), + }); + } catch (error) { + providerConnections.delete(message.connectionId); + unsubscribe(); + void connection.close().catch(() => undefined); + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + } + }); + providerConnections.set(message.connectionId, { connection, unsubscribe }); + send({ + type: "provider.connected", + connectionId: message.connectionId, + version: connection.version, + capabilities: connection.capabilities, + }); +} + +async function sendProviderInput( + message: Extract, +): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const current = providerConnections.get(message.connectionId); + if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`); + await current.connection.send(message.input); + send({ + type: "provider.accepted", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + }); +} + +async function closeProviderConnection(connectionId: string): Promise { + const current = providerConnections.get(connectionId); + if (!current) return; + providerConnections.delete(connectionId); + current.unsubscribe(); + await current.connection.close(); + send({ type: "provider.closed", connectionId }); +} + const pluginAuthorRuntime = { defineAttachmentSource, defineRpc, @@ -90,7 +211,7 @@ function evaluateBundle(bundle: string): void { if (typeof setup !== "function") { throw new Error("Plugin server bundle must default export a function"); } - const contributedCleanup = setup({ handle: register }); + const contributedCleanup = setup({ handle: register, registerProvider }); if (typeof contributedCleanup !== "function") { throw new Error("Plugin contribution must return a cleanup function"); } @@ -117,12 +238,19 @@ async function initialize(message: Extract left.id.localeCompare(right.id)) + .map(providerMetadata), + }); } async function shutdown(): Promise { if (stopping) return; stopping = true; + for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; try { @@ -130,6 +258,7 @@ async function shutdown(): Promise { } catch (error) { console.error("Plugin cleanup failed", error); } + await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); await daemonClient?.close().catch(() => undefined); await sendAndWait({ type: "paseo_close" }); daemonClient = null; @@ -137,7 +266,25 @@ async function shutdown(): Promise { process.disconnect(); } -process.on("message", (message: PluginProcessRequest) => { +process.on("message", (rawMessage: unknown) => { + const parsed = PluginProcessRequestSchema.safeParse(rawMessage); + if (!parsed.success) { + const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null; + if (value && typeof value.connectionId === "string" && typeof value.acceptanceId === "string") { + send({ + type: "provider.rejected", + connectionId: value.connectionId, + acceptanceId: value.acceptanceId, + error: `Invalid provider input: ${parsed.error.message}`, + }); + void closeProviderConnection(value.connectionId); + return; + } + send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); + void shutdown(); + return; + } + const message = parsed.data; if (message.type === "initialize") { void initialize(message).catch(async (error) => { send({ type: "fatal", error: describeError(error) }); @@ -149,8 +296,59 @@ process.on("message", (message: PluginProcessRequest) => { void shutdown(); return; } + if (stopping) { + if (message.type === "provider.connect") { + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.send") { + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.close") { + send({ type: "provider.closed", connectionId: message.connectionId }); + } + return; + } + if (message.type === "provider.connect") { + void connectProvider(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.send") { + void sendProviderInput(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.close") { + void closeProviderConnection(message.connectionId).catch((error) => { + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } if (message.type === "paseo_frame" || message.type === "paseo_close") return; - if (stopping) return; const registered = handlers.get(message.method); if (!registered) { send({ diff --git a/packages/server/src/server/plugins/provider-icon.test.ts b/packages/server/src/server/plugins/provider-icon.test.ts new file mode 100644 index 0000000000..4faf9d289c --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.test.ts @@ -0,0 +1,84 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { readPluginProviderIcon } from "./provider-icon.js"; + +const directories: string[] = []; + +async function writeIcon(contents: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icon-")); + directories.push(directory); + await writeFile(path.join(directory, "icon.svg"), contents); + return directory; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("readPluginProviderIcon", () => { + test("accepts a self-contained SVG", async () => { + const svg = + ''; + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).resolves.toBe(svg); + }); + + test.each([ + ["non-SVG document", "hello", "file is not an SVG document"], + ["script", "", "script elements"], + ["foreign object", "", "foreignObject elements"], + ["style", "", "style elements"], + ["event handler", '', "event-handler attributes"], + ["javascript URL", '', "javascript URLs"], + ["external href", '', "external href"], + ])("rejects %s", async (_name, svg, reason) => { + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + `icon.svg": ${reason}`, + ); + }); + + test("rejects a path outside the plugin directory", async () => { + const directory = await writeIcon(""); + + await expect(readPluginProviderIcon(directory, "../icon.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a symlink that escapes the plugin directory", async () => { + const directory = await writeIcon(""); + const externalDirectory = await mkdtemp(path.join(tmpdir(), "paseo-external-icon-")); + directories.push(externalDirectory); + const externalIcon = path.join(externalDirectory, "external.svg"); + await writeFile(externalIcon, ""); + await symlink(externalIcon, path.join(directory, "linked.svg")); + + await expect(readPluginProviderIcon(directory, "linked.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a directory instead of a regular file", async () => { + const directory = await writeIcon(""); + await mkdir(path.join(directory, "nested.svg")); + + await expect(readPluginProviderIcon(directory, "nested.svg")).rejects.toThrow( + "file does not exist or is not a regular file", + ); + }); + + test("rejects an icon larger than 64 KiB", async () => { + const directory = await writeIcon(`${" ".repeat(64 * 1024)}`); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + "file exceeds 64 KiB", + ); + }); +}); diff --git a/packages/server/src/server/plugins/provider-icon.ts b/packages/server/src/server/plugins/provider-icon.ts new file mode 100644 index 0000000000..9a1e3f6714 --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.ts @@ -0,0 +1,64 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +const MAX_ICON_BYTES = 64 * 1024; + +export async function readPluginProviderIcon( + pluginDirectory: string, + iconPath: string, +): Promise { + const resolvedDirectory = await realpath(path.resolve(pluginDirectory)); + const requestedIcon = path.resolve(resolvedDirectory, iconPath); + assertInsidePluginDirectory(resolvedDirectory, requestedIcon, iconPath); + const resolvedIcon = await realpath(requestedIcon).catch(() => null); + if (!resolvedIcon) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + assertInsidePluginDirectory(resolvedDirectory, resolvedIcon, iconPath); + + const iconStat = await stat(resolvedIcon); + if (!iconStat.isFile()) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB"); + + const svg = await readFile(resolvedIcon, "utf8"); + validateSvg(iconPath, svg); + return svg; +} + +function assertInsidePluginDirectory( + resolvedDirectory: string, + resolvedIcon: string, + iconPath: string, +): void { + const relative = path.relative(resolvedDirectory, resolvedIcon); + const escapesDirectory = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory"); +} + +function validateSvg(iconPath: string, svg: string): void { + if (!/^\s*)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document"); + if (/)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed"); + if (/)/i.test(svg)) { + throw iconError(iconPath, "foreignObject elements are not allowed"); + } + if (/)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed"); + if (/\son[a-z0-9_-]*\s*=/i.test(svg)) { + throw iconError(iconPath, "event-handler attributes are not allowed"); + } + if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed"); + + const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi; + for (const match of svg.matchAll(hrefPattern)) { + const href = match[2] ?? match[3] ?? ""; + if (!href.startsWith("#")) { + throw iconError(iconPath, "external href references are not allowed"); + } + } +} + +function iconError(iconPath: string, reason: string): Error { + return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`); +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 021f695ff7..415c0e5374 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -4,12 +4,19 @@ import { PassThrough } from "node:stream"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; +import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; +function hasCompletedAgentTurn(events: readonly AgentStreamEvent[]): boolean { + return events.some((event) => event.type === "turn_completed"); +} + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); @@ -18,7 +25,20 @@ async function createPlugin(id: string, source: string): Promise { return directory; } -function createReloadChild(name: string, events: string[], methods: string[] = []) { +async function readTextIfPresent(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +function createReloadChild( + name: string, + events: string[], + methods: string[] = [], + providers: Array<{ id: string; label: string }> = [], +) { const listeners = new Map void>>(); const emit = (event: string, message: unknown) => { for (const listener of listeners.get(event) ?? []) listener(message as never); @@ -32,7 +52,7 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ callback?.(null); if (message.type === "initialize") { events.push(`start:${name}`); - queueMicrotask(() => emit("message", { type: "ready", methods })); + queueMicrotask(() => emit("message", { type: "ready", methods, providers })); } if (message.type === "shutdown") { events.push(`shutdown:${name}`); @@ -59,6 +79,9 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ listeners.set(event, registered); return this; }, + emitMessage(message: unknown) { + emit("message", message); + }, }; } @@ -140,6 +163,42 @@ function lifecycleMessages(runtime: PluginRuntime): string[] { .sort(); } +function hasCompletedTurn(events: readonly ProviderEvent[]): boolean { + return events.some((event) => event.type === "session.turn" && event.state === "completed"); +} + +function hasReadyRequest(events: readonly ProviderEvent[], requestId: string): boolean { + return events.some((event) => event.type === "session.ready" && event.requestId === requestId); +} + +function runtimeFailure(events: readonly ProviderEvent[]): ProviderEvent | undefined { + return events.find((event) => event.type === "session.runtime_failed"); +} + +function providerCloseCount( + messages: readonly { type: string; connectionId?: string }[], + connectionId: string | undefined, +): number { + return messages.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ).length; +} + +function hasMessageType(messages: readonly { type: string }[], type: string): boolean { + return messages.some((message) => message.type === type); +} + +function adaptRuntimeProvider( + runtime: PluginRuntime, + pluginId: string, + metadata: { id: string; label: string; description?: string; icon?: string }, +): ProviderRegistration { + return { + ...metadata, + connect: (request) => runtime.connectProvider(pluginId, metadata.id, request), + }; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), @@ -147,6 +206,523 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it("runs the direct example through the existing AgentClient path", async () => { + const pluginId = "provider-direct-example"; + const directory = fileURLToPath( + new URL("../../../../../plugin-examples/provider-direct/", import.meta.url), + ); + const runtime = createTestRuntime(); + await runtime.startPlugin(pluginId, directory); + const [metadata] = runtime.getProviderRegistrations(pluginId); + expect(metadata).toBeDefined(); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, pluginId, metadata!)]); + const client = adapters.clients()[metadata!.id]; + + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await expect(session.startTurn("hello", { clientMessageId: "direct-client" })).resolves.toEqual( + { turnId: "turn-1" }, + ); + await expect.poll(() => hasCompletedAgentTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { + type: "assistant_message", + id: undefined, + messageId: "assistant-1", + text: "Echo: hello", + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline" }), + }), + ); + + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("runs a provider connection through the real plugin subprocess boundary", async () => { + const directory = await createPlugin( + "provider-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; + +const provider: ProviderRegistration = { + id: "direct-example", + label: "Direct example", + icon: "icon.svg", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { + for (const listener of listeners) listener(event); + }; + return { + version: request.versions[0] ?? 1, + capabilities: request.capabilities, + async send(input) { + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: request.capabilities, + restoration: "core", + persistence: { version: 1, data: { resume: "native-1" } }, + cwd: input.config.cwd, + futureField: "ignored by this protocol version", + } as ProviderEvent); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "example-1", + models: [{ id: "example-1", label: "Example 1" }], + modes: [], + thinkingOptions: [], + settings: [ + { type: "toggle", id: "concise", label: "Concise", value: true }, + ], + }, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type !== "session.prompt") return; + if (input.prompt.input.type === "command") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "completed" }, + }); + return; + } + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: "user-1", + text: "hello", + clientMessageId: input.prompt.clientMessageId, + }, + }); + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + cwd: "/repo", + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { + type: "plugin", + id: "review-1", + pluginId: "provider-round-trip", + kind: "review", + version: 1, + data: { verdict: "ship" }, + }, + }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() {}, + }; + }, +}; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(provider); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-round-trip", directory); + + const [registration] = runtime.getProviderRegistrations("provider-round-trip"); + expect(registration).toMatchObject({ + id: "direct-example", + label: "Direct example", + iconPath: "icon.svg", + }); + const connection = await runtime.connectProvider("provider-round-trip", "direct-example", { + versions: [1], + capabilities: [ + "prompt.message", + "prompt.command", + "session.configure", + "session.persistence", + "session.subsession", + "timeline.plugin", + "future.capability", + ], + }); + expect(connection.capabilities).not.toContain("future.capability"); + const events: ProviderEvent[] = []; + const unsubscribe = connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-1", + sessionId: "root-1", + config: { + cwd: "/repo", + env: {}, + mcpServers: {}, + settings: {}, + persist: true, + }, + history: "replay", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-2", + delivery: "auto", + input: { type: "command", name: "compact", arguments: "" }, + }, + }); + + await expect.poll(() => events.length).toBe(8); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-1", + result: { type: "turn", turnId: "turn-1" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-2", + result: { type: "completed" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: "root-1", + }), + ); + expect(events.find((event) => event.type === "session.opened")).not.toHaveProperty( + "futureField", + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: "child-1", + item: expect.objectContaining({ type: "plugin", pluginId: "provider-round-trip" }), + }), + ); + + unsubscribe(); + await connection.close(); + await runtime.stopAll(); + }); + + it("adapts an ACP command and example transformer through the AgentClient path", async () => { + const transformerPath = fileURLToPath( + new URL( + "../../../../../plugin-examples/provider-acp-transformer/server/vendor-edit.ts", + import.meta.url, + ), + ); + const directory = await createPlugin( + "provider-acp-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; +import { vendorEditTransformer } from "./server/vendor-edit.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(runAcpProvider({ + id: "acp-example", + label: "ACP example", + command: [process.execPath, __ACP_COMMAND__], + transformers: [vendorEditTransformer], + })); + return () => {}; +}`, + ); + await mkdir(path.join(directory, "server")); + await writeFile( + path.join(directory, "server/vendor-edit.ts"), + await readFile(transformerPath, "utf8"), + "utf8", + ); + const agentPath = path.join(directory, "fake-acp.cjs"); + await writeFile( + agentPath, + `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + } else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + } else if (message.method === "session/prompt") { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "tool_call", toolCallId: "edit-1", name: "vendor_file_edit", title: "Vendor edit", status: "completed", rawInput: { path: "/tmp/example.ts", before: "old", after: "new" } } } }); + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: "ACP says hi" } } } }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + } else if (message.method === "session/close" || message.method === "session/cancel") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +});`, + "utf8", + ); + const pluginPath = path.join(directory, "index.server.ts"); + const pluginSource = (await readFile(pluginPath, "utf8")).replace( + "__ACP_COMMAND__", + JSON.stringify(agentPath), + ); + await writeFile(pluginPath, pluginSource, "utf8"); + + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-acp-round-trip", directory); + const connection = await runtime.connectProvider("provider-acp-round-trip", "acp-example", { + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-acp", + sessionId: "paseo-1", + config: { cwd: directory, env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await expect.poll(() => hasReadyRequest(events, "open-acp")).toBe(true); + await connection.send({ + type: "session.prompt", + sessionId: "paseo-1", + prompt: { + clientMessageId: "client-acp", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + + await expect.poll(() => hasCompletedTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-acp", + result: { type: "turn", turnId: "acp:client-acp" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + item: expect.objectContaining({ + type: "assistant_message", + id: "assistant-1", + text: "ACP says hi", + }), + }), + ); + + await connection.close(); + const [metadata] = runtime.getProviderRegistrations("provider-acp-round-trip"); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, "provider-acp-round-trip", metadata!)]); + const client = adapters.clients()[metadata!.id]; + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const agentEvents: AgentStreamEvent[] = []; + session.subscribe((event) => agentEvents.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "agent-client-acp" }), + ).resolves.toEqual({ turnId: "acp:agent-client-acp" }); + await expect.poll(() => hasCompletedAgentTurn(agentEvents)).toBe(true); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: "ACP says hi" }), + }), + ); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + type: "tool_call", + callId: "edit-1", + detail: { + type: "edit", + filePath: "/tmp/example.ts", + oldString: "old", + newString: "new", + unifiedDiff: undefined, + }, + }), + }), + ); + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("rejects a malformed provider event from a real plugin subprocess", async () => { + const directory = await createPlugin( + "malicious-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +let connectionId = ""; +process.on("message", (message: unknown) => { + const value = message as { type?: string; connectionId?: string }; + if (value.type === "provider.connect") connectionId = value.connectionId ?? ""; +}); +const provider: ProviderRegistration = { + id: "malicious", + label: "Malicious", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } else if (input.type === "session.prompt") { + process.send?.({ type: "provider.event", connectionId, event: { type: "invented.event", sessionId: input.sessionId } }); + } + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("malicious-provider", directory); + const connection = await runtime.connectProvider("malicious-provider", "malicious", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-malicious", + sessionId: "root-malicious", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "root-malicious", + prompt: { + clientMessageId: "malformed-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "break transport" }] }, + }, + }), + ).rejects.toThrow("invalid message"); + expect(events).toContainEqual( + expect.objectContaining({ type: "session.runtime_failed", sessionId: "root-malicious" }), + ); + expect(runtime.catalog()).toHaveLength(1); + await runtime.stopAll(); + }); + + it("emits runtime failure for live sessions when a real plugin process dies", async () => { + const directory = await createPlugin( + "dying-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider"; +const provider: ProviderRegistration = { + id: "dying", + label: "Dying", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } + if (input.type === "session.prompt") setTimeout(() => process.exit(17), 10); + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("dying-provider", directory); + const connection = await runtime.connectProvider("dying-provider", "dying", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-dying", + sessionId: "root-dying", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-dying", + prompt: { + clientMessageId: "die-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "bye" }] }, + }, + }); + + await expect + .poll(() => runtimeFailure(events)) + .toMatchObject({ + type: "session.runtime_failed", + sessionId: "root-dying", + error: { message: "Plugin process exited: dying-provider" }, + }); + await runtime.stopAll(); + }); + it("records host-owned plugin lifecycle events", async () => { const directory = await createPlugin( "lifecycle", @@ -382,38 +958,298 @@ export default function contribute(plugin: unknown) { await rm(cleanupFile, { force: true }); }); - it("does not kill a healthy child while its graceful cleanup is still running", async () => { + it("closes a provider connection that resolves during plugin shutdown", async () => { + const suffix = `${process.pid}-${Date.now()}`; + const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`); + const closedFile = path.join(tmpdir(), `paseo-provider-connect-closed-${suffix}`); + const directory = await createPlugin( + "shutdown-connect", + `import { writeFile } from "node:fs/promises"; +import type { ProviderRegistration } from "@getpaseo/plugin/provider"; + +const provider: ProviderRegistration = { + id: "delayed", + label: "Delayed", + async connect() { + await writeFile(${JSON.stringify(startedFile)}, "started"); + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + version: 1, + capabilities: [], + async send() {}, + onEvent() { return () => undefined; }, + async close() { await writeFile(${JSON.stringify(closedFile)}, "closed"); }, + }; + }, +}; + +export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) { + server.registerProvider(provider); + return () => undefined; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("shutdown-connect", directory); + const pending = runtime.connectProvider("shutdown-connect", "delayed", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => readTextIfPresent(startedFile)).toBe("started"); + + const stopping = runtime.stopPluginById("shutdown-connect"); + await expect(pending).rejects.toThrow("Plugin stopped: shutdown-connect"); + await stopping; + + await expect(readFile(closedFile, "utf8")).resolves.toBe("closed"); + await Promise.all([rm(startedFile, { force: true }), rm(closedFile, { force: true })]); + }); + + it("closes a provider connection that arrives after negotiation timed out", async () => { + const directory = await createPlugin( + "late-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "late-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("late-provider", directory); + vi.useFakeTimers(); + try { + const rejected = expect( + runtime.connectProvider("late-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + const connectionId = sent.find( + (message) => message.type === "provider.connect", + )?.connectionId; + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(1); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await vi.advanceTimersByTimeAsync(0); + + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(2); + child.emitMessage({ type: "provider.closed", connectionId }); + } finally { + await runtime.stopAll(); + vi.useRealTimers(); + } + }); + + it("closes child-side state after invalid provider negotiation", async () => { + const directory = await createPlugin( + "invalid-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "invalid-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connected", + connectionId: message.connectionId, + version: 2, + capabilities: [], + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("invalid-provider", directory); + + await expect( + runtime.connectProvider("invalid-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("unoffered version"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + await expect.poll(() => sent).toContainEqual({ type: "provider.close", connectionId }); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after the child rejected its negotiation", async () => { + const directory = await createPlugin( + "rejected-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "rejected-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "provider rejected connection", + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("rejected-provider", directory); + + await expect( + runtime.connectProvider("rejected-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("provider rejected connection"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after plugin shutdown cancels negotiation", async () => { + const directory = await createPlugin( + "canceled-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "canceled-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect" || message.type === "shutdown") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("canceled-provider", directory); + const pending = runtime.connectProvider("canceled-provider", "provider", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => hasMessageType(sent, "provider.connect")).toBe(true); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const rejection = pending.then( + () => null, + (error: unknown) => error, + ); + const stopping = runtime.stopPluginById("canceled-provider"); + try { + await expect.poll(() => providerCloseCount(sent, connectionId)).toBe(1); + await expect(rejection).resolves.toMatchObject({ + message: "Plugin stopped: canceled-provider", + }); + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + } finally { + child.kill(); + await stopping; + } + }); + + it("escalates a plugin that ignores graceful shutdown from TERM to KILL", async () => { vi.useFakeTimers(); try { const directory = await createPlugin( "held-cleanup", `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); - const events: string[] = []; - const child = createReloadChild("held-cleanup", events); + const child = createReloadChild("held-cleanup", []); const originalSend = child.send.bind(child); - let releaseCleanup = () => undefined; child.send = (message, callback) => { if (message.type !== "shutdown") return originalSend(message, callback); callback?.(null); - events.push("shutdown:held-cleanup"); - releaseCleanup = () => { - child.connected = false; - child.kill(); - child.killed = false; - }; + return true; + }; + const signals: Array = []; + const originalKill = child.kill.bind(child); + child.kill = (signal?: NodeJS.Signals) => { + signals.push(signal); + if (signal === "SIGKILL") return originalKill(); + child.killed = true; return true; }; const runtime = createTestRuntime({ spawnChild: () => child }); await runtime.startPlugin("held-cleanup", directory); const stopping = runtime.stopPluginById("held-cleanup"); - await vi.advanceTimersByTimeAsync(60_000); - expect(child.killed).toBe(false); - - releaseCleanup(); + await vi.advanceTimersByTimeAsync(2_000); + expect(signals).toEqual(["SIGTERM"]); + await vi.advanceTimersByTimeAsync(2_000); await stopping; - expect(events).toContain("shutdown:held-cleanup"); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); } finally { vi.useRealTimers(); } diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 1afb0655e5..922a529324 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -4,10 +4,23 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import type pino from "pino"; +import { + PROVIDER_CAPABILITIES, + requireProviderCapabilities, + type ProviderConnectRequest, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, +} from "@getpaseo/plugin/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import type { + PluginProcessMessage, + PluginProcessRequest, + PluginProviderMetadata, +} from "./plugin-process-protocol.js"; +import { PluginProcessMessageSchema } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; @@ -16,6 +29,7 @@ const REQUEST_TIMEOUT_MS = 30_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; +const SOFT_SHUTDOWN_TIMEOUT_MS = 2_000; interface PluginOutputStream { on(event: "data", listener: (chunk: Buffer | string) => void): this; @@ -27,9 +41,9 @@ interface PluginChild { stdout?: PluginOutputStream | null; stderr?: PluginOutputStream | null; send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; - kill(): boolean; + kill(signal?: NodeJS.Signals): boolean; disconnect(): void; - on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "message", listener: (message: unknown) => void): this; on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; } @@ -43,13 +57,37 @@ interface LoadedPlugin { id: string; clientBundle: string; methods: ReadonlySet; + providers: readonly PluginProviderMetadata[]; child: PluginChild | null; outputCapture: PluginOutputCapture | null; pending: Map; + providerConnections: Map; + providerConnectionTombstones: Set; sessionSocket: PluginSessionSocket | null; sessionClosed: Promise | null; } +interface PendingProviderSend { + input: ProviderInput; + resolve: () => void; + reject: (error: Error) => void; +} + +interface RemoteProviderConnection { + request: ProviderConnectRequest; + capabilities: readonly string[]; + listeners: Set<(event: ProviderEvent) => void>; + pendingSends: Map; + sessions: Map; + connected: boolean; + closed: boolean; + closedPromise: Promise; + resolveConnected(connection: ProviderConnection): void; + rejectConnected(error: Error): void; + resolveClosed(): void; + rejectClosed(error: Error): void; +} + interface PluginRuntimeDependencies { spawnChild?: () => PluginChild; sessionHost?: PluginPaseoSessionHost; @@ -174,6 +212,12 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; +} + function send(child: PluginChild, message: PluginProcessRequest): Promise { return new Promise((resolve, reject) => { child.send(message, (error) => { @@ -285,6 +329,70 @@ export class PluginRuntime { .sort((left, right) => left.id.localeCompare(right.id)); } + getProviderRegistrations(pluginId: string): readonly PluginProviderMetadata[] { + return this.plugins.get(pluginId)?.providers ?? []; + } + + async connectProvider( + pluginId: string, + providerId: string, + request: ProviderConnectRequest, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded?.child) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.providers.some((provider) => provider.id === providerId)) { + throw new Error(`Plugin ${pluginId} does not contribute provider ${providerId}`); + } + const connectionId = randomUUID(); + let resolveConnected!: (connection: ProviderConnection) => void; + let rejectConnected!: (error: Error) => void; + let resolveClosed!: () => void; + let rejectClosed!: (error: Error) => void; + const connected = new Promise((resolve, reject) => { + resolveConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve; + rejectClosed = reject; + }); + void closed.catch(() => undefined); + const state: RemoteProviderConnection = { + request, + capabilities: [], + listeners: new Set(), + pendingSends: new Map(), + sessions: new Map(), + connected: false, + closed: false, + closedPromise: closed, + resolveConnected, + rejectConnected, + resolveClosed, + rejectClosed, + }; + loaded.providerConnections.set(connectionId, state); + const timeout = setTimeout(() => { + if (state.connected) return; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider connection timed out: ${pluginId}.${providerId}`), + ); + }, REQUEST_TIMEOUT_MS); + void send(loaded.child, { + type: "provider.connect", + providerId, + connectionId, + request, + }).catch((error) => { + clearTimeout(timeout); + this.abandonProviderConnect(loaded, connectionId, state, error); + }); + return connected.finally(() => clearTimeout(timeout)); + } + getLogs(pluginId: string): PluginLogEntry[] { return ( this.logTails.get(pluginId)?.entries.map((entry) => ({ @@ -345,9 +453,12 @@ export class PluginRuntime { id: pluginId, clientBundle: bundles.clientBundle ?? "", methods: new Set(), + providers: [], child: null, outputCapture: null, pending: new Map(), + providerConnections: new Map(), + providerConnectionTombstones: new Set(), sessionSocket: null, sessionClosed: null, }; @@ -367,51 +478,63 @@ export class PluginRuntime { throw error; }); let loaded: LoadedPlugin | null = null; - let methods: string[]; + let ready: Extract; 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 === "paseo_frame") { - sessionSocket.receive(message.data, message.isBinary); - } else if (message.type === "paseo_close") { - sessionSocket.peerClosed(); - } else if (message.type === "ready") { + ready = 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); - resolve(message.methods); - } else if (message.type === "fatal") { - fail(new Error(message.error)); - } else if (loaded) { - this.handleChildMessage(loaded, message); - } - }); - child.on("close", () => { - sessionSocket.peerClosed(); - if (!loaded) { - fail(new Error(`Plugin ${pluginId} exited during initialization`)); - return; - } - void this.handleChildClose(loaded); - }); - void send(child, { - type: "initialize", - pluginId, - appVersion: this.daemonVersion, - bundle: serverBundle, - }).catch(fail); - }); + reject(error); + }; + child.on("message", (rawMessage) => { + const parsed = PluginProcessMessageSchema.safeParse(rawMessage); + if (!parsed.success) { + const error = new Error( + `Plugin ${pluginId} sent an invalid message: ${parsed.error.message}`, + ); + if (loaded) this.handleMalformedChildMessage(loaded, rawMessage, error); + else fail(error); + return; + } + const message = parsed.data; + if (message.type === "paseo_frame") { + sessionSocket.receive(message.data, message.isBinary); + } else if (message.type === "paseo_close") { + sessionSocket.peerClosed(); + } else if (message.type === "ready") { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(message); + } else if (message.type === "fatal") { + fail(new Error(message.error)); + } else if (loaded) { + this.handleChildMessage(loaded, message); + } + }); + child.on("close", () => { + sessionSocket.peerClosed(); + if (!loaded) { + fail(new Error(`Plugin ${pluginId} exited during initialization`)); + return; + } + void this.handleChildClose(loaded); + }); + void send(child, { + type: "initialize", + pluginId, + appVersion: this.daemonVersion, + bundle: serverBundle, + }).catch(fail); + }, + ); } catch (error) { sessionSocket.close(); await sessionAttachment.closed; @@ -421,18 +544,28 @@ export class PluginRuntime { loaded = { id: pluginId, clientBundle: bundles.clientBundle ?? "", - methods: new Set(methods), + methods: new Set(ready.methods), + providers: ready.providers ?? [], child, outputCapture, pending, + providerConnections: new Map(), + providerConnectionTombstones: new Set(), sessionSocket, sessionClosed: sessionAttachment.closed, }; - this.logger.info({ pluginId, methods }, "Loaded plugin"); + this.logger.info( + { pluginId, methods: ready.methods, providers: ready.providers }, + "Loaded plugin", + ); return loaded; } private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type.startsWith("provider.")) { + this.handleProviderMessage(loaded, message); + return; + } if (message.type !== "result" && message.type !== "error") return; const pending = loaded.pending.get(message.requestId); if (!pending) return; @@ -442,6 +575,295 @@ export class PluginRuntime { else pending.reject(new Error(message.error)); } + private handleProviderMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (!("connectionId" in message)) return; + const state = loaded.providerConnections.get(message.connectionId); + if (!state) { + if (!loaded.providerConnectionTombstones.has(message.connectionId)) return; + if (message.type === "provider.connected" && loaded.child?.connected) { + void send(loaded.child, { + type: "provider.close", + connectionId: message.connectionId, + }).catch(() => undefined); + } + if (message.type === "provider.closed" || message.type === "provider.connect_failed") { + loaded.providerConnectionTombstones.delete(message.connectionId); + } + return; + } + if (message.type === "provider.connected") { + this.finishProviderConnect(loaded, message.connectionId, state, message); + return; + } + if (message.type === "provider.connect_failed") { + this.abandonProviderConnect(loaded, message.connectionId, state, new Error(message.error)); + return; + } + if (message.type === "provider.accepted" || message.type === "provider.rejected") { + const pending = state.pendingSends.get(message.acceptanceId); + if (!pending) return; + state.pendingSends.delete(message.acceptanceId); + if (message.type === "provider.accepted") { + this.trackAcceptedProviderInput(state, pending.input); + pending.resolve(); + } else pending.reject(new Error(message.error)); + return; + } + if (message.type === "provider.event") { + this.publishProviderEvent(loaded, message.connectionId, state, message.event); + return; + } + if (message.type === "provider.closed") { + loaded.providerConnections.delete(message.connectionId); + if (message.error) { + this.failRemoteProviderConnection(state, new Error(message.error)); + return; + } + state.closed = true; + this.rejectProviderSends(state, "Provider connection closed"); + state.resolveClosed(); + } + } + + private finishProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + message: Extract, + ): void { + if (!Number.isInteger(message.version) || message.version < 1) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected invalid version: ${message.version}`), + ); + return; + } + if (!state.request.versions.includes(message.version)) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered version: ${message.version}`), + ); + return; + } + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = message.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.request.capabilities.includes(capability), + ); + if (invalidCapability) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + message.capabilities.includes(capability), + ); + const connection: ProviderConnection = { + version: message.version, + capabilities, + send: (input) => this.sendProviderInput(loaded, connectionId, state, input), + onEvent(listener) { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + close: () => this.closeProviderConnection(loaded, connectionId, state), + }; + state.capabilities = capabilities; + state.connected = true; + state.resolveConnected(connection); + } + + private abandonProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + if (state.connected || state.closed) return; + loaded.providerConnections.delete(connectionId); + loaded.providerConnectionTombstones.add(connectionId); + state.closed = true; + state.rejectConnected(error); + state.rejectClosed(error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private sendProviderInput( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + input: ProviderInput, + ): Promise { + if (state.closed) return Promise.reject(new Error("Provider connection is closed")); + try { + if (input.type === "session.open" || !("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + } else { + const capabilities = state.sessions.get(input.sessionId); + if (!capabilities) throw new Error(`Unknown provider session: ${input.sessionId}`); + requireProviderCapabilities(capabilities, input); + } + } catch (error) { + return Promise.reject(error); + } + const child = loaded.child; + if (!child) return Promise.reject(new Error(`Plugin has no server entry: ${loaded.id}`)); + const acceptanceId = randomUUID(); + return new Promise((resolve, reject) => { + state.pendingSends.set(acceptanceId, { input, resolve, reject }); + void send(child, { + type: "provider.send", + connectionId, + acceptanceId, + input: jsonTransportValue(input), + }).catch((error) => { + state.pendingSends.delete(acceptanceId); + reject(error); + }); + }); + } + + private async closeProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + ): Promise { + if (state.closed) return; + const child = loaded.child; + if (!child) throw new Error(`Plugin has no server entry: ${loaded.id}`); + await send(child, { type: "provider.close", connectionId }); + await state.closedPromise; + } + + private publishProviderEvent( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + event: ProviderEvent, + ): void { + if ( + event.type === "timeline.item" && + event.item.type === "plugin" && + event.item.pluginId !== loaded.id + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error( + `Provider plugin ${loaded.id} cannot emit timeline items for ${event.item.pluginId}`, + ), + ); + return; + } + if (event.type === "session.opened") { + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = event.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.capabilities.includes(capability), + ); + if (invalidCapability) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider session selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const selectedCapabilities = event.capabilities; + event = { + ...event, + capabilities: PROVIDER_CAPABILITIES.filter((capability) => + selectedCapabilities.includes(capability), + ), + }; + if ( + event.parentSessionId && + !state.sessions.get(event.parentSessionId)?.includes("session.subsession") + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider parent session did not negotiate session.subsession`), + ); + return; + } + } + if (event.type === "session.opened") { + state.sessions.set(event.sessionId, event.capabilities); + } + for (const listener of state.listeners) listener(event); + if (event.type === "session.closed") state.sessions.delete(event.sessionId); + } + + private failProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + loaded.providerConnections.delete(connectionId); + this.failRemoteProviderConnection(state, error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private trackAcceptedProviderInput(state: RemoteProviderConnection, input: ProviderInput): void { + if (input.type === "session.open" && !state.sessions.has(input.sessionId)) { + state.sessions.set(input.sessionId, []); + } + if (input.type === "session.close") state.sessions.delete(input.sessionId); + } + + private handleMalformedChildMessage( + loaded: LoadedPlugin, + rawMessage: unknown, + error: Error, + ): void { + const connectionId = readConnectionId(rawMessage); + const state = connectionId ? loaded.providerConnections.get(connectionId) : undefined; + if (!connectionId || !state) { + this.appendLog(loaded.id, "stderr", `[paseo] ${error.message}`); + terminatePluginChild(loaded.child!); + return; + } + this.failProviderConnection(loaded, connectionId, state, error); + } + + private failRemoteProviderConnection(state: RemoteProviderConnection, error: Error): void { + if (state.closed) return; + for (const sessionId of state.sessions.keys()) { + const event: ProviderEvent = { + type: "session.runtime_failed", + sessionId, + error: { message: error.message }, + }; + for (const listener of state.listeners) listener(event); + } + state.closed = true; + state.rejectConnected(error); + this.rejectProviderSends(state, error.message); + state.rejectClosed(error); + } + + private rejectProviderSends(state: RemoteProviderConnection, message: string): void { + for (const pending of state.pendingSends.values()) pending.reject(new Error(message)); + state.pendingSends.clear(); + } + private async handleChildClose(loaded: LoadedPlugin): Promise { loaded.sessionSocket?.peerClosed(); const wasPublished = this.plugins.get(loaded.id) === loaded; @@ -449,12 +871,25 @@ export class PluginRuntime { this.plugins.delete(loaded.id); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); + for (const state of loaded.providerConnections.values()) { + this.failRemoteProviderConnection(state, new Error(`Plugin process exited: ${loaded.id}`)); + } + loaded.providerConnections.clear(); await loaded.sessionClosed; if (wasPublished) this.notify(loaded.id, `Plugin process exited: ${loaded.id}`); } private async stopPlugin(loaded: LoadedPlugin): Promise { this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin"); + for (const [connectionId, state] of loaded.providerConnections) { + if (state.connected) continue; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin stopped: ${loaded.id}`), + ); + } const { child, sessionSocket, sessionClosed } = loaded; if (!child || !sessionSocket || !sessionClosed) { this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -474,7 +909,15 @@ export class PluginRuntime { if (child.connected) { await send(child, { type: "shutdown" }).catch(() => undefined); } - await closed; + let forceTimer: ReturnType | null = setTimeout(() => { + if (!child.killed) child.kill("SIGTERM"); + forceTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + await closed.finally(() => { + if (forceTimer) clearTimeout(forceTimer); + }); sessionSocket.peerClosed(); await sessionClosed; this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -515,3 +958,9 @@ export class PluginRuntime { for (const listener of this.listeners) listener(pluginId, error); } } + +function readConnectionId(value: unknown): string | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const connectionId = Reflect.get(value, "connectionId"); + return typeof connectionId === "string" ? connectionId : null; +} diff --git a/plugin-examples/provider-acp-transformer/README.md b/plugin-examples/provider-acp-transformer/README.md new file mode 100644 index 0000000000..50c54e37c8 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/README.md @@ -0,0 +1,11 @@ +# ACP provider transformer example + +This server plugin registers an ACP command through `runAcpProvider()`. The helper owns ACP process +and SDK details behind Paseo's callback-based provider connection. + +`server/vendor-edit.ts` shows the intended extension seam: a focused transformer converts one +vendor tool payload into Paseo's ordinary file-edit shape. It does not translate the whole event +stream. + +Replace the placeholder `example-acp --stdio` command with an installed ACP agent before enabling +the plugin. diff --git a/plugin-examples/provider-acp-transformer/icon.svg b/plugin-examples/provider-acp-transformer/icon.svg new file mode 100644 index 0000000000..841eef3aee --- /dev/null +++ b/plugin-examples/provider-acp-transformer/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-examples/provider-acp-transformer/index.server.ts b/plugin-examples/provider-acp-transformer/index.server.ts new file mode 100644 index 0000000000..5b85f30fa5 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/index.server.ts @@ -0,0 +1,17 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; +import { vendorEditTransformer } from "./server/vendor-edit.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider( + runAcpProvider({ + id: "example-acp", + label: "Example ACP", + description: "An ACP command adapted to Paseo's provider boundary", + icon: "icon.svg", + command: ["example-acp", "--stdio"], + transformers: [vendorEditTransformer], + }), + ); + return () => {}; +} diff --git a/plugin-examples/provider-acp-transformer/paseo-plugin.json b/plugin-examples/provider-acp-transformer/paseo-plugin.json new file mode 100644 index 0000000000..9ef350ba02 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "provider-acp-transformer-example", + "name": "ACP provider transformer example", + "version": "1.0.0", + "description": "Wraps an ACP command and normalizes one vendor file-edit shape." +} diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts new file mode 100644 index 0000000000..e570e94974 --- /dev/null +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { vendorEditTransformer } from "./vendor-edit.js"; + +describe("vendorEditTransformer", () => { + it("normalizes only the vendor edit payload", () => { + const transformed = vendorEditTransformer.toolCall?.( + { + id: "edit-1", + name: "vendor_file_edit", + title: "Edit file", + status: "completed", + input: { path: "src/app.ts", before: "old", after: "new" }, + output: null, + locations: [], + }, + { sessionId: "session-1" }, + ); + + expect(transformed).toMatchObject({ + kind: "edit", + input: { filePath: "src/app.ts", oldString: "old", newString: "new" }, + }); + }); + + it("preserves a vendor edit with a malformed payload", () => { + const toolCall = { + id: "edit-1", + name: "vendor_file_edit", + title: "Edit file", + status: "completed" as const, + input: { path: "src/app.ts", before: "old" }, + output: null, + locations: [], + }; + + const transformed = vendorEditTransformer.toolCall?.(toolCall, { + sessionId: "session-1", + }); + + expect(transformed).toBe(toolCall); + }); +}); diff --git a/plugin-examples/provider-acp-transformer/server/vendor-edit.ts b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts new file mode 100644 index 0000000000..58908678af --- /dev/null +++ b/plugin-examples/provider-acp-transformer/server/vendor-edit.ts @@ -0,0 +1,25 @@ +import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import { z } from "zod"; + +const vendorEditInputSchema = z.object({ + path: z.string(), + before: z.string(), + after: z.string(), +}); + +export const vendorEditTransformer: AcpTransformer = { + toolCall(toolCall) { + if (toolCall.name !== "vendor_file_edit") return toolCall; + const input = vendorEditInputSchema.safeParse(toolCall.input); + if (!input.success) return toolCall; + return { + ...toolCall, + kind: "edit", + input: { + filePath: input.data.path, + oldString: input.data.before, + newString: input.data.after, + }, + }; + }, +}; diff --git a/plugin-examples/provider-direct/README.md b/plugin-examples/provider-direct/README.md new file mode 100644 index 0000000000..8bd5921330 --- /dev/null +++ b/plugin-examples/provider-direct/README.md @@ -0,0 +1,12 @@ +# Direct provider example + +This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation, +catalog and session listing, session creation, prompt and command disposition, settings, +persistence and replay, archive actions, and an ordinary child session. + +The provider emits versioned `provider-result` timeline items for its root and child sessions. +`index.client.tsx` registers the Zod schema and React Native renderer for those items. The server +and client share only the item kind and schema under `shared/`. + +The provider receives Paseo host tools through `config.mcpServers`; it does not receive a second +callback tool API. diff --git a/plugin-examples/provider-direct/client/provider-result.tsx b/plugin-examples/provider-direct/client/provider-result.tsx new file mode 100644 index 0000000000..b7d2745f37 --- /dev/null +++ b/plugin-examples/provider-direct/client/provider-result.tsx @@ -0,0 +1,32 @@ +import type { PluginTimelineItemProps } from "@getpaseo/plugin"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import type { z } from "zod"; +import { providerResultSchema } from "../shared/provider-result"; + +type ProviderResultData = z.output; + +export function ProviderResult({ item, theme }: PluginTimelineItemProps) { + const styles = useMemo( + () => ({ + card: { + gap: 4, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 10, + padding: 12, + backgroundColor: theme.colors.surface1, + }, + label: { color: theme.colors.accent, fontWeight: "600" as const }, + detail: { color: theme.colors.foreground }, + }), + [theme], + ); + + return ( + + {item.data.label} + {item.data.detail} + + ); +} diff --git a/plugin-examples/provider-direct/icon.svg b/plugin-examples/provider-direct/icon.svg new file mode 100644 index 0000000000..0da745c896 --- /dev/null +++ b/plugin-examples/provider-direct/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-examples/provider-direct/index.client.tsx b/plugin-examples/provider-direct/index.client.tsx new file mode 100644 index 0000000000..7954fdecb2 --- /dev/null +++ b/plugin-examples/provider-direct/index.client.tsx @@ -0,0 +1,13 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { ProviderResult } from "./client/provider-result"; +import { providerResultKind, providerResultSchema } from "./shared/provider-result"; + +export default function contribute(client: PluginClientContext) { + client.addTimelineRenderer({ + kind: providerResultKind, + version: 1, + schema: providerResultSchema, + Component: ProviderResult, + }); + return () => {}; +} diff --git a/plugin-examples/provider-direct/index.server.ts b/plugin-examples/provider-direct/index.server.ts new file mode 100644 index 0000000000..61a094391d --- /dev/null +++ b/plugin-examples/provider-direct/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createDirectExampleProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createDirectExampleProvider()); + return () => {}; +} diff --git a/plugin-examples/provider-direct/paseo-plugin.json b/plugin-examples/provider-direct/paseo-plugin.json new file mode 100644 index 0000000000..e9af46c731 --- /dev/null +++ b/plugin-examples/provider-direct/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "provider-direct-example" +} diff --git a/plugin-examples/provider-direct/server/provider.test.ts b/plugin-examples/provider-direct/server/provider.test.ts new file mode 100644 index 0000000000..ce989f4b4f --- /dev/null +++ b/plugin-examples/provider-direct/server/provider.test.ts @@ -0,0 +1,169 @@ +import type { ProviderEvent } from "@getpaseo/plugin/provider"; +import { describe, expect, it } from "vitest"; +import { createDirectExampleProvider } from "./provider.js"; + +describe("direct provider example", () => { + it("fails steering when no turn is active without creating a turn", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: ["prompt.command", "prompt.message", "prompt.steer"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-1", + sessionId: "session-1", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false }, + history: "skip", + }); + const turnEventsBefore = events.filter((event) => event.type === "session.turn").length; + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "steer-1", + delivery: "steer", + input: { type: "command", name: "reset", arguments: "" }, + }, + }); + + expect(events).toContainEqual({ + type: "session.prompt_result", + sessionId: "session-1", + clientMessageId: "steer-1", + result: { type: "failed", error: { message: "There is no active turn to steer" } }, + }); + expect(events.filter((event) => event.type === "session.turn")).toHaveLength(turnEventsBefore); + expect(events).not.toContainEqual(expect.objectContaining({ type: "session.notice" })); + await connection.close(); + }); + + it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: [ + "prompt.message", + "prompt.command", + "session.configure", + "session.persistence", + "session.subsession", + "timeline.plugin", + ], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-complete", + sessionId: "session-complete", + config: { + cwd: "/repo", + env: { EXAMPLE: "1" }, + systemPrompt: "Be useful", + mcpServers: {}, + model: "example-1", + mode: "build", + settings: { concise: true, voice: "calm" }, + providerOptions: { privateValue: 1 }, + persist: true, + }, + history: "skip", + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.config", + config: expect.objectContaining({ + settings: expect.arrayContaining([ + expect.objectContaining({ id: "concise", value: true }), + expect.objectContaining({ id: "voice", value: "calm" }), + ]), + }), + }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: "session.commands" })); + + await connection.send({ + type: "session.configure", + requestId: "configure-complete", + sessionId: "session-complete", + changes: { settings: { voice: "direct" } }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "session-complete", + prompt: { + clientMessageId: "command-1", + delivery: "auto", + input: { type: "command", name: "reset", arguments: "" }, + }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "session-complete", + prompt: { + clientMessageId: "message-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "command-1", + result: { type: "completed" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + item: expect.objectContaining({ type: "user_message", clientMessageId: "message-1" }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: "session-complete", + item: expect.objectContaining({ type: "plugin", kind: "provider-result" }), + }), + ); + const child = events.find( + (event): event is Extract => + event.type === "session.opened" && event.parentSessionId === "session-complete", + ); + expect(child?.sessionId).toMatch(/^[0-9a-f-]{36}$/u); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: child?.sessionId, + item: expect.objectContaining({ type: "plugin", kind: "provider-result" }), + }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: "session.persistence" })); + await connection.close(); + }); + + it("rejects calls after the example connection closes", async () => { + const connection = await createDirectExampleProvider().connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + + await connection.close(); + + await expect( + connection.send({ type: "catalog", requestId: "catalog-after-close" }), + ).rejects.toThrow("Provider connection is closed"); + await expect( + connection.send({ + type: "session.open", + requestId: "open-after-close", + sessionId: "closed-session", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false }, + history: "skip", + }), + ).rejects.toThrow("Provider connection is closed"); + }); +}); diff --git a/plugin-examples/provider-direct/server/provider.ts b/plugin-examples/provider-direct/server/provider.ts new file mode 100644 index 0000000000..6a8bc957cc --- /dev/null +++ b/plugin-examples/provider-direct/server/provider.ts @@ -0,0 +1,401 @@ +import { + negotiateProviderCapabilities, + requireProviderCapabilities, + type ProviderConfigState, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, + type ProviderPersistence, + type ProviderRegistration, + type ProviderSessionConfig, +} from "@getpaseo/plugin/provider"; +import { randomUUID } from "node:crypto"; +import { providerResultKind } from "../shared/provider-result.js"; + +const CAPABILITIES = [ + "prompt.message", + "prompt.command", + "prompt.steer", + "session.archive", + "session.configure", + "session.list", + "session.persistence", + "session.subsession", + "session.unarchive", + "timeline.plugin", +] as const; + +interface ExampleSession { + config: ProviderSessionConfig; + persistence: ProviderPersistence; + turn: number; + activeTurnId: string | null; +} + +export function createDirectExampleProvider(): ProviderRegistration { + return { + id: "direct-example", + label: "Direct provider example", + description: "A complete provider implemented directly against Paseo's provider boundary", + icon: "icon.svg", + async connect(request) { + if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required"); + return createConnection(negotiateProviderCapabilities(request.capabilities, CAPABILITIES)); + }, + }; +} + +function createConnection(capabilities: readonly string[]): ProviderConnection { + const listeners = new Set<(event: ProviderEvent) => void>(); + const sessions = new Map(); + const archived = new Set(); + let closed = false; + const emit = (event: ProviderEvent) => { + if (closed) return; + for (const listener of listeners) listener(event); + }; + + return { + version: 1, + capabilities, + async send(input) { + if (closed) throw new Error("Provider connection is closed"); + validateAdmission(input, { sessions, capabilities }); + queueMicrotask(() => { + if (!closed) dispatch(input, { sessions, archived, emit, capabilities }); + }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + if (closed) return; + closed = true; + sessions.clear(); + listeners.clear(); + }, + }; +} + +function validateAdmission( + input: ProviderInput, + state: Pick, +): void { + if (input.type === "session.open") { + if (state.sessions.has(input.sessionId)) { + throw new Error(`Session already exists: ${input.sessionId}`); + } + requireProviderCapabilities(state.capabilities, input); + return; + } + if (!("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + return; + } + const session = state.sessions.get(input.sessionId); + if (!session) throw new Error(`Unknown session: ${input.sessionId}`); + requireProviderCapabilities(state.capabilities, input); +} + +interface ExampleState { + sessions: Map; + archived: Set; + emit(event: ProviderEvent): void; + capabilities: readonly string[]; +} + +function dispatch(input: ProviderInput, state: ExampleState): void { + switch (input.type) { + case "catalog": + state.emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "example-1", label: "Example 1" }], + modes: [{ id: "build", label: "Build" }], + defaultModel: "example-1", + defaultMode: "build", + }, + }); + return; + case "sessions": + state.emit({ + type: "sessions", + requestId: input.requestId, + sessions: [...state.sessions.values()].map((session) => ({ + persistence: session.persistence, + cwd: session.config.cwd, + title: session.config.title, + })), + }); + return; + case "session.open": + openSession(input, state); + return; + case "session.prompt": + promptSession(input, state); + return; + case "session.configure": + configureSession(input, state); + return; + case "session.interrupt": + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.permission": + state.emit({ + type: "session.permission_resolved", + sessionId: input.sessionId, + permissionId: input.permissionId, + }); + return; + case "session.revert": + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.archive": + state.archived.add(persistenceKey(input.persistence)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.unarchive": + state.archived.delete(persistenceKey(input.persistence)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.close": + state.sessions.delete(input.sessionId); + state.emit({ type: "session.closed", sessionId: input.sessionId }); + state.emit({ type: "request.completed", requestId: input.requestId }); + } +} + +function openSession( + input: Extract, + state: ExampleState, +): void { + const persistence = input.persistence ?? { + version: 1, + data: { nativeSessionId: `example:${input.sessionId}` }, + }; + const session: ExampleSession = { + config: input.config, + persistence, + turn: 0, + activeTurnId: null, + }; + state.sessions.set(input.sessionId, session); + state.emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: state.capabilities, + restoration: "core", + persistence, + title: input.config.title, + cwd: input.config.cwd, + }); + state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) }); + state.emit({ + type: "session.commands", + sessionId: input.sessionId, + commands: [{ name: "reset", description: "Reset the example provider state" }], + }); + if (input.history === "replay" && input.persistence) { + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "replayed-1", text: "Restored from persistence" }, + }); + } + state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); +} + +function promptSession( + input: Extract, + state: ExampleState, +): void { + const session = requireSession(state, input.sessionId); + if (input.prompt.delivery === "steer") { + if (!session.activeTurnId) { + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: { message: "There is no active turn to steer" } }, + }); + return; + } + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "steer", turnId: session.activeTurnId }, + }); + return; + } + + if (input.prompt.input.type === "command") { + if (input.prompt.input.name !== "reset") { + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: { message: "Unknown command" } }, + }); + return; + } + session.turn = 0; + state.emit({ + type: "session.notice", + sessionId: input.sessionId, + notice: { id: "reset", severity: "info", title: "Example state reset" }, + }); + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "completed" }, + }); + return; + } + + session.turn += 1; + const turnId = `turn-${session.turn}`; + session.activeTurnId = turnId; + const text = input.prompt.input.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: `user-${session.turn}`, + text, + clientMessageId: input.prompt.clientMessageId, + }, + }); + state.emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: `assistant-${session.turn}`, text: `Echo: ${text}` }, + }); + state.emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "plugin", + id: `provider-result-${session.turn}`, + pluginId: "provider-direct-example", + kind: providerResultKind, + version: 1, + data: { label: "Provider result", detail: `Echoed turn ${session.turn}` }, + }, + }); + publishChild(input.sessionId, session.turn, state); + session.persistence = { + version: 1, + data: { nativeSessionId: `example:${input.sessionId}`, turns: session.turn }, + }; + state.emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: session.persistence, + }); + state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + session.activeTurnId = null; +} + +function publishChild(parentSessionId: string, turn: number, state: ExampleState): void { + const sessionId = randomUUID(); + state.emit({ + type: "session.opened", + sessionId, + parentSessionId, + capabilities: [], + restoration: "parent", + title: "Example child", + cwd: requireSession(state, parentSessionId).config.cwd, + }); + state.emit({ + type: "timeline.item", + sessionId, + item: { + type: "plugin", + id: `child-result-${turn}`, + pluginId: "provider-direct-example", + kind: providerResultKind, + version: 1, + data: { label: "Child result", detail: "Completed" }, + }, + }); + state.emit({ type: "session.ready", sessionId }); +} + +function configureSession( + input: Extract, + state: ExampleState, +): void { + const session = requireSession(state, input.sessionId); + const changes = input.changes; + session.config = { + ...session.config, + model: changes.model === null ? undefined : (changes.model ?? session.config.model), + mode: changes.mode === null ? undefined : (changes.mode ?? session.config.mode), + thinkingOption: + changes.thinkingOption === null + ? undefined + : (changes.thinkingOption ?? session.config.thinkingOption), + settings: changes.settings + ? { ...session.config.settings, ...changes.settings } + : session.config.settings, + }; + state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) }); + state.emit({ type: "request.completed", requestId: input.requestId }); +} + +function configState(session: ExampleSession): ProviderConfigState { + return { + model: session.config.model ?? "example-1", + mode: session.config.mode ?? "build", + thinkingOption: session.config.thinkingOption, + models: [{ id: "example-1", label: "Example 1" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "toggle", + id: "concise", + label: "Concise responses", + value: session.config.settings.concise === true, + }, + { + type: "select", + id: "voice", + label: "Voice", + value: + typeof session.config.settings.voice === "string" ? session.config.settings.voice : null, + options: [ + { label: "Calm", value: "calm" }, + { label: "Direct", value: "direct" }, + ], + }, + ], + }; +} + +function requireSession(state: ExampleState, sessionId: string): ExampleSession { + const session = state.sessions.get(sessionId); + if (!session) throw new Error(`Unknown session: ${sessionId}`); + return session; +} + +function persistenceKey(persistence: ProviderPersistence): string { + return JSON.stringify(persistence); +} diff --git a/plugin-examples/provider-direct/shared/provider-result.ts b/plugin-examples/provider-direct/shared/provider-result.ts new file mode 100644 index 0000000000..40ba0324bb --- /dev/null +++ b/plugin-examples/provider-direct/shared/provider-result.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const providerResultKind = "provider-result"; + +export const providerResultSchema = z.object({ + label: z.string(), + detail: z.string(), +}); diff --git a/public-docs/plugins/v0.8/index.md b/public-docs/plugins/v0.8/index.md index fe0915f0c2..7623a8b476 100644 --- a/public-docs/plugins/v0.8/index.md +++ b/public-docs/plugins/v0.8/index.md @@ -24,7 +24,8 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [timeline items](/docs/plugins/v0.8/reference#timeline-items), [themes](/docs/plugins/v0.8/reference#contribute-a-theme), [attachment sources](/docs/plugins/v0.8/reference#add-a-composer-attachment-source), and -[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). Client +[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). It can also +[connect a coding agent as a provider](/docs/plugins/v0.8/providers). Client contributions run on every Paseo client connected to that daemon, including mobile. This guide scaffolds a plugin, runs it, and adds a workspace panel to it. @@ -265,6 +266,8 @@ log. ## Next +- [Build a provider plugin](/docs/plugins/v0.8/providers): connect an agent directly or through ACP, + render provider-owned timeline items, test it, and publish it. - [Plugin reference](/docs/plugins/v0.8/reference): every contribution type, its fields, the runtime modules, hosts, and the CLI. - [Migrate a plugin to runtime entries](/docs/plugins/v0.8/migration): move a plugin written against the diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md new file mode 100644 index 0000000000..8b488def40 --- /dev/null +++ b/public-docs/plugins/v0.8/providers.md @@ -0,0 +1,407 @@ +--- +title: Build a provider plugin +description: Add a coding agent to Paseo directly or adapt an ACP agent. +nav: Provider plugins +order: 47 +category: Plugins +--- + +# Build a provider plugin + +> **For the upcoming Paseo v0.8 release.** Start with the +> [plugin quickstart](/docs/plugins/v0.8) if you have not built a Paseo plugin before. + +A provider plugin connects a coding agent to Paseo without adding it to Paseo core. Publish the +plugin in a Git repository and users can install and update it with `paseo plugin add` and +`paseo plugin update`. + +Choose one implementation path: + +| Agent | Path | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| It already implements ACP | Register it with `runAcpProvider()` and add only narrow vendor transformers. | +| It has a TypeScript SDK, JSON-RPC API, or custom process protocol | Implement `ProviderRegistration` directly. | + +The complete examples are: + +- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer; +- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command with a Zod-validated vendor edit transformer; +- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider. + +## Register a direct provider + +Add a server entry: + +```ts +// index.server.ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { createProvider } from "./server/provider"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(createProvider()); + return () => {}; +} +``` + +`createProvider()` returns one `ProviderRegistration`: + +```ts +import { + negotiateProviderCapabilities, + type ProviderRegistration, +} from "@getpaseo/plugin/provider"; + +const supported = ["prompt.message"] as const; + +export function createProvider(): ProviderRegistration { + return { + id: "my-agent", + label: "My agent", + icon: "icon.svg", + async connect(request) { + const capabilities = negotiateProviderCapabilities(request.capabilities, supported); + return createConnection(capabilities); + }, + }; +} +``` + +The connection has three operations: + +- `send(input)` accepts work and returns. Results are events, not return values. +- `onEvent(listener)` publishes session state and complete timeline snapshots. +- `close()` stops every session and releases processes, subscriptions, and pending work. + +Keep the native SDK, process, and stream inside the connection implementation. Convert its output +to `ProviderEvent` objects before publishing it. + +## Return models, modes, and thinking options + +Paseo requests the catalog before creating a session. Return the choices needed by the agent form: + +```ts +if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "agent-large", label: "Agent Large" }, + { id: "agent-small", label: "Agent Small" }, + ], + modes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + ], + thinkingOptions: [ + { id: "standard", label: "Standard" }, + { id: "extended", label: "Extended" }, + ], + defaultModel: "agent-large", + defaultMode: "build", + defaultThinkingOption: "standard", + }, + }); +} +``` + +Return an empty array for a category the agent does not expose. The selected `model`, `mode`, and +`thinkingOption` arrive in the session configuration. + +The catalog describes choices available before a session exists. Session-specific controls come +later through `session.config`. + +## Open a session + +`session.open` contains the complete launch configuration: working directory, environment, system +prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence +preference. + +Create the native session with the selected catalog values, then publish its effective state: + +```ts +const nativeSession = await sdk.createSession({ + cwd: input.config.cwd, + model: input.config.model, + mode: input.config.mode, + thinking: input.config.thinkingOption, + systemPrompt: input.config.systemPrompt, + env: input.config.env, + mcpServers: input.config.mcpServers, +}); + +emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: ["prompt.message"], + restoration: "core", + cwd: input.config.cwd, +}); +emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: nativeSession.model, + mode: nativeSession.mode, + thinkingOption: nativeSession.thinking, + models, + modes, + thinkingOptions, + settings: [], + }, +}); +emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); +``` + +`session.config` contains the committed values. Publish what the native agent selected after +normalization, not a copy of the request. + +## Complete the first prompt + +Start with message prompts. Advertise `prompt.message`, pass the content to the native agent, and +publish the resulting lifecycle: + +```ts +const turnId = crypto.randomUUID(); + +emit({ + type: "timeline.item", + sessionId, + item: { + type: "user_message", + id: `user:${turnId}`, + text, + clientMessageId: prompt.clientMessageId, + }, +}); +emit({ + type: "session.prompt_result", + sessionId, + clientMessageId: prompt.clientMessageId, + result: { type: "turn", turnId }, +}); +emit({ type: "session.turn", sessionId, turnId, state: "started" }); + +// Publish complete snapshots as native output changes. +emit({ + type: "timeline.item", + sessionId, + item: { type: "assistant_message", id: `assistant:${turnId}`, text: completeText }, +}); + +emit({ type: "session.turn", sessionId, turnId, state: "completed" }); +``` + +Publish exactly one `session.prompt_result` for each `clientMessageId`. Copy `clientMessageId` onto +the user timeline item so Paseo replaces its optimistic message. Every started turn needs one +terminal `completed`, `failed`, or `canceled` event. + +At this point the provider is usable: users can select a model, open a session, send a message, and +see the response. + +## Add session-specific composer controls + +Add `session.configure` only when the native agent has settings users should change after opening a +session. Publish them in `session.config` as toggle or select controls: + +```ts +emit({ + type: "session.config", + sessionId, + config: { + model: "agent-large", + models: [{ id: "agent-large", label: "Agent Large" }], + modes: [], + thinkingOptions: [], + settings: [ + { + type: "toggle", + id: "fast", + label: "Fast mode", + value: nativeSession.fast, + }, + { + type: "select", + id: "approval", + label: "Approvals", + value: nativeSession.approval, + options: [ + { label: "Ask", value: "ask" }, + { label: "Auto accept", value: "auto" }, + ], + }, + ], + }, +}); +``` + +Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it +and publish the committed `session.config` before `request.completed`. The provider may change the +available controls at runtime, including after a model change. + +`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation +for provider-specific configuration that Paseo does not render. A provider-specific token budget +belongs in one of those two places; it is not a dedicated prompt field. + +## Add persistence and replay + +Advertise `session.persistence` when a native session can be reopened. Open a new native session +when `session.open.persistence` is absent. Resume the identified native session when it is present. +Return an opaque persistence value in `session.opened` or `session.persistence`; Paseo stores it +without inspecting it. + +When `history` is `"replay"`, publish the native session's existing `timeline.item` snapshots before +`session.ready`. Use `history: "skip"` to open without replaying old rows. + +Paseo refreshes an agent by closing its current provider session and opening it again with current +configuration and persistence. Re-read credentials, environment, global configuration, and MCP +servers during `session.open`. There is no separate reload operation. + +Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child +uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and +recreate it while restoring the parent. + +## Add commands and steering + +All user input arrives through `session.prompt`: + +- `input.type: "message"` carries text, images, and attachments; +- `input.type: "command"` carries a selected command and its arguments; +- `delivery: "steer"` asks you to add input to the active turn; +- `delivery: "auto"` lets you start a turn or complete a command without one. + +Publish `session.commands` to add structured commands to the composer. A command can start a normal +turn or finish as a side effect with a `completed` prompt result. + +Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo +can replace the active turn instead. + +## Publish timeline items + +Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text, +a running tool, or a todo list. Paseo derives the live delta and keeps the normal timeline. + +Built-in item types cover user and assistant messages, reasoning, tools, todos, errors, +notifications, and compaction. Use a plugin item when your provider has a presentation that those +types cannot express: + +```ts +emit({ + type: "timeline.item", + sessionId, + item: { + type: "plugin", + id: "review-42", + pluginId: "my-provider-plugin", + kind: "review-verdict", + version: 1, + data: { verdict: "ship", summary: "All checks passed" }, + }, +}); +``` + +Register the renderer independently in `index.client.tsx`: + +```tsx +import type { PluginClientContext } from "@getpaseo/plugin"; +import { z } from "zod"; +import { ReviewVerdict } from "./client/review-verdict"; + +const reviewVerdictSchema = z.object({ + verdict: z.enum(["ship", "hold"]), + summary: z.string(), +}); + +export default function contribute(client: PluginClientContext) { + client.addTimelineRenderer({ + kind: "review-verdict", + version: 1, + schema: reviewVerdictSchema, + Component: ReviewVerdict, + }); + return () => {}; +} +``` + +The same renderer can display items emitted by a provider, a client timeline transformer, or a +daemon timeline append. A renderer does not require a provider implementation. + +## Adapt an ACP agent + +Use the ACP shim when the agent already speaks ACP: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin"; +import { runAcpProvider } from "@getpaseo/plugin/acp"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider( + runAcpProvider({ + id: "vendor-agent", + label: "Vendor agent", + icon: "icon.svg", + command: ["vendor-agent", "acp"], + }), + ); + return () => {}; +} +``` + +The shim owns the ACP process, capability mapping, session lifecycle, prompts, permissions, and +timeline conversion. Do not copy that machinery into the plugin. + +Use `transformers` only for vendor differences ACP cannot describe. Validate vendor payloads with +Zod and leave malformed or unrelated values unchanged: + +```ts +import type { AcpTransformer } from "@getpaseo/plugin/acp"; +import { z } from "zod"; + +const editSchema = z.object({ + path: z.string(), + before: z.string(), + after: z.string(), +}); + +export const vendorEdits: AcpTransformer = { + toolCall(toolCall) { + if (toolCall.name !== "vendor_file_edit") return toolCall; + const input = editSchema.safeParse(toolCall.input); + if (!input.success) return toolCall; + return { + ...toolCall, + kind: "edit", + input: { + filePath: input.data.path, + oldString: input.data.before, + newString: input.data.after, + }, + }; + }, +}; +``` + +## Test and publish + +Test the provider against the real agent, not only mocked frames: + +1. install the plugin as a directory source; +2. confirm its SVG, models, modes, and settings appear; +3. create a session and complete a real prompt; +4. exercise permissions, steering, cancellation, persistence, and replay when supported; +5. render every custom timeline item on desktop and mobile-width layouts; +6. reload and remove the plugin while a session is active and confirm the session terminates. + +Push the plugin to a Git repository. Users install it with: + +```bash +paseo plugin add owner/repository +paseo plugin update my-provider-plugin +``` + +Keep vendor compatibility and releases in that repository. Paseo core should only change when the +provider boundary cannot express a user-facing capability shared by more than one provider. + +See the [plugin reference](/docs/plugins/v0.8/reference#providers) for the exact runtime and SVG +rules. diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 139def7da2..6e3cd4f000 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -2,7 +2,7 @@ title: Plugin reference description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands. nav: Reference -order: 47 +order: 48 category: Plugins --- @@ -142,7 +142,42 @@ Center callbacks can only open surfaces and panels registered by the same plugin ### Server runtime -Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess. +Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, +`@getpaseo/plugin/provider`, `@getpaseo/plugin/acp`, and `zod` to server code. Backend +contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, +process, credential, and other machine-local work under `server/`. A plugin without +`index.server.ts` starts no subprocess. + +### Providers + +Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and ACP implementations, +session lifecycle, composer settings, timeline renderers, testing, and distribution. + +Call `server.registerProvider()` with a `ProviderRegistration` from +`@getpaseo/plugin/provider`. Its connection accepts inputs with `send()` and emits complete state +snapshots through `onEvent()`. `send()` reports acceptance only; prompt disposition, turns, +configuration, persistence, permissions, and failures are events. + +Use the single `session.prompt` input for messages, structured commands, steering, and command side +effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching +`session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`. + +Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep +provider-private JSON under `providerOptions`. Host tools arrive as MCP servers in the complete +session config. + +Paseo refreshes an agent by closing its current provider session and opening it with current +configuration and persistence. Providers re-read external state during `session.open`. + +Use `runAcpProvider()` from `@getpaseo/plugin/acp` to adapt a command-backed ACP. Add transformer +hooks only for a vendor's discovery, configuration, notification, or tool-call differences. + +`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`. +It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be +self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and +external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are +allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline +SVG or URL. ## Entry point and cleanup