Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 669ec700d8 test(speech): reproduce model installation without bzip2 2026-10-26 21:43:09 +00:00
41 changed files with 228 additions and 2574 deletions
+1 -8
View File
@@ -430,15 +430,8 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
-63
View File
@@ -1770,66 +1770,3 @@ test("qualified parents preserve foreign identity without resolving an equal loc
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
-11
View File
@@ -369,11 +369,6 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/**
* Reopens the provider session with current configuration, rerunning session-open hooks.
* Interrupts a running turn and returns the refreshed snapshot.
*/
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
@@ -986,12 +981,6 @@ function createAgentHandleFactory(
current = result?.agent ?? null;
return result;
},
reload: async (options) => {
await daemonClient.refreshAgent(id, options?.requestId);
const result = await daemonClient.fetchAgent({ agentId: id });
current = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
+1 -61
View File
@@ -92,12 +92,7 @@ function typeErrors(code: string): string[] {
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
});
it("classifies every published entry", () => {
@@ -214,58 +209,3 @@ describe("plugin panel SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
typeErrors(`
import { z } from "zod";
import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server";
const tool: AgentToolDefinition = defineAgentTool({
name: "create_issue",
description: "Create an issue",
input: z.object({ title: z.string(), count: z.string().transform(Number) }),
async execute(input, { agent, paseo, signal }) {
const count: number = input.count;
// @ts-expect-error input is inferred from the schema
input.missing;
return { text: agent.id + input.title, structuredContent: { count } };
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
const title: string | null = request.title;
// @ts-expect-error labels are read-only
request.labels.x = "changed";
if (request.labels.x !== "y") return;
// @ts-expect-error MCP servers use the same typed transport config as saved servers
request.mcpServers.invalid = { type: "http", url: 42 };
return { ...request, tools: [...request.tools, tool], mcpServers: {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
-39
View File
@@ -1,39 +0,0 @@
import type { PaseoApi } from "@getpaseo/client";
import type { z } from "zod";
import type { PluginHookAgent } from "./lifecycle.js";
export interface AgentToolContext {
agent: PluginHookAgent;
paseo: PaseoApi;
signal: AbortSignal;
}
export interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
export interface AgentToolDefinition {
name: string;
title?: string;
description: string;
input: z.ZodType;
execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise<AgentToolResult>;
}
export function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition {
if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) {
throw new Error(`Invalid agent tool name: ${tool.name}`);
}
return tool;
}
-3
View File
@@ -24,6 +24,3 @@ export type {
} from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
export { defineAgentTool } from "./agent-tools.js";
export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js";
-5
View File
@@ -1,4 +1,3 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
@@ -37,11 +36,7 @@ export interface PluginSessionOpenRequest {
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
export type PluginTurnOutcome =
+33 -182
View File
@@ -1,5 +1,3 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -85,11 +83,7 @@ import {
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import {
PASEO_MCP_SERVER_NAME,
stripInternalPaseoMcpServer,
withRuntimePaseoMcpServer,
} from "./runtime-mcp-config.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
@@ -339,7 +333,6 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -563,20 +556,18 @@ interface AgentMetadataPatch {
const SYSTEM_ERROR_PREFIX = "[System Error]";
function normalizePersistenceForConfig(
function attachPersistenceCwd(
handle: AgentPersistenceHandle | null,
config: AgentSessionConfig,
cwd: string,
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
return {
...handle,
metadata: {
...metadata,
cwd: config.cwd,
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
...handle.metadata,
cwd,
},
};
}
@@ -775,10 +766,6 @@ export class AgentManager {
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
@@ -796,7 +783,6 @@ export class AgentManager {
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.pluginAgentTools = options.pluginAgentTools;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
@@ -901,62 +887,6 @@ export class AgentManager {
this.paseoToolCatalogFactory = factory;
}
invalidatePluginSessions(): void {
this.sessionOpenGeneration++;
}
async reloadStalePluginSession(agentId: string): Promise<void> {
if (!this.needsPluginSessionReload(agentId)) return;
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
agent !== null &&
agent.lifecycle !== "closed" &&
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
!this.hasInFlightRun(agentId)
);
}
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
);
}
async executePluginTool(
tool: AttachedAgentTool,
agentId: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const agent = this.getAgent(agentId);
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
const record = await this.registry?.get(agentId);
return this.pluginAgentTools.callTool(
tool.pluginId,
tool.pluginInstanceId,
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
tool.name,
input,
signal,
);
}
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
return this.paseoToolPolicies.get(agentId);
}
@@ -1382,18 +1312,12 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{
reason: "create",
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
},
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
initialTitle: options.initialTitle,
@@ -1510,8 +1434,6 @@ export class AgentManager {
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1521,7 +1443,7 @@ export class AgentManager {
launchContext,
currentResumeOptions,
);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
persistence: handle,
@@ -1569,12 +1491,7 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "import",
purpose: "interactive",
workspaceId: input.workspaceId,
labels: input.labels ?? {},
},
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
@@ -1584,7 +1501,6 @@ export class AgentManager {
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
@@ -1670,20 +1586,14 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "refresh",
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
},
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
Object.keys(storedConfig.mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
throw new Error(`Provider '${provider}' does not support MCP servers`);
}
let session: AgentSession | undefined;
@@ -1702,7 +1612,7 @@ export class AgentManager {
session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig);
this.assertAcceptingAgentRegistrations();
if (rehydrateFromDisk) {
@@ -1740,7 +1650,6 @@ export class AgentManager {
throw error;
} finally {
if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
@@ -1948,7 +1857,6 @@ export class AgentManager {
} catch (error) {
persistError = error;
}
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -2928,10 +2836,7 @@ export class AgentManager {
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null);
if (persistenceHandle) {
mutableAgent.persistence = normalizePersistenceForConfig(
persistenceHandle,
mutableAgent.config,
);
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
}
this.logger.trace(
{
@@ -3875,23 +3780,16 @@ export class AgentManager {
private async requireExternalMcpSupport(
session: AgentSession,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
storedConfig: AgentSessionConfig,
): Promise<void> {
if (
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
Object.keys(storedConfig.mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true
) {
return;
}
await this.closeUnregisteredSession(session);
throw this.unsupportedMcpError(launchConfig, launchContext);
}
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
}
private async initializeAgentTimelineForRegister(params: {
@@ -3977,9 +3875,9 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null,
persistence: normalizePersistenceForConfig(
persistence: attachPersistenceCwd(
options?.persistence ?? session.describePersistence(),
config,
config.cwd,
),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null,
@@ -4042,7 +3940,6 @@ export class AgentManager {
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
@@ -4283,9 +4180,9 @@ export class AgentManager {
newInfo.modeId !== agent.runtimeInfo?.modeId;
agent.runtimeInfo = newInfo;
if (!agent.persistence && newInfo.sessionId) {
agent.persistence = normalizePersistenceForConfig(
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: newInfo.sessionId },
agent.config,
agent.cwd,
);
}
// Emit state if runtimeInfo changed so clients get the updated model
@@ -4643,9 +4540,9 @@ export class AgentManager {
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = normalizePersistenceForConfig(
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.config,
agent.cwd,
);
}
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
@@ -4719,7 +4616,7 @@ export class AgentManager {
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = normalizePersistenceForConfig(handle, agent.config);
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
}
}
@@ -5529,17 +5426,10 @@ export class AgentManager {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
if (this.pluginLifecycle) {
const request = {
const request: PluginSessionOpenRequest = {
agentId,
provider: client.provider,
cwd,
@@ -5547,49 +5437,29 @@ export class AgentManager {
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
tools: [],
mcpServers: {},
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env;
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
}
const context: AgentLaunchContext = {
agentId,
mcpServers,
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
const useNativeTools =
if (
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools;
if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
) {
try {
const catalog = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
}
this.sessionOpenGenerations.set(agentId, generation);
return context;
}
@@ -5597,26 +5467,7 @@ export class AgentManager {
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): AgentSessionConfig {
const savedServerNames = new Set(
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
);
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
const owner = launchContext.mcpServerOwners?.[name];
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
const existingOwner = savedServerNames.has(name)
? "agent saved configuration"
: "Paseo internal MCP server";
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
}
}
const merged =
Object.keys(launchContext.mcpServers ?? {}).length > 0
? {
...launchConfig,
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
}
: launchConfig;
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
@@ -15,7 +15,6 @@ export type AgentRunController = Pick<
| "getAgent"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
@@ -116,7 +115,6 @@ export async function startAgentRun(
return { disposition: "out_of_band" };
}
try {
await agentManager.reloadStalePluginSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
@@ -616,9 +616,6 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
agentId?: string;
env?: Record<string, string>;
/**
@@ -69,7 +69,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
getAgent: vi.fn(() => snapshot),
tryRunOutOfBand: vi.fn(() => false),
hasInFlightRun: vi.fn(() => false),
reloadStalePluginSession: async () => {},
streamAgent,
waitForAgentRunStart: vi.fn(async () => undefined),
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
@@ -270,8 +270,6 @@ function buildAgentManagerSpies() {
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
reloadStalePluginSession: async () => {},
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
@@ -619,10 +617,6 @@ class FakeBrowserToolsBroker {
}
class BoundaryAgentManagerFake {
public getPluginTools(): [] {
return [];
}
private readonly agent = createManagedAgent({
id: "agent-1",
cwd: REPO_CWD,
@@ -40,8 +40,6 @@ class FakePermissionAgentManager {
return undefined;
}
async reloadStalePluginSession(): Promise<void> {}
hasInFlightRun(): boolean {
return this.hasRunInFlight;
}
@@ -1321,28 +1321,3 @@ test("plugin without a runtime safety capability uses baseline collection withou
await rm(home, { recursive: true, force: true });
}
});
test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const storedConfig = { provider: harness.registration.id, cwd: "/project" };
const company = { type: "http", url: "https://tools.example.com/mcp" } as const;
try {
const imported = await client.importSession!(
{ providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" },
{ config: { ...storedConfig, mcpServers: { company } }, storedConfig },
);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.open",
config: expect.objectContaining({ mcpServers: { company } }),
}),
);
expect(imported.config).toEqual(storedConfig);
await imported.session.close();
} finally {
await registry.shutdown();
}
});
@@ -1052,7 +1052,7 @@ class PluginAgentClient implements AgentClient {
if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
return {
session,
config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
config: { ...context.config, provider: this.provider, cwd: input.cwd },
persistence,
timeline: session.timelineHistory(),
providerSubagentEvents: session.subagentHistory(),
@@ -1,6 +1,6 @@
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
export const PASEO_MCP_SERVER_NAME = "paseo";
const PASEO_MCP_SERVER_NAME = "paseo";
const PASEO_MCP_PATHNAME = "/mcp/agents";
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
@@ -1,4 +1,3 @@
import { registerPluginTools } from "./plugin-tools.js";
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import {
@@ -705,7 +704,6 @@ function createToolCatalog(
}
const localHostOperations = new Map<string, HostOperation>();
const tools = new Map<string, PaseoToolDefinition>();
const builtinNames = new Set<string>();
const hostTools = new Map([
["create_agent", "createAgent"],
["create_workspace", "createWorkspace"],
@@ -740,7 +738,6 @@ function createToolCatalog(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries.
handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>,
) => {
builtinNames.add(name);
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
return;
}
@@ -3275,14 +3272,6 @@ function createToolCatalog(
},
);
registerPluginTools({
agentManager,
callerAgentId,
scope,
tools,
builtinNames,
policy: options.paseoToolPolicy,
});
return toCatalog();
}
@@ -1,40 +0,0 @@
import { z } from "zod";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import type { AgentManager } from "../agent-manager.js";
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
import type { PaseoToolDefinition } from "./types.js";
import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js";
export function registerPluginTools(options: {
agentManager: AgentManager;
callerAgentId?: string;
scope: "agent" | "top-level";
tools: Map<string, PaseoToolDefinition>;
builtinNames: Set<string>;
policy?: ProviderPaseoToolsPolicy;
}): void {
const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options;
if (scope !== "agent" || !callerAgentId) return;
for (const tool of agentManager.getPluginTools(callerAgentId)) {
if (builtinNames.has(tool.name)) {
throw new Error(
`Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`,
);
}
if (!isPaseoToolEnabled(policy, tool.name)) continue;
tools.set(tool.name, {
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: z.fromJSONSchema(tool.inputSchema),
async handler(input, { signal }) {
const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal);
return addModelVisibleStructuredContent({
content: result.text === undefined ? [] : [{ type: "text", text: result.text }],
structuredContent: result.structuredContent,
isError: result.isError,
});
},
});
}
}
-7
View File
@@ -995,7 +995,6 @@ export async function createPaseoDaemon(
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime,
pluginAgentTools: pluginRuntime,
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
@@ -1016,10 +1015,6 @@ export async function createPaseoDaemon(
const unsubscribePluginProviders =
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
agentManager.invalidatePluginSessions();
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
@@ -1919,7 +1914,6 @@ export async function createPaseoDaemon(
localCredential = null;
await deleteLocalCredential(config.paseoHome);
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().catch(() => undefined);
await agentProviderRuntime.shutdown().catch(() => undefined);
@@ -1939,7 +1933,6 @@ export async function createPaseoDaemon(
// that is still open. Plugins themselves are stopped once every session
// they serve has been closed, further down.
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
@@ -4,29 +4,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => {
test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
const launches: AgentSessionConfig[] = [];
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.13.2",
agentClients: {
codex: createTestAgentClient("codex", {
supportsMcpServers: true,
beforeCreateSession: async (config) => {
launches.push(config);
},
}),
},
daemonVersion: "0.8.0",
agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) },
mcpEnabled: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
@@ -56,15 +44,9 @@ test("the configuration example saves Codex options and injects launch-only MCP
},
mcpServers: {
existing: { type: "http", url: "https://existing.example.com/mcp" },
company: { type: "http", url: "https://tools.example.com/mcp" },
},
});
expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({
existing: { type: "http", url: "https://existing.example.com/mcp" },
});
expect(launches[0].mcpServers).toEqual({
existing: { type: "http", url: "https://existing.example.com/mcp" },
company: { type: "http", url: "https://tools.example.com/mcp" },
});
await client.archiveAgent(agent.id);
} finally {
await client.close();
File diff suppressed because it is too large Load Diff
@@ -1,46 +0,0 @@
import { expect, test } from "vitest";
import { z } from "zod";
import { defineAgentTool } from "@getpaseo/plugin/server";
import { serializeAgentTool } from "./agent-tools.js";
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
test("agent tool schemas survive the process boundary and catalog round trip", () => {
const input = z
.object({
title: z.string().min(1).describe("Issue title"),
state: z.enum(["open", "closed"]),
tags: z.array(z.string()).optional(),
nested: z.object({ enabled: z.boolean() }),
})
.describe("Create an issue");
const tool = defineAgentTool({
name: "create_issue",
description: "Create",
input,
execute: () => ({ text: "done" }),
});
const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool)));
const restored = z.fromJSONSchema(metadata.inputSchema);
expect(
serializePaseoToolInputParameters({
...metadata,
inputSchema: restored,
handler: async () => ({ content: [] }),
}),
).toEqual({
...metadata.inputSchema,
additionalProperties: {},
properties: {
...metadata.inputSchema.properties,
nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} },
},
});
expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({
title: "Bug",
state: "open",
nested: { enabled: true },
});
expect(
restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success,
).toBe(false);
});
@@ -1,108 +0,0 @@
import { z } from "zod";
import type {
AgentToolDefinition,
AgentToolResult,
PluginHookContext,
PluginSessionOpenRequest,
PluginHookAgent,
} from "@getpaseo/plugin/server";
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
export const agentToolMetadataSchema = z
.object({
name: z
.string()
.regex(/^[a-z][a-z0-9_]*$/)
.max(64),
title: z.string().optional(),
description: z.string(),
inputSchema: z.record(z.string(), z.unknown()),
pluginId: z.string().optional(),
pluginInstanceId: z.string().optional(),
})
.strict();
export type AgentToolMetadata = z.infer<typeof agentToolMetadataSchema>;
export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string };
export type SessionOpenWireRequest = Omit<PluginSessionOpenRequest, "tools"> & {
tools: AgentToolMetadata[];
};
export interface PluginAgentToolsRuntime {
isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean;
detachTools(agentId: string): void;
callTool(
pluginId: string,
pluginInstanceId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult>;
}
export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata {
return agentToolMetadataSchema.parse({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }),
});
}
// This store lives only in the plugin process. Definitions and parsed values never cross IPC.
export class PluginAgentTools {
private readonly agents = new Map<string, AgentToolDefinition[]>();
private readonly calls = new Map<string, { agentId: string; controller: AbortController }>();
attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] {
const names = new Set<string>();
const metadata = tools.map((tool) => {
const serialized = serializeAgentTool(tool);
if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`);
names.add(tool.name);
return serialized;
});
this.agents.set(agentId, tools);
return metadata;
}
detach(agentId: string): void {
this.agents.delete(agentId);
for (const call of this.calls.values()) {
if (call.agentId === agentId) call.controller.abort();
}
}
cancel(requestId: string): void {
this.calls.get(requestId)?.controller.abort();
}
close(): void {
for (const call of this.calls.values()) call.controller.abort();
this.agents.clear();
}
async call(
requestId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
paseo: PluginHookContext["paseo"],
): Promise<AgentToolResult> {
const controller = new AbortController();
this.calls.set(requestId, { agentId: agent.id, controller });
try {
const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name);
if (!tool) throw new Error(`Agent tool not attached: ${name}`);
const parsed = await tool.input.parseAsync(input);
controller.signal.throwIfAborted();
const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal });
controller.signal.throwIfAborted();
return result;
} catch (error) {
return { isError: true, text: error instanceof Error ? error.message : String(error) };
} finally {
this.calls.delete(requestId);
}
}
}
@@ -1,4 +1,3 @@
import type { PluginAgentToolsRuntime } from "./agent-tools.js";
import type { PluginLifecycle } from "./lifecycle/index.js";
import path from "node:path";
import { stat, rm } from "node:fs/promises";
@@ -38,10 +37,6 @@ import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js";
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
interface PluginRuntimePort {
subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"];
isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"];
callTool?: PluginRuntime["callTool"];
detachTools?: PluginRuntime["detachTools"];
emit?: PluginLifecycle["emit"];
drainEvents?: PluginRuntime["drainEvents"];
before?: PluginLifecycle["before"];
@@ -136,34 +131,6 @@ export class PluginService {
this.runtime.emit?.(name, event);
};
subscribeSessionOpenChanges(listener: () => void): () => void {
return this.runtime.subscribeSessionOpenChanges?.(listener) ?? (() => {});
}
readonly isToolPluginAvailable: PluginAgentToolsRuntime["isToolPluginAvailable"] = (
pluginId,
pluginInstanceId,
) => {
return this.runtime.isToolPluginAvailable?.(pluginId, pluginInstanceId) ?? false;
};
readonly detachTools: PluginAgentToolsRuntime["detachTools"] = (agentId) => {
this.runtime.detachTools?.(agentId);
};
readonly callTool: PluginAgentToolsRuntime["callTool"] = async (
pluginId,
pluginInstanceId,
agent,
name,
input,
signal,
) => {
if (!this.isToolPluginAvailable(pluginId, pluginInstanceId) || !this.runtime.callTool)
return { isError: true, text: `Plugin is not available: ${pluginId}` };
return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal);
};
async drainEvents(): Promise<void> {
await this.runtime.drainEvents?.();
}
@@ -93,41 +93,8 @@ test("session-open hooks reject changes to session identity instead of silently
reason: "resume",
purpose: "interactive",
env: {},
title: null,
labels: {},
tools: [],
mcpServers: {},
},
paseo,
),
).rejects.toThrow("agent.session_open hooks can only change env");
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
const hooks = new PluginHookHandlers(() => {});
hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch }));
await expect(
hooks.invoke(
"operation",
"before",
"agent.session_open",
{
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
title: "Original",
labels: {},
tools: [],
mcpServers: {},
env: {},
reason: "create",
purpose: "interactive",
},
paseo,
),
).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers");
},
);
@@ -1,11 +1,3 @@
import { isDeepStrictEqual } from "node:util";
import {
serializeAgentTool,
agentToolMetadataSchema,
type SessionOpenWireRequest,
type PluginAgentTools,
} from "../agent-tools.js";
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
import { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
@@ -23,15 +15,6 @@ import type {
import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session_open"> & {
"agent.session_open": SessionOpenWireRequest;
};
/** Daemon-only results; ownership never crosses the plugin process boundary. */
export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
};
export const lifecycleEventNames = [
"agent.created",
"agent.closed",
@@ -55,45 +38,28 @@ const beforeSchemas = {
cwd: z.string(),
reason: z.enum(["create", "resume", "refresh", "import"]),
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
})
.strict(),
"workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
};
const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({
tools: z.array(
z.custom<AgentToolDefinition>(
(tool) =>
typeof tool === "object" &&
tool !== null &&
"input" in tool &&
"execute" in tool &&
typeof tool.execute === "function",
),
),
});
export interface PluginLifecycle {
emit<Name extends keyof PluginLifecycleEvents>(
name: Name,
event: PluginLifecycleEvents[Name],
): void;
before<Name extends keyof PluginWireBeforeRequests>(
before<Name extends keyof PluginBeforeRequests>(
name: Name,
request: PluginWireBeforeRequests[Name],
): Promise<PluginBeforeResults[Name]>;
request: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]>;
}
export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
export function validateBeforeRequest<Name extends keyof PluginBeforeRequests>(
name: Name,
value: unknown,
): PluginWireBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name];
): PluginBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name];
}
export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace {
@@ -163,11 +129,11 @@ export function publishAgentStream(
}
}
export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
export function validateBeforeResult<Name extends keyof PluginBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
input: PluginBeforeRequests[Name],
output: unknown,
): PluginWireBeforeRequests[Name] {
): PluginBeforeRequests[Name] {
const result = validateBeforeRequest(name, output);
if (name === "agent.session_open") {
const previous = beforeSchemas["agent.session_open"].parse(input);
@@ -178,11 +144,9 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels)
previous.purpose !== next.purpose
) {
throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
throw new Error("agent.session_open hooks can only change env");
}
}
if (name === "agent.create") {
@@ -204,10 +168,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
private readonly changed: () => void;
private readonly agentTools: PluginAgentTools | undefined;
constructor(changed: () => void, agentTools?: PluginAgentTools) {
this.agentTools = agentTools;
constructor(changed: () => void) {
this.changed = changed;
}
@@ -248,9 +209,6 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
throw new Error(`Unknown before hook: ${name}`);
}
const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests;
if (hookName === "agent.session_open") {
return await this.openSession(input, { paseo, signal: controller.signal });
}
let request = validateBeforeRequest(hookName, input);
for (const handler of this.transforms.get(name) ?? []) {
controller.signal.throwIfAborted();
@@ -258,7 +216,9 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
{ request: structuredClone(request) },
{ paseo, signal: controller.signal },
);
if (result !== undefined) request = validateBeforeResult(hookName, request, result);
if (result !== undefined) {
request = validateBeforeResult(hookName, request, result);
}
}
return request;
}
@@ -276,33 +236,6 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
}
}
private async openSession(
input: unknown,
context: PluginHookContext,
): Promise<SessionOpenWireRequest> {
let request = validateBeforeRequest("agent.session_open", input);
this.agentTools?.detach(request.agentId);
let tools: AgentToolDefinition[] = [];
for (const handler of this.transforms.get("agent.session_open") ?? []) {
context.signal.throwIfAborted();
const result = await handler(
{ request: { ...structuredClone(request), tools: [...tools] } },
context,
);
if (result === undefined) continue;
const local = sessionOpenLocalResultSchema.parse(result);
const metadata = local.tools.map(serializeAgentTool);
request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata });
tools = local.tools;
}
context.signal.throwIfAborted();
if (request.purpose === "history") {
request.tools = [];
request.mcpServers = {};
} else this.agentTools?.attach(request.agentId, tools);
return request;
}
cancel(id: string): void {
this.active.get(id)?.abort();
}
@@ -1,4 +1,3 @@
import type { PluginHookAgent } from "@getpaseo/plugin/server";
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
import type {
ProviderConnectRequest,
@@ -32,9 +31,6 @@ export interface PluginUsageSourceMetadata {
}
export type PluginProcessRequest =
| { type: "tool.call"; requestId: string; agent: PluginHookAgent; name: string; input: unknown }
| { type: "tool.cancel"; requestId: string }
| { type: "tools.detach"; agentId: string }
| {
type: "provider.status";
requestId: string;
@@ -145,26 +141,6 @@ const frameFields = {
export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion(
"type",
[
z
.object({
type: z.literal("tool.call"),
requestId: z.string(),
agent: z
.object({
id: z.string(),
workspaceId: z.string().nullable(),
parentAgentId: z.string().nullable(),
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
})
.strict(),
name: z.string(),
input: z.unknown(),
})
.strict(),
z.object({ type: z.literal("tool.cancel"), requestId: z.string() }).strict(),
z.object({ type: z.literal("tools.detach"), agentId: z.string() }).strict(),
z
.object({
type: z.literal("provider.status"),
@@ -1,4 +1,3 @@
import { PluginAgentTools } from "./agent-tools.js";
import { PluginHookHandlers } from "./lifecycle/index.js";
import { evaluateBundle } from "./bundle-evaluator.js";
import {
@@ -60,10 +59,9 @@ export function createPluginWorker(options: {
handler: RpcHandler;
}
const agentTools = new PluginAgentTools();
const hooks = new PluginHookHandlers(() => {
send({ type: "hooks.changed", hooks: hooks.catalog() });
}, agentTools);
});
const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const usageSources = new Map<string, UsageSourceRegistration>();
@@ -324,7 +322,6 @@ export function createPluginWorker(options: {
?.dispose()
.catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close();
agentTools.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
cleanup = null;
@@ -384,42 +381,6 @@ export function createPluginWorker(options: {
}
}
function handleToolMessage(
message: PluginProcessRequest,
): message is Extract<
PluginProcessRequest,
{ type: "tool.call" | "tool.cancel" | "tools.detach" }
> {
if (message.type === "tools.detach") {
agentTools.detach(message.agentId);
return true;
}
if (message.type === "tool.cancel") {
agentTools.cancel(message.requestId);
return true;
}
if (message.type === "tool.call") {
if (!paseo) {
send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" });
return true;
}
void agentTools
.call(message.requestId, message.agent, message.name, message.input, paseo)
.then((output) =>
send({
type: "result",
requestId: message.requestId,
output: jsonTransportValue(output),
}),
)
.catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return true;
}
return false;
}
function handleMessage(rawMessage: unknown): void {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) {
@@ -461,7 +422,6 @@ export function createPluginWorker(options: {
rejectWhileStopping(message);
return;
}
if (handleToolMessage(message)) return;
if (message.type === "provider.status") {
void (async () => {
const provider = providers.get(message.providerId);
+12 -158
View File
@@ -1,16 +1,10 @@
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage";
import {
ProviderStatusSchema,
type ProviderStatus,
type ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider";
import type {
PluginLifecycleEvents,
PluginHookAgent,
AgentToolResult,
} from "@getpaseo/plugin/server";
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
@@ -46,7 +40,6 @@ import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000;
const TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
const MAX_LOG_LINE_BYTES = 16 * 1024;
@@ -76,7 +69,6 @@ interface PendingInvocation {
interface LoadedPlugin {
id: string;
instanceId: string;
requirements: PluginRequirements | undefined;
clientBundle: string;
methods: ReadonlySet<string>;
@@ -310,7 +302,6 @@ export class PluginRuntime {
private readonly spawnChild: () => PluginChild;
private sessionHost: PluginPaseoSessionHost | null;
private readonly listeners = new Set<(pluginId: string, error?: string) => void>();
private readonly sessionOpenListeners = new Set<() => void>();
constructor(
logger: pino.Logger,
@@ -333,16 +324,6 @@ export class PluginRuntime {
return () => this.listeners.delete(listener);
}
subscribeSessionOpenChanges(listener: () => void): () => void {
this.sessionOpenListeners.add(listener);
return () => this.sessionOpenListeners.delete(listener);
}
private notifySessionOpenChange(loaded: LoadedPlugin): void {
if (!loaded.hooks.before.includes("agent.session_open")) return;
for (const listener of this.sessionOpenListeners) listener();
}
async startPlugin(
pluginId: string,
configuredPath: string,
@@ -359,7 +340,6 @@ export class PluginRuntime {
throw new Error(`Plugin start cancelled: ${pluginId}`);
}
this.plugins.set(pluginId, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
}
@@ -383,7 +363,6 @@ export class PluginRuntime {
clientBundle: bundles.clientBundle ?? "",
});
this.plugins.set(input.id, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(input.id, "stdout", "[paseo] Plugin ready");
}
@@ -399,7 +378,6 @@ export class PluginRuntime {
const loaded = this.plugins.get(pluginId);
if (!loaded) return false;
this.plugins.delete(pluginId);
this.notifySessionOpenChange(loaded);
this.rejectPending(loaded, `Plugin stopped: ${pluginId}`);
await this.stopPlugin(loaded);
return true;
@@ -568,17 +546,14 @@ export class PluginRuntime {
await Promise.all(this.pendingEvents);
}
async before<Name extends keyof PluginWireBeforeRequests>(
async before<Name extends keyof PluginBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
): Promise<PluginBeforeResults[Name]> {
input: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]> {
let request = validateBeforeRequest(name, input);
const plugins = [...this.plugins.values()].sort((left, right) => {
return left.id.localeCompare(right.id);
});
const attached: AttachedAgentTool[] = [];
const mcpServers: SessionOpenWireRequest["mcpServers"] = {};
const mcpServerOwners: Record<string, string> = {};
for (const loaded of plugins) {
if (!loaded.hooks.before.includes(name)) {
continue;
@@ -589,31 +564,15 @@ export class PluginRuntime {
requestId: randomUUID(),
kind: "before",
name,
input:
name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request,
input: request,
});
request = validateBeforeResult(name, request, output);
if (name === "agent.session_open") {
const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
this.collectAgentTools(attached, loaded, opened.tools);
}
} catch (error) {
if (name === "agent.session_open")
this.detachTools((request as SessionOpenWireRequest).agentId);
throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, {
cause: error,
});
}
}
if (name === "agent.session_open") {
return {
...request,
tools: attached,
mcpServers,
mcpServerOwners,
} as PluginBeforeResults[Name];
}
return request;
}
@@ -635,119 +594,25 @@ export class PluginRuntime {
return output;
}
private collectMcpServers(
servers: SessionOpenWireRequest["mcpServers"],
owners: Record<string, string>,
loaded: LoadedPlugin,
contributions: SessionOpenWireRequest["mcpServers"],
): void {
for (const [name, server] of Object.entries(contributions)) {
if (Object.hasOwn(servers, name)) {
throw new Error(
`MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`,
);
}
servers[name] = server;
owners[name] = loaded.id;
}
}
private collectAgentTools(
attached: AttachedAgentTool[],
loaded: LoadedPlugin,
tools: SessionOpenWireRequest["tools"],
): void {
for (const tool of tools) {
const collision = attached.find((entry) => entry.name === tool.name);
if (collision)
throw new Error(
`Agent tool ${tool.name} from plugin ${loaded.id} collides with plugin ${collision.pluginId}`,
);
attached.push({ ...tool, pluginId: loaded.id, pluginInstanceId: loaded.instanceId });
}
}
isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean {
return this.plugins.get(pluginId)?.instanceId === pluginInstanceId;
}
detachTools(agentId: string): void {
for (const loaded of this.plugins.values()) {
if (loaded.child) void send(loaded.child, { type: "tools.detach", agentId }).catch(() => {});
}
}
async callTool(
pluginId: string,
pluginInstanceId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const loaded = this.plugins.get(pluginId);
if (!loaded || loaded.instanceId !== pluginInstanceId)
return { isError: true, text: `Plugin is not available: ${pluginId}` };
const requestId = randomUUID();
try {
return (await this.request(
loaded,
{ type: "tool.call", requestId, agent, name, input },
signal,
)) as AgentToolResult;
} catch (error) {
if (signal?.aborted) throw error;
return { isError: true, text: describeError(error) };
}
}
private request(
loaded: LoadedPlugin,
message: Extract<PluginProcessRequest, { requestId: string }>,
signal?: AbortSignal,
): Promise<unknown> {
const child = loaded.child;
const pluginId = loaded.id;
if (!child) throw new Error(`Plugin ${pluginId} has no server entry`);
const requestId = message.requestId;
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const cancel = () => {
clearTimeout(timeout);
const timeout = setTimeout(() => {
loaded.pending.delete(requestId);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
signal?.removeEventListener("abort", cancel);
reject(signal?.reason ?? new Error("Tool call canceled"));
};
const timeout = setTimeout(
() => {
loaded.pending.delete(requestId);
signal?.removeEventListener("abort", cancel);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
},
message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
);
signal?.addEventListener("abort", cancel, { once: true });
loaded.pending.set(requestId, {
resolve: (value) => {
signal?.removeEventListener("abort", cancel);
resolve(value);
},
reject: (error) => {
signal?.removeEventListener("abort", cancel);
reject(error);
},
timeout,
});
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
void send(child, message).catch((error) => {
clearTimeout(timeout);
signal?.removeEventListener("abort", cancel);
loaded.pending.delete(requestId);
reject(error);
});
@@ -758,7 +623,6 @@ export class PluginRuntime {
const loaded = [...this.plugins.values()];
this.plugins.clear();
for (const plugin of loaded) {
this.notifySessionOpenChange(plugin);
this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`);
}
await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin)));
@@ -777,7 +641,6 @@ export class PluginRuntime {
if (!serverBundle) {
return {
id: pluginId,
instanceId: randomUUID(),
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(),
@@ -906,7 +769,6 @@ export class PluginRuntime {
}
loaded = {
id: pluginId,
instanceId: randomUUID(),
clientBundle,
requirements,
methods: new Set(ready.methods),
@@ -990,14 +852,7 @@ export class PluginRuntime {
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "hooks.changed") {
const hadSessionOpen = loaded.hooks.before.includes("agent.session_open");
loaded.hooks = message.hooks;
if (
this.plugins.get(loaded.id) === loaded &&
hadSessionOpen !== loaded.hooks.before.includes("agent.session_open")
) {
for (const listener of this.sessionOpenListeners) listener();
}
return;
}
if (message.type === "settings.changed") {
@@ -1311,7 +1166,6 @@ export class PluginRuntime {
const wasPublished = this.plugins.get(loaded.id) === loaded;
if (wasPublished) {
this.plugins.delete(loaded.id);
this.notifySessionOpenChange(loaded);
}
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
for (const state of loaded.providerConnections.values()) {
@@ -226,7 +226,6 @@ type ScheduleAgentManager = Pick<
AgentRunController,
| "getAgent"
| "reloadAgentSession"
| "reloadStalePluginSession"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "replaceAgentRun"
@@ -8,6 +8,7 @@ import {
existsSync,
truncateSync,
readdirSync,
symlinkSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
@@ -23,7 +24,71 @@ function makeTmpDir(): string {
const logger = pino({ level: "silent" });
// A tar.bz2 containing the three Kokoro files ("complete model data") and espeak-ng-data/.
// Generated with Python tarfile + bz2, so testing extraction needs no system compressor.
const compressedModelArchive = Buffer.from(
"QlpoOTFBWSZTWYGlJLIAAOjfgcqBQAP/ICEAAAD+r99AABAICDAA1mQkopo0P1JkeoMTBDE9RgyiADQZAAAACSVPTU9TT1B6g0aGJ6hkyafQaedKK3SkotKCAUvkgkMWhnICQFABoykPAQBIYAZHteVO8PnEPulpWBWogKIMAi5XOBFWQPCYTsU1U7snVwYKXg/axQ0QgDBKDgsClwZB7z0TLGwJV+3hIFscrlfwje/SLh4QDkqFxkXTaE+x7Zh/DDrAILu8MdygDAzOzD88IBQpa8wkDqd3cEH8XckU4UJCBpSSyA==",
"base64",
);
describe("sherpa model downloader", () => {
test.skipIf(process.platform !== "linux")(
"installs a cached bzip2 model without a system bzip2 executable",
async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
const archive = path.join(
modelsDir,
".downloads",
path.basename(new URL(spec.archiveUrl).pathname),
);
const originalPath = process.env.PATH;
try {
const binDir = path.join(root, "bin");
mkdirSync(binDir);
symlinkSync(
execFileSync("which", ["tar"], { encoding: "utf8" }).trim(),
path.join(binDir, "tar"),
);
mkdirSync(path.dirname(archive), { recursive: true });
writeFileSync(archive, compressedModelArchive);
// GNU tar is available, but the bzip2 executable it invokes is not.
process.env.PATH = binDir;
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
expect(existsSync(path.join(modelDir, "espeak-ng-data"))).toBe(true);
expect(existsSync(archive)).toBe(false);
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
rmSync(root, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform !== "linux")(
"installs the same cached bzip2 archive when system tools are available",
async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
const archive = path.join(
modelsDir,
".downloads",
path.basename(new URL(spec.archiveUrl).pathname),
);
try {
mkdirSync(path.dirname(archive), { recursive: true });
writeFileSync(archive, compressedModelArchive);
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
expect(existsSync(archive)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
test("does not expose a partial model when tar extraction fails", async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");
@@ -60,8 +60,6 @@ interface FakeAgentSessionOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -70,8 +68,6 @@ export interface TestAgentClientOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -344,8 +340,6 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
private readonly onInterrupt: (() => void) | undefined;
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
@@ -360,8 +354,6 @@ class FakeAgentSession implements AgentSession {
this.config = options.config;
this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null;
this.steerActiveTurn = options.steerActiveTurn;
this.onInterrupt = options.onInterrupt;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
@@ -904,7 +896,6 @@ class FakeAgentSession implements AgentSession {
}
async interrupt(): Promise<void> {
this.onInterrupt?.();
this.interruptSignal.resolve();
}
@@ -1243,8 +1234,6 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents,
});
}
@@ -1272,8 +1261,6 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents,
});
}
+11 -18
View File
@@ -1,19 +1,14 @@
# Agent configuration
Sets saved Codex options through `server.before("agent.create", ...)`:
Changes new Codex agents through `server.before("agent.create", ...)`:
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
- Sets `config.providerOptions.approval_policy` to `on-request`.
- Preserves other configuration and provider options.
Adds the `company` MCP server to Codex launches through
`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration;
existing saved MCP servers are preserved. Existing agents receive the server through the
[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents).
- Adds the `company` server to `config.mcpServers`.
- Preserves other configuration, provider options, MCP servers, and environment overrides.
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
this example. A `company` name collision with a saved server or another
plugin fails the session open and names both sources.
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name.
Install on a daemon with plugins enabled:
@@ -21,13 +16,11 @@ Install on a daemon with plugins enabled:
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
```
Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve
entries added by this plugin's earlier callbacks. The create callback receives configuration after
earlier plugins have transformed it; its explicit Codex options replace the named fields in the
saved configuration of new agents and remain after this plugin is disabled.
The callback receives the request after earlier plugins have transformed it. The nested spreads
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves
the resulting configuration before starting the agent. Existing agents are unchanged.
See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing
between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions)
demonstrates provider switching and environment injection. Its provider-switch callback clears
provider options when changing Codex to Claude, so use these examples separately when trying the
Codex-specific settings.
See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and
environment injection. Its provider-switch callback clears provider options when changing Codex to
Claude, so use these examples separately when trying the Codex-specific settings.
@@ -15,22 +15,12 @@ export default function contribute(server: PluginServerContext) {
sandbox_mode: "workspace-write",
approval_policy: "on-request",
},
},
};
});
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") {
return;
}
return {
...request,
mcpServers: {
...request.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
},
};
@@ -1,6 +1,6 @@
{
"id": "agent-configuration",
"requirements": {
"paseo": ">=0.13.2"
"paseo": ">=0.8.0"
}
}
-33
View File
@@ -1,33 +0,0 @@
# Agent tools
Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`.
Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that
line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path.
It creates the file if it does not exist.
Install on a daemon with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/agent-tools
```
Create a labeled agent through the SDK:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
The session-open callback also runs when an agent resumes or reloads. After changing an existing
agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This
interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session.
From the repository root, `npm run typecheck` includes this example. Outside this repository,
use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts`
and the manifest into that project.
See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents)
for targeting, execution, provider gates, collisions, and plugin reload behavior.
@@ -1,26 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
@@ -1,4 +0,0 @@
{
"id": "agent-tools",
"requirements": { "paseo": ">=0.13.2" }
}
+27 -179
View File
@@ -653,14 +653,9 @@ export default function contribute(server: PluginServerContext) {
Hooks run on the daemon while the plugin is enabled, even with no app connected.
`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain
after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`,
and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's
lifecycle without being saved in the agent record.
### Change configuration and inject an MCP server
### Change saved configuration
Add this callback inside `contribute(server)` to set the saved options for new Codex agents:
Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint.
```ts
server.before("agent.create", ({ request }) => {
@@ -677,50 +672,27 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write",
approval_policy: "on-request",
},
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
},
};
});
```
| Input | Result |
| ------------------------------------------- | ----------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | Preserved by the spread |
| Input | Result |
| ------------------------------------------- | ----------------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | Preserved by the spread |
| Existing `mcpServers.search` | Preserved by the spread |
| Existing `mcpServers.company` | Replaced with the entry above |
The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options
override its mode presets.
### Inject MCP servers
Use `agent.create.config.mcpServers` when the server should become part of the agent's saved
configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing
the placeholder URL with your MCP endpoint:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`.
The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never
contains the agent's saved servers or another plugin's servers. Replace the map to add, replace,
or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins'
final maps in plugin-ID order.
A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server
fails the session open and names the server and both sources. A provider without MCP support fails
with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore
these servers. Install, disable, and reload changes reach existing agents through the
[session reopen behavior](#attach-tools-to-agents).
The selected provider validates `providerOptions` and must support the configured MCP servers.
Explicit Codex sandbox and approval options override its mode presets.
### Inject environment variables on every session opening
@@ -736,122 +708,8 @@ server.before("agent.session_open", ({ request }) => {
});
```
Session-open overrides are re-derived on every open. `agent.create.env` applies only to the
initial launch and is not saved in the agent's configuration.
### Attach tools to agents
Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`.
This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`:
```ts
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
```
Set the label when creating an agent with the SDK:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
Select agents using any of the session-open request fields:
| Select by | Condition in the callback |
| ---------------- | -------------------------------------------- |
| Label | `request.labels["notes/enabled"] === "true"` |
| Provider | `request.provider === "codex"` |
| Workspace | `request.workspaceId === "workspace-456"` |
| Individual agent | `request.agentId === chosenAgentId` |
`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier
callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves
the request. Tools are ignored for `purpose: "history"`.
Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from
`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema:
```ts
declare function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition;
interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
```
| Execution context | Value |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` |
| `paseo` | SDK connected to this daemon |
| `signal` | Abort signal for this tool call; pass it to file operations or external requests |
`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool
metadata and call inputs/results cross the process boundary. Paseo validates input before calling
`execute`. Invalid input returns schema issues to the agent. A thrown error becomes
`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its
transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate
from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions.
Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is
omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error.
Paseo delivers attached tools through its agent tool transport, using MCP or native registration
as the provider supports. The same definition and callback work with either. Attached tools obey
the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them.
Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo
tool or another plugin's tool on the same agent fails the session open and names both owners.
A duplicate name within one plugin also fails the session open. Agents the callback does not
target are unaffected.
| Change | When the agent receives it |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. |
| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. |
| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. |
| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. |
If an automatic reopen fails, the triggering prompt fails with that error. See the
[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools).
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an
`agent.create` callback instead.
### Choose workspace isolation
@@ -1020,11 +878,11 @@ type PluginTurnOutcome =
### Before hooks
| Name | Request fields | Editable |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
| Name | Request fields | Editable |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
**`agent.create.config`** uses `AgentSessionConfig`:
@@ -1045,10 +903,6 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456",
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"mcpServers": {},
"reason": "resume",
"purpose": "interactive",
"env": { "COMPANY_ENV": "development" }
@@ -1058,10 +912,6 @@ type PluginTurnOutcome =
| Field | Values |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | String or `null` |
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). |
| `reason` | `create`, `resume`, `refresh`, `import` |
| `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
@@ -1073,7 +923,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ agent.session_open hooks (same ordering; env only)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
@@ -1085,9 +935,8 @@ Creation request
| `undefined` | Unchanged request |
| Throws or returns invalid data | Operation fails; later callbacks do not run |
There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers`
accumulate only across callbacks within the same plugin. Returning a replacement map or array
replaces that scope's prior value.
No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is
saved; environment overrides are not persisted with it.
### Context and cleanup
@@ -1111,7 +960,6 @@ replaces that scope's prior value.
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`.
+27 -171
View File
@@ -529,12 +529,9 @@ export default function contribute(server: PluginServerContext) {
只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。
`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。
`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。
### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server}
### 修改已保存的配置 {#change-saved-configuration}
在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项:
在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。
```ts
server.before("agent.create", ({ request }) => {
@@ -551,6 +548,13 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write",
approval_policy: "on-request",
},
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
},
};
});
@@ -560,37 +564,10 @@ server.before("agent.create", ({ request }) => {
| ------------------------------------------- | ------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | 由展开运算保留 |
| 已有的 `mcpServers.search` | 由展开运算保留 |
| 已有的 `mcpServers.company` | 被替换为上面的条目 |
所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。
### 注入 MCP 服务器 {#inject-mcp-servers}
需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。
插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。
每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。
替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。
Paseo 按插件 ID 顺序合并各插件的最终映射。
名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。
不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。
`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent
通过[会话重新打开行为](#attach-tools-to-agents)获取变化。
所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
@@ -606,118 +583,7 @@ server.before("agent.session_open", ({ request }) => {
});
```
会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。
### 为 Agent 附加工具 {#attach-tools-to-agents}
在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。
此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`:
```ts
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
```
通过 SDK 创建 Agent 时设置标签:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
使用会话打开请求中的字段选择 Agent:
| 选择依据 | 回调中的条件 |
| ---------- | -------------------------------------------- |
| 标签 | `request.labels["notes/enabled"] === "true"` |
| Provider | `request.provider === "codex"` |
| 工作区 | `request.workspaceId === "workspace-456"` |
| 单个 Agent | `request.agentId === chosenAgentId` |
每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具;
替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。
从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext`
和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型:
```ts
declare function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition;
interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
```
| 执行 context | 值 |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` |
| `paseo` | 已连接到此 daemon 的 SDK |
| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 |
`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的
输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给
Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭
其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。
关闭或重新打开 Agent 会话会移除旧定义。
在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时,
Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。
Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。
同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。
只有该 Agent 能看到这些工具。
名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo
工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致
会话打开失败。回调未选中的 Agent 不受影响。
| 变更 | Agent 何时收到变更 |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 |
| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 |
| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 |
| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 |
如果自动重新打开失败,触发它的提示词会返回该错误。参阅
[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。
它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。
### 选择工作区隔离方式 {#choose-workspace-isolation}
@@ -875,11 +741,11 @@ type PluginTurnOutcome =
### before 钩子 {#before-hooks}
| 名称 | 请求字段 | 可编辑 |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
| 名称 | 请求字段 | 可编辑 |
| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
**`agent.create.config`** 使用 `AgentSessionConfig`:
@@ -900,26 +766,18 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456",
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"mcpServers": {},
"reason": "resume",
"purpose": "interactive",
"env": { "COMPANY_ENV": "development" }
}
```
| 字段 | 取值 |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `workspaceId` | 字符串或 `null` |
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
| `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
| 字段 | 取值 |
| ------------- | ------------------------------------------------------------------------------------ |
| `workspaceId` | 字符串或 `null` |
| `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
### 顺序与返回值 {#ordering-and-returned-values}
@@ -928,7 +786,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ agent.session_open hooks (same ordering; env only)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
@@ -940,8 +798,7 @@ Creation request
| `undefined` | 未改变的请求 |
| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 |
没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。
返回替换的映射或数组会替换该范围内之前的值。
没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。
### context 与清理 {#context-and-cleanup}
@@ -965,7 +822,6 @@ Creation request
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。
@@ -1213,7 +1069,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) {
```tsx
import { Markdown } from "@getpaseo/plugin/client/ui";
<Markdown>{"## Review\n\nThe checks **passed**."}</Markdown>;
<Markdown>{"## 审查\n\n检查**通过**。"}</Markdown>;
```
`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
+21 -51
View File
@@ -27,29 +27,28 @@ public docs; this skill indexes the references and examples.
Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws.
| Contribution | Registration | Use it when | Reference |
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Contribution | Registration | Use it when | Reference |
| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
| Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) |
| Lifecycle task | Example |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
## Create the project
@@ -378,35 +377,6 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam
Use TanStack Query for async request state, caching, and mutations.
### Contribute tools and MCP servers at session open
Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this
callback inside `contribute(server)`:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks.
They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent.
See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents)
for `defineAgentTool` and targeting, and
[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions.
Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale.
Each reopens before its next idle prompt without interrupting a running turn.
`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn;
`refresh()` only reads the current snapshot.
### Debug daemon-side behavior
Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;