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
@@ -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(),
});