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
This commit is contained in:
Mohamed Boudra
2026-09-04 19:56:57 +02:00
committed by GitHub
parent 140b0bb716
commit 35d2457d83
68 changed files with 9577 additions and 125 deletions
+46
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA=
+10
View File
@@ -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",
@@ -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) =>
+1 -1
View File
@@ -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"]) =>
@@ -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<FetchRecentProviderSessionEntry>;
disabled: boolean;
importingSessionKey: string | null;
@@ -381,6 +385,7 @@ function SessionRows({
{entries.map((entry) => (
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
@@ -552,11 +557,11 @@ export function ImportSessionSheet({
const map = new Map<string, React.ReactNode>();
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
for (const provider of filterProviders) {
const ProviderIcon = getProviderIcon(provider);
const ProviderIcon = getProviderIcon(provider, serverId);
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
}
return map;
}, [filterProviders, theme.colors.foregroundMuted]);
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
const renderFilterOption = useCallback(
({
@@ -736,7 +741,7 @@ export function ImportSessionSheet({
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
(() => {
const ProviderIcon = getProviderIcon(selectedProvider);
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
})()
)}
@@ -773,6 +778,7 @@ export function ImportSessionSheet({
) : null}
{visibleEntries.length > 0 ? (
<SessionRows
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
+42 -8
View File
@@ -154,6 +154,7 @@ interface ModelBrowserInput {
}
export interface ModelBrowserState {
serverId: string | null;
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
@@ -192,6 +193,7 @@ interface ModelBrowserProps {
}
interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> {
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 <Icon size={size} color={color} />;
@@ -330,7 +334,12 @@ export function useModelBrowser({
return {
title: view.providerLabel,
leading: (
<ModelProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />
<ModelProviderGlyph
provider={view.providerId}
serverId={serverId}
size={ICON_SIZE.md}
tone="foreground"
/>
),
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(
() => <ModelProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
[row.provider],
() => <ModelProviderGlyph provider={row.provider} serverId={serverId} size={ICON_SIZE.sm} />,
[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 (
<ModelRow
row={row}
serverId={serverId}
isSelected={isSelected}
showProviderLabel={showProviderLabel}
onPress={handlePress}
@@ -948,9 +963,11 @@ function AgentProfilesPickerContent({
function GroupProviderButton({
provider,
serverId,
onDrillDown,
}: {
provider: ProviderSelectorProvider;
serverId: string | null;
onDrillDown: (providerId: string, providerLabel: string) => void;
}) {
const { t } = useTranslation();
@@ -988,8 +1005,8 @@ function GroupProviderButton({
);
}, [selection, t]);
const leadingSlot = useMemo(
() => <ModelProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />,
[provider.id],
() => <ModelProviderGlyph provider={provider.id} serverId={serverId} size={ICON_SIZE.sm} />,
[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) => (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
<GroupProviderButton provider={provider} serverId={serverId} onDrillDown={onDrillDown} />
</View>
))}
</View>
@@ -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 }) => (
<SelectableModelRow
row={item}
serverId={serverId}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
showProviderLabel={showProviderLabel}
onSelect={onSelect}
@@ -1156,6 +1178,7 @@ function ModelRowList({
profiledLookup,
selectedModel,
selectedProvider,
serverId,
showProviderLabel,
],
);
@@ -1229,6 +1252,7 @@ function ModelSearchEmptyState() {
}
function ProviderModelBrowserContent({
serverId,
view,
provider,
profiles,
@@ -1246,6 +1270,7 @@ function ProviderModelBrowserContent({
isRetryingProvider,
scrolling,
}: {
serverId: string | null;
view: Extract<ModelBrowserView, { kind: "provider" }>;
provider: ProviderSelectorProvider | null;
profiles: AgentProfilePicker | null;
@@ -1318,6 +1343,7 @@ function ProviderModelBrowserContent({
}
return (
<ModelRowList
serverId={serverId}
rows={visibleRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
@@ -1333,6 +1359,7 @@ function ProviderModelBrowserContent({
}
function ModelBrowserContent({
serverId,
view,
providers,
selectedProvider,
@@ -1380,6 +1407,7 @@ function ModelBrowserContent({
if (view.kind === "provider") {
return (
<ProviderModelBrowserContent
serverId={serverId}
view={view}
provider={selectedViewProvider}
profiles={profiles}
@@ -1414,6 +1442,7 @@ function ModelBrowserContent({
if (allView.kind === "searchResults") {
return (
<ModelRowList
serverId={serverId}
rows={allView.rows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
@@ -1445,7 +1474,11 @@ function ModelBrowserContent({
<Text style={styles.sectionHeadingText}>{t("modelSelector.providers")}</Text>
</View>
) : null}
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
<GroupedProviderRows
providers={providers}
serverId={serverId}
onDrillDown={onDrillDown}
/>
</View>
) : null)}
{!hasResults ? <ModelSearchEmptyState /> : null}
@@ -1487,6 +1520,7 @@ export function ModelBrowser({
}: ModelBrowserProps) {
return (
<ModelBrowserContent
serverId={state.serverId}
view={state.view}
providers={state.providers}
selectedProvider={state.selectedProvider}
@@ -5,7 +5,7 @@ import {
TERMINAL_PROFILE_ICON_NAMES,
} from "@getpaseo/protocol/provider-icon-names";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "./provider-icon-name";
import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name";
describe("resolveProviderIconName", () => {
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 = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></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 = "<svg id='second' />";
replaceProviderSnapshotIcons("server-1", [
{ provider: "removed-provider", iconSvg: "<svg />" },
{ provider: "snapshot-provider", iconSvg: "<svg />" },
{ provider: "claude", iconSvg: "<svg />" },
]);
replaceProviderSnapshotIcons("server-2", [
{ provider: "snapshot-provider", iconSvg: secondSvg },
]);
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({
kind: "svg",
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", () => {
@@ -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<string, ReadonlyMap<string, string>>();
export function resolveProviderIconName(provider: string): ProviderIconName {
export function replaceProviderSnapshotIcons(
serverId: string,
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
): void {
const icons = new Map<string, string>();
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 };
}
@@ -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 = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></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);
});
});
+19 -7
View File
@@ -34,17 +34,18 @@ const CATALOG_ICON_SVGS = new Map(
);
const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
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;
}
@@ -661,8 +661,8 @@ function ScheduleTargetFields({
[],
);
const modelTriggerLeading = useMemo(
() => <ProviderGlyph provider={state.selectedProvider} />,
[state.selectedProvider],
() => <ProviderGlyph provider={state.selectedProvider} serverId={state.selectedServerId} />,
[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 <Icon size={16} color={styles.providerIcon.color} />;
}
@@ -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 <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
}
@@ -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({
>
<View style={styles.main}>
<View style={styles.leading}>
<ProviderGlyph provider={provider} />
<ProviderGlyph provider={provider} serverId={serverId} />
</View>
<View style={styles.textGroup}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
@@ -129,6 +129,7 @@ function SchedulesTableRow({
return (
<ScheduleRow
serverId={row.schedule.serverId}
schedule={schedule}
targetLabel={row.targetLabel}
provider={row.provider}
@@ -131,7 +131,7 @@ export function CompactModelSheet({
serverId,
});
const ProviderIcon =
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null;
const ModelIcon = ProviderIcon ?? Bot;
const rootHeader = useMemo(
() => ({
+2
View File
@@ -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,
@@ -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 = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></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([])] });
@@ -21,6 +21,7 @@ import {
providersSnapshotQueryRoot,
providersSnapshotRequestOptions,
} from "@/data/providers-snapshot";
import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name";
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
@@ -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,
+1 -1
View File
@@ -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 ?? "",
+1
View File
@@ -128,6 +128,7 @@ export const AgentTracks = memo(function AgentTracks({
<ComposerTrackBar>
<AgentTaskList tasks={tasks} />
<SubagentsTrack
serverId={serverId}
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
@@ -56,7 +56,7 @@ function useProviderSubagentDescriptor(
subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel,
tooltip: label,
titleState: descriptor ? "ready" : "loading",
icon: getProviderIcon(provider),
icon: getProviderIcon(provider, context.serverId),
statusBucket: descriptor
? deriveSidebarStateBucket({
status: providerSubagentLifecycleStatus(descriptor.status),
@@ -77,6 +77,7 @@ function getProviderStatus(
}
interface ProviderRowProps {
serverId: string;
def: ProviderDefinition;
entry: ProviderEntry;
enabled: boolean;
@@ -167,6 +168,7 @@ function ProviderActionsMenu({
}
function ProviderRow({
serverId,
def,
entry,
enabled,
@@ -181,7 +183,7 @@ function ProviderRow({
const { t } = useTranslation();
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const ProviderIcon = getProviderIcon(def.id);
const ProviderIcon = getProviderIcon(def.id, serverId);
const providerError =
enabled &&
entry.status === "error" &&
@@ -436,6 +438,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
return (
<ProviderRow
key={def.id}
serverId={serverId}
def={def}
entry={entry}
enabled={entry.enabled ?? true}
+8 -3
View File
@@ -30,6 +30,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
});
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({
<SubagentsTrackRow
key={row.id}
row={row}
serverId={serverId}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
@@ -164,6 +167,7 @@ function ArchiveFinishedRow({
}
interface SubagentsTrackRowProps {
serverId: string;
row: SubagentRow;
onOpenSubagent: (id: string) => 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(() => {
+1 -1
View File
@@ -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: ["*"] },
},
+11
View File
@@ -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": {
File diff suppressed because it is too large Load Diff
+985
View File
@@ -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<string> {
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<typeof runAcpProvider>[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<string, string> = {},
): Extract<ProviderInput, { type: "session.open" }> {
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<ProviderEvent> {
await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy();
return events.find(predicate)!;
}
type AcpRequestMessage = Extract<AcpStreamMessage, { id: string | number | null; method: string }>;
interface ConnectorInstance {
readonly requests: AcpRequestMessage[];
readsCanceled: boolean;
writesClosed: boolean;
closeFromAgent(): void;
request(method: string, params: unknown): Promise<unknown>;
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<string, unknown>;
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<AcpStreamMessage>;
let requestSequence = 0;
const responses = new Map<number, ConnectorResponse>();
const instance: ConnectorInstance = {
requests: [],
readsCanceled: false,
writesClosed: false,
closeFromAgent: () => controller.close(),
request: (method, params) => {
const response = new Promise<unknown>((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<AcpStreamMessage>({
start(value) {
controller = value;
},
cancel() {
instance.readsCanceled = true;
},
});
const writable = new WritableStream<AcpStreamMessage>({
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<ProviderEvent, { type: "request.completed" }>).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<Record<string, unknown>> | 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);
});
});
+122
View File
@@ -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<AcpStreamMessage>;
readable: ReadableStream<AcpStreamMessage>;
}
export type AcpConnector = () => AcpStream | Promise<AcpStream>;
export interface AcpOptions {
protocol?: "auto" | "v1";
startupTimeoutMs?: number;
waitForInitialCommands?: boolean;
initialCommandsTimeoutMs?: number;
}
export interface AcpContext {
sessionId: string;
}
export interface AcpConfigAccess {
read(): Promise<Readonly<Record<string, JsonValue>>>;
set(id: string, value: JsonValue): Promise<void>;
}
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> | 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);
},
};
}
+2
View File
@@ -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<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
registerProvider(provider: ProviderRegistration): void;
}
export type PluginCleanup = () => void | Promise<void>;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,6 +4,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023", "DOM"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
+1
View File
@@ -113,6 +113,7 @@ export interface ProviderSnapshotEntry {
fetchedAt?: string;
label?: string;
description?: string;
iconSvg?: string;
defaultModeId?: string | null;
}
+1
View File
@@ -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(),
});
@@ -18,6 +18,7 @@ function providerEntry(): ProviderSnapshotEntry {
provider: "pi",
status: "ready",
enabled: true,
iconSvg: '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>',
models: [
{
provider: "pi",
@@ -125,6 +125,7 @@ export interface ProviderSnapshotEntry {
fetchedAt?: string;
label?: string;
description?: string;
iconSvg?: string;
defaultModeId?: string | null;
}
@@ -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");
});
});
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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 = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
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") {
@@ -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<AgentProvider, ProviderDefinition>;
private providerClients: Record<AgentProvider, AgentClient>;
private readonly ownedClients = new Set<AgentClient>();
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<AgentProvider, AgentClient>;
this.providerClients = {
...this.extraClients,
...this.pluginProviders.clients(),
} as Record<AgentProvider, AgentClient>;
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<AgentProvider, AgentClient>;
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<AgentProvider, AgentClient>;
this.providerClients = {
...this.extraClients,
...this.pluginProviders.clients(),
} as Record<AgentProvider, AgentClient>;
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) => {
@@ -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));
}
`,
);
+9
View File
@@ -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();
@@ -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 = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
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);
+113 -10
View File
@@ -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<string> = new Set(BUILTIN_PROVIDER_IDS);
interface PluginRuntimePort {
catalog(): Array<{ id: string; clientBundle: string }>;
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void;
getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[];
connectProvider: PluginRuntime["connectProvider"];
validatePlugin?(path: string): Promise<void>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>;
@@ -49,6 +57,9 @@ export class PluginService {
private readonly logger: pino.Logger;
private readonly errors = new Map<string, string>();
private readonly listeners = new Set<(pluginId: string) => void>();
private readonly providers = new Map<string, ProviderRegistration>();
private readonly providerIdsByPlugin = new Map<string, readonly string[]>();
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<void> {
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<PluginListItem> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
this.removeProviderRegistrations(pluginId);
return this.runtime.stopPluginById(pluginId);
}
private async stopAll(): Promise<void> {
for (const pluginId of this.providerIdsByPlugin.keys()) {
this.removeProviderRegistrations(pluginId);
}
await this.runtime.stopAll();
}
private async publishProviderRegistrations(
pluginId: string,
pluginDirectory: string,
): Promise<void> {
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<ProviderRegistration> => ({
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<T>(operation: () => Promise<T>): Promise<T> {
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}`);
}
@@ -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<PluginProcessRequest> = 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<PluginProcessMessage> = 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(),
],
);
@@ -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<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const providerConnections = new Map<
string,
{ connection: ProviderConnection; unsubscribe: () => void }
>();
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
let cleanup: (() => void | Promise<void>) | 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): 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<PluginProcessRequest, { type: "provider.connect" }>,
): Promise<void> {
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<PluginProcessRequest, { type: "provider.send" }>,
): Promise<void> {
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<void> {
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<PluginProcessRequest, { type: "initia
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
evaluateBundle(message.bundle);
send({ type: "ready", methods: [...handlers.keys()].sort() });
send({
type: "ready",
methods: [...handlers.keys()].sort(),
providers: [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(providerMetadata),
});
}
async function shutdown(): Promise<void> {
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<void> {
} 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<void> {
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({
@@ -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<string> {
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 =
'<svg viewBox="0 0 24 24"><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></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", "<svg><script /></svg>", "script elements"],
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
["style", "<svg><style /></svg>", "style elements"],
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "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("<svg />");
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("<svg />");
const externalDirectory = await mkdtemp(path.join(tmpdir(), "paseo-external-icon-"));
directories.push(externalDirectory);
const externalIcon = path.join(externalDirectory, "external.svg");
await writeFile(externalIcon, "<svg />");
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("<svg />");
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(`<svg>${" ".repeat(64 * 1024)}</svg>`);
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
"file exceeds 64 KiB",
);
});
});
@@ -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<string> {
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*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
if (/<foreignObject(?:\s|>)/i.test(svg)) {
throw iconError(iconPath, "foreignObject elements are not allowed");
}
if (/<style(?:\s|>)/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}`);
}
@@ -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<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
@@ -18,7 +25,20 @@ async function createPlugin(id: string, source: string): Promise<string> {
return directory;
}
function createReloadChild(name: string, events: string[], methods: string[] = []) {
async function readTextIfPresent(filePath: string): Promise<string | null> {
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<string, Array<(message: never) => 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<NodeJS.Signals | undefined> = [];
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();
}
+496 -47
View File
@@ -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<string>;
providers: readonly PluginProviderMetadata[];
child: PluginChild | null;
outputCapture: PluginOutputCapture | null;
pending: Map<string, PendingInvocation>;
providerConnections: Map<string, RemoteProviderConnection>;
providerConnectionTombstones: Set<string>;
sessionSocket: PluginSessionSocket | null;
sessionClosed: Promise<void> | 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<string, PendingProviderSend>;
sessions: Map<string, readonly string[]>;
connected: boolean;
closed: boolean;
closedPromise: Promise<void>;
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): 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<void> {
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<ProviderConnection> {
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<ProviderConnection>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
});
const closed = new Promise<void>((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<PluginProcessMessage, { type: "ready" }>;
try {
methods = await new Promise<string[]>((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<Extract<PluginProcessMessage, { type: "ready" }>>(
(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<PluginProcessMessage, { type: "provider.connected" }>,
): 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<string>(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<void> {
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<void> {
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<string>(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<void> {
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<void> {
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<typeof setTimeout> | 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;
}
@@ -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.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h16M4 17h16" />
<path d="m8 3-4 4 4 4m8 2 4 4-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -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 () => {};
}
@@ -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."
}
@@ -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);
});
});
@@ -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,
},
};
},
};
+12
View File
@@ -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.
@@ -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<typeof providerResultSchema>;
export function ProviderResult({ item, theme }: PluginTimelineItemProps<ProviderResultData>) {
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 (
<View style={styles.card}>
<Text style={styles.label}>{item.data.label}</Text>
<Text style={styles.detail}>{item.data.detail}</Text>
</View>
);
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 5h14v14H5z" />
<path d="m9 9 6 6m0-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 227 B

@@ -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 () => {};
}
@@ -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 () => {};
}
@@ -0,0 +1,3 @@
{
"id": "provider-direct-example"
}
@@ -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<ProviderEvent, { type: "session.opened" }> =>
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");
});
});
@@ -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<string, ExampleSession>();
const archived = new Set<string>();
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<ExampleState, "sessions" | "capabilities">,
): 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<string, ExampleSession>;
archived: Set<string>;
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<ProviderInput, { type: "session.open" }>,
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<ProviderInput, { type: "session.prompt" }>,
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<ProviderInput, { type: "session.configure" }>,
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);
}
@@ -0,0 +1,8 @@
import { z } from "zod";
export const providerResultKind = "provider-result";
export const providerResultSchema = z.object({
label: z.string(),
detail: z.string(),
});
+4 -1
View File
@@ -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
+407
View File
@@ -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.
+37 -2
View File
@@ -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