Compare commits

...
19 changed files with 663 additions and 131 deletions
+5 -1
View File
@@ -430,7 +430,11 @@ 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 entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all 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
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. 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), 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; as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
+5 -1
View File
@@ -238,7 +238,11 @@ describe("plugin agent tools SDK boundary", () => {
// @ts-expect-error labels are read-only // @ts-expect-error labels are read-only
request.labels.x = "changed"; request.labels.x = "changed";
if (request.labels.x !== "y") return; if (request.labels.x !== "y") return;
return { ...request, tools: [...request.tools, tool] }; // @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 () => {}; return () => {};
} }
+1
View File
@@ -41,6 +41,7 @@ export interface PluginSessionOpenRequest {
readonly labels: Readonly<Record<string, string>>; readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[]; tools: AgentToolDefinition[];
env: Record<string, string>; env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
} }
export type PluginTurnOutcome = export type PluginTurnOutcome =
@@ -85,7 +85,11 @@ import {
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import {
PASEO_MCP_SERVER_NAME,
stripInternalPaseoMcpServer,
withRuntimePaseoMcpServer,
} from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
@@ -559,18 +563,20 @@ interface AgentMetadataPatch {
const SYSTEM_ERROR_PREFIX = "[System Error]"; const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd( function normalizePersistenceForConfig(
handle: AgentPersistenceHandle | null, handle: AgentPersistenceHandle | null,
cwd: string, config: AgentSessionConfig,
): AgentPersistenceHandle | null { ): AgentPersistenceHandle | null {
if (!handle) { if (!handle) {
return null; return null;
} }
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
return { return {
...handle, ...handle,
metadata: { metadata: {
...handle.metadata, ...metadata,
cwd, cwd: config.cwd,
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
}, },
}; };
} }
@@ -1387,7 +1393,7 @@ export class AgentManager {
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options); const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels, labels: options.labels,
initialTitle: options.initialTitle, initialTitle: options.initialTitle,
@@ -1515,7 +1521,7 @@ export class AgentManager {
launchContext, launchContext,
currentResumeOptions, currentResumeOptions,
); );
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, { return this.registerSession(session, storedConfig, resolvedAgentId, {
...options, ...options,
persistence: handle, persistence: handle,
@@ -1578,6 +1584,7 @@ export class AgentManager {
}, },
{ config: providerLaunchConfig, storedConfig, launchContext }, { config: providerLaunchConfig, storedConfig, launchContext },
); );
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false; let handedToRegistration = false;
try { try {
const importedConfig = await this.normalizeConfig( const importedConfig = await this.normalizeConfig(
@@ -1673,10 +1680,10 @@ export class AgentManager {
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if ( if (
Object.keys(storedConfig.mcpServers ?? {}).length > 0 && Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true existing.session.capabilities.supportsMcpServers !== true
) { ) {
throw new Error(`Provider '${provider}' does not support MCP servers`); throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
} }
let session: AgentSession | undefined; let session: AgentSession | undefined;
@@ -1695,7 +1702,7 @@ export class AgentManager {
session = handle session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext) ? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext); : await client.createSession(providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
if (rehydrateFromDisk) { if (rehydrateFromDisk) {
@@ -2921,7 +2928,10 @@ export class AgentManager {
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null); : null);
if (persistenceHandle) { if (persistenceHandle) {
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); mutableAgent.persistence = normalizePersistenceForConfig(
persistenceHandle,
mutableAgent.config,
);
} }
this.logger.trace( this.logger.trace(
{ {
@@ -3865,16 +3875,23 @@ export class AgentManager {
private async requireExternalMcpSupport( private async requireExternalMcpSupport(
session: AgentSession, session: AgentSession,
storedConfig: AgentSessionConfig, launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> { ): Promise<void> {
if ( if (
Object.keys(storedConfig.mcpServers ?? {}).length === 0 || Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true session.capabilities.supportsMcpServers === true
) { ) {
return; return;
} }
await this.closeUnregisteredSession(session); await this.closeUnregisteredSession(session);
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); 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}`);
} }
private async initializeAgentTimelineForRegister(params: { private async initializeAgentTimelineForRegister(params: {
@@ -3960,9 +3977,9 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(), foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(), finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null, unsubscribeSession: null,
persistence: attachPersistenceCwd( persistence: normalizePersistenceForConfig(
options?.persistence ?? session.describePersistence(), options?.persistence ?? session.describePersistence(),
config.cwd, config,
), ),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUserMessageAt: options?.lastUserMessageAt ?? null,
@@ -4266,9 +4283,9 @@ export class AgentManager {
newInfo.modeId !== agent.runtimeInfo?.modeId; newInfo.modeId !== agent.runtimeInfo?.modeId;
agent.runtimeInfo = newInfo; agent.runtimeInfo = newInfo;
if (!agent.persistence && newInfo.sessionId) { if (!agent.persistence && newInfo.sessionId) {
agent.persistence = attachPersistenceCwd( agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: newInfo.sessionId }, { provider: agent.provider, sessionId: newInfo.sessionId },
agent.cwd, agent.config,
); );
} }
// Emit state if runtimeInfo changed so clients get the updated model // Emit state if runtimeInfo changed so clients get the updated model
@@ -4626,9 +4643,9 @@ export class AgentManager {
case "model_changed": case "model_changed":
agent.runtimeInfo = event.runtimeInfo; agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) { if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd( agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, { provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.cwd, agent.config,
); );
} }
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
@@ -4702,7 +4719,7 @@ export class AgentManager {
private refreshSessionPersistence(agent: ActiveManagedAgent): void { private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence(); const handle = agent.session.describePersistence();
if (handle) { if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd); agent.persistence = normalizePersistenceForConfig(handle, agent.config);
} }
} }
@@ -5519,6 +5536,8 @@ export class AgentManager {
// A plugin changing during this open must still make the resulting session stale. // A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration; const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId); this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
if (this.pluginLifecycle) { if (this.pluginLifecycle) {
const request = { const request = {
agentId, agentId,
@@ -5531,14 +5550,20 @@ export class AgentManager {
title: opening?.title ?? null, title: opening?.title ?? null,
labels: opening?.labels ?? {}, labels: opening?.labels ?? {},
tools: [], tools: [],
mcpServers: {},
}; };
const transformed = await this.pluginLifecycle.before("agent.session_open", request); const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env; env = transformed.env;
if (request.purpose === "interactive") if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
} }
const context: AgentLaunchContext = { const context: AgentLaunchContext = {
agentId, agentId,
mcpServers,
mcpServerOwners,
env: { env: {
...env, ...env,
PASEO_AGENT_ID: agentId, PASEO_AGENT_ID: agentId,
@@ -5572,7 +5597,26 @@ export class AgentManager {
launchConfig: AgentSessionConfig, launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext, launchContext: AgentLaunchContext,
): AgentSessionConfig { ): AgentSessionConfig {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; 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;
} }
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> { private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
} }
export interface AgentLaunchContext { export interface AgentLaunchContext {
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
agentId?: string; agentId?: string;
env?: Record<string, string>; env?: Record<string, string>;
/** /**
@@ -1321,3 +1321,28 @@ test("plugin without a runtime safety capability uses baseline collection withou
await rm(home, { recursive: true, force: true }); 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`); if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
return { return {
session, session,
config: { ...context.config, provider: this.provider, cwd: input.cwd }, config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
persistence, persistence,
timeline: session.timelineHistory(), timeline: session.timelineHistory(),
providerSubagentEvents: session.subagentHistory(), providerSubagentEvents: session.subagentHistory(),
@@ -1,6 +1,6 @@
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
const PASEO_MCP_SERVER_NAME = "paseo"; export const PASEO_MCP_SERVER_NAME = "paseo";
const PASEO_MCP_PATHNAME = "/mcp/agents"; const PASEO_MCP_PATHNAME = "/mcp/agents";
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
@@ -4,17 +4,29 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js"; 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 { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
const launches: AgentSessionConfig[] = [];
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0", daemonVersion: "0.13.2",
agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, agentClients: {
codex: createTestAgentClient("codex", {
supportsMcpServers: true,
beforeCreateSession: async (config) => {
launches.push(config);
},
}),
},
mcpEnabled: false, mcpEnabled: false,
}); });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
try { try {
await client.connect(); await client.connect();
await client.fetchAgents({ subscribe: {} }); await client.fetchAgents({ subscribe: {} });
@@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi
}, },
mcpServers: { mcpServers: {
existing: { type: "http", url: "https://existing.example.com/mcp" }, 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); await client.archiveAgent(agent.id);
} finally { } finally {
await client.close(); await client.close();
@@ -1,5 +1,5 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
@@ -11,6 +11,7 @@ import {
type TestAgentClientOptions, type TestAgentClientOptions,
} from "../test-utils/fake-agent-client.js"; } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { importSessionFromPersistence } from "../agent/provider-session-import.js";
import { BuiltinPluginLoader } from "./builtin/index.js"; import { BuiltinPluginLoader } from "./builtin/index.js";
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
import type { import type {
@@ -27,7 +28,7 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions =
const events = { interrupts: 0 }; const events = { interrupts: 0 };
const inner = createTestAgentClient("codex", { const inner = createTestAgentClient("codex", {
...callbacks, ...callbacks,
supportsMcpServers: true, supportsMcpServers: callbacks.supportsMcpServers ?? true,
onStartTurn: (prompt, config) => { onStartTurn: (prompt, config) => {
turns.push({ prompt, launchCount: launches.length }); turns.push({ prompt, launchCount: launches.length });
callbacks.onStartTurn?.(prompt, config); callbacks.onStartTurn?.(prompt, config);
@@ -52,6 +53,13 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions =
launches.push({ config: config as AgentSessionConfig, context }); launches.push({ config: config as AgentSessionConfig, context });
return inner.resumeSession(handle, config, context, options); return inner.resumeSession(handle, config, context, options);
}, },
importSession: (input, context) =>
importSessionFromPersistence({
provider: "codex",
request: input,
context,
resumeSession: provider.resumeSession.bind(provider),
}),
isAvailable: () => inner.isAvailable(), isAvailable: () => inner.isAvailable(),
fetchCatalog: (options) => inner.fetchCatalog(options), fetchCatalog: (options) => inner.fetchCatalog(options),
}; };
@@ -750,3 +758,285 @@ test("the agent-tools example appends notes only for labeled agents", async () =
await h.close(); await h.close();
} }
}, 60_000); }, 60_000);
const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const;
const mcpSource = `
export default function contribute(server) {
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} },
env: { ...request.env, OPEN_REASON: request.reason } };
});
return () => {};
}
`;
test.each([
{ native: false, serverNames: ["paseo", "company"] },
{ native: true, serverNames: ["company"] },
])(
"session-open MCP servers reach launch but never saved configuration (native=$native)",
async ({ native, serverNames }) => {
const h = await setup(native, mcpSource);
try {
const a = await h.create();
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
expect(a.config.mcpServers?.company).toBeUndefined();
const snapshot = (await h.client.fetchAgent(a.id))!.agent;
expect(snapshot.id).toBe(a.id);
expect(JSON.stringify(snapshot)).not.toContain("tools.example.com");
expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create");
expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames);
await h.daemon.daemon.agentManager.closeAgent(a.id);
const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), {
recursive: true,
});
const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!;
const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8");
expect(record).not.toContain("tools.example.com");
} finally {
await h.close();
}
},
60_000,
);
test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => {
const source = reloadRpcSource
.replace(' if (request.labels.x !== "y") return;', "")
.replace(
"return { ...request, env:",
`return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`,
);
const h = await setup(true, source);
try {
const a = await h.create({ x: "y" });
const manager = h.daemon.daemon.agentManager;
await manager.closeAgent(a.id);
await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id);
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume");
await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id });
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
const imported = await manager.importProviderSession({
provider: "codex",
providerHandleId: "imported-session",
cwd: h.directory,
workspaceId: "import-workspace",
labels: { x: "y" },
});
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import");
expect(imported.config.mcpServers?.company).toBeUndefined();
expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com");
expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined();
expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com");
} finally {
await h.close();
}
}, 60_000);
test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => {
const h = await setup(true, "export default function contribute() { return () => {}; }");
try {
const a = await h.create();
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
await h.install("company-mcp", mcpSource);
await sendAndFinish(h, a.id, "after install");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
await h.client.disablePlugin("company-mcp");
await sendAndFinish(h, a.id, "after disable");
expect(h.launches).toHaveLength(3);
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
await sendAndFinish(h, a.id, "stable");
expect(h.launches).toHaveLength(3);
expect(h.events.interrupts).toBe(0);
} finally {
await h.close();
}
}, 60_000);
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const h = await setup(
true,
"export default function contribute() { return () => {}; }",
undefined,
{ beforeTurnComplete: async () => blocked },
);
try {
const a = await h.create();
await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" });
const manager = h.daemon.daemon.agentManager;
await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull();
const turn = manager.getAgent(a.id)!.activeTurnId;
await h.install("company-mcp", mcpSource);
expect(h.launches).toHaveLength(1);
expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn);
await h.client.disablePlugin("company-mcp");
expect(h.launches).toHaveLength(1);
expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn);
expect(h.events.interrupts).toBe(0);
release();
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
await sendAndFinish(h, a.id, "idle");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
} finally {
release();
await h.close();
}
}, 60_000);
test.each([
{
target: "saved configuration",
saved: true,
name: "company",
other: false,
error: /company.*plugin tools-test.*agent saved configuration/,
},
{
target: "saved Paseo name",
saved: true,
name: "paseo",
other: false,
error: /paseo.*plugin tools-test.*agent saved configuration/,
},
{
target: "Paseo internal server",
saved: false,
name: "paseo",
other: false,
error: /paseo.*plugin tools-test.*Paseo internal MCP server/,
},
{
target: "another plugin with identical config",
saved: false,
name: "company",
other: true,
error: /Plugin tools-test.*company.*plugin other-mcp/,
},
])(
"session-open MCP collisions with $target name both owners",
async ({ saved, name, other, error }) => {
const h = await setup(true, mcpSource.replace("company:", name + ":"));
try {
if (other) await h.install("other-mcp", mcpSource);
await expect(
h.daemon.daemon.agentManager.createAgent(
{
provider: "codex",
cwd: h.directory,
...(saved
? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } }
: {}),
},
undefined,
{},
),
).rejects.toThrow(error);
expect(h.launches).toHaveLength(0);
} finally {
await h.close();
}
},
60_000,
);
test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => {
const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false });
try {
await h.client.disablePlugin("tools-test");
const a = await h.create();
await h.client.enablePlugin("tools-test");
await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow(
/does not support MCP servers.*plugin tools-test/,
);
expect(a.config.mcpServers?.company).toBeUndefined();
await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/);
const imported = h.daemon.daemon.agentManager.importProviderSession({
provider: "codex",
providerHandleId: "unsupported-import",
cwd: h.directory,
workspaceId: "import-workspace",
});
await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/);
} finally {
await h.close();
}
}, 60_000);
test("history opens ignore session-open MCP servers", async () => {
const h = await setup(true, mcpSource);
try {
const a = await h.create();
await h.client.archiveAgent(a.id);
await h.install("other-mcp", mcpSource);
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id);
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined();
} finally {
await h.close();
}
}, 60_000);
test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => {
const h = await setup(true, mcpSource);
try {
await h.install(
"z-extend",
`
export default function contribute(server) {
server.before("agent.session_open", ({ request }) => {
if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked");
if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers");
return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } };
});
server.before("agent.session_open", ({ request }) => {
if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server");
return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } };
});
server.before("agent.session_open", () => undefined);
return () => {};
}`,
);
const a = await h.create();
expect(h.launches.at(-1)!.config.mcpServers).toEqual({
company: companyServer,
extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } },
additional: { type: "http", url: "https://additional.example.com/mcp" },
});
const contributedServers = h.launches.at(-1)!.config.mcpServers;
await h.install(
"zz-remove",
`export default function contribute(server) {
server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} }));
return () => {};
}`,
);
await sendAndFinish(h, a.id, "after removal");
expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers);
expect(a.config.mcpServers).toBeUndefined();
} finally {
await h.close();
}
}, 60_000);
test("session-open MCP configs are validated before a provider launches", async () => {
const h = await setup(
true,
mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'),
);
try {
await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s);
expect(h.launches).toHaveLength(0);
} finally {
await h.close();
}
}, 60_000);
@@ -96,6 +96,7 @@ test("session-open hooks reject changes to session identity instead of silently
title: null, title: null,
labels: {}, labels: {},
tools: [], tools: [],
mcpServers: {},
}, },
paseo, paseo,
), ),
@@ -120,12 +121,13 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
title: "Original", title: "Original",
labels: {}, labels: {},
tools: [], tools: [],
mcpServers: {},
env: {}, env: {},
reason: "create", reason: "create",
purpose: "interactive", purpose: "interactive",
}, },
paseo, paseo,
), ),
).rejects.toThrow("agent.session_open hooks can only change env and tools"); ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers");
}, },
); );
@@ -27,6 +27,11 @@ export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session
"agent.session_open": SessionOpenWireRequest; "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 = [ export const lifecycleEventNames = [
"agent.created", "agent.created",
"agent.closed", "agent.closed",
@@ -54,6 +59,7 @@ const beforeSchemas = {
labels: z.record(z.string(), z.string()), labels: z.record(z.string(), z.string()),
tools: z.array(agentToolMetadataSchema), tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()), env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
}) })
.strict(), .strict(),
"workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
@@ -80,7 +86,7 @@ export interface PluginLifecycle {
before<Name extends keyof PluginWireBeforeRequests>( before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
request: PluginWireBeforeRequests[Name], request: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]>; ): Promise<PluginBeforeResults[Name]>;
} }
export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>( export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
@@ -176,7 +182,7 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
previous.title !== next.title || previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels) !isDeepStrictEqual(previous.labels, next.labels)
) { ) {
throw new Error("agent.session_open hooks can only change env and tools"); throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
} }
} }
if (name === "agent.create") { if (name === "agent.create") {
@@ -290,8 +296,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
tools = local.tools; tools = local.tools;
} }
context.signal.throwIfAborted(); context.signal.throwIfAborted();
if (request.purpose === "history") request.tools = []; if (request.purpose === "history") {
else this.agentTools?.attach(request.agentId, tools); request.tools = [];
request.mcpServers = {};
} else this.agentTools?.attach(request.agentId, tools);
return request; return request;
} }
+34 -5
View File
@@ -1,4 +1,4 @@
import type { PluginWireBeforeRequests } from "./lifecycle/index.js"; import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage"; import type { UsageScope } from "@getpaseo/plugin/server/usage";
import { import {
@@ -571,12 +571,14 @@ export class PluginRuntime {
async before<Name extends keyof PluginWireBeforeRequests>( async before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginWireBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]> { ): Promise<PluginBeforeResults[Name]> {
let request = validateBeforeRequest(name, input); let request = validateBeforeRequest(name, input);
const plugins = [...this.plugins.values()].sort((left, right) => { const plugins = [...this.plugins.values()].sort((left, right) => {
return left.id.localeCompare(right.id); return left.id.localeCompare(right.id);
}); });
const attached: AttachedAgentTool[] = []; const attached: AttachedAgentTool[] = [];
const mcpServers: SessionOpenWireRequest["mcpServers"] = {};
const mcpServerOwners: Record<string, string> = {};
for (const loaded of plugins) { for (const loaded of plugins) {
if (!loaded.hooks.before.includes(name)) { if (!loaded.hooks.before.includes(name)) {
continue; continue;
@@ -587,11 +589,14 @@ export class PluginRuntime {
requestId: randomUUID(), requestId: randomUUID(),
kind: "before", kind: "before",
name, name,
input: name === "agent.session_open" ? { ...request, tools: [] } : request, input:
name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request,
}); });
request = validateBeforeResult(name, request, output); request = validateBeforeResult(name, request, output);
if (name === "agent.session_open") { if (name === "agent.session_open") {
this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools); const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
this.collectAgentTools(attached, loaded, opened.tools);
} }
} catch (error) { } catch (error) {
if (name === "agent.session_open") if (name === "agent.session_open")
@@ -601,7 +606,14 @@ export class PluginRuntime {
}); });
} }
} }
if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached; if (name === "agent.session_open") {
return {
...request,
tools: attached,
mcpServers,
mcpServerOwners,
} as PluginBeforeResults[Name];
}
return request; return request;
} }
@@ -623,6 +635,23 @@ export class PluginRuntime {
return output; 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( private collectAgentTools(
attached: AttachedAgentTool[], attached: AttachedAgentTool[],
loaded: LoadedPlugin, loaded: LoadedPlugin,
+18 -11
View File
@@ -1,14 +1,19 @@
# Agent configuration # Agent configuration
Changes new Codex agents through `server.before("agent.create", ...)`: Sets saved Codex options through `server.before("agent.create", ...)`:
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
- Sets `config.providerOptions.approval_policy` to `on-request`. - Sets `config.providerOptions.approval_policy` to `on-request`.
- Adds the `company` server to `config.mcpServers`. - Preserves other configuration and provider options.
- Preserves other configuration, provider options, MCP servers, and environment overrides.
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).
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. this example. A `company` name collision with a saved server or another
plugin fails the session open and names both sources.
Install on a daemon with plugins enabled: Install on a daemon with plugins enabled:
@@ -16,11 +21,13 @@ Install on a daemon with plugins enabled:
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
``` ```
The callback receives the request after earlier plugins have transformed it. The nested spreads Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves entries added by this plugin's earlier callbacks. The create callback receives configuration after
the resulting configuration before starting the agent. Existing agents are unchanged. 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.
See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions)
environment injection. Its provider-switch callback clears provider options when changing Codex to demonstrates provider switching and environment injection. Its provider-switch callback clears
Claude, so use these examples separately when trying the Codex-specific settings. provider options when changing Codex to Claude, so use these examples separately when trying the
Codex-specific settings.
@@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: { },
...request.config.mcpServers, };
company: { });
type: "http",
url: "https://tools.example.com/mcp", 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",
}, },
}, },
}; };
@@ -1,6 +1,6 @@
{ {
"id": "agent-configuration", "id": "agent-configuration",
"requirements": { "requirements": {
"paseo": ">=0.8.0" "paseo": ">=0.13.2"
} }
} }
+58 -27
View File
@@ -653,9 +653,14 @@ export default function contribute(server: PluginServerContext) {
Hooks run on the daemon while the plugin is enabled, even with no app connected. Hooks run on the daemon while the plugin is enabled, even with no app connected.
### Change configuration and inject an MCP server `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.
Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. ### Change saved configuration
Add this callback inside `contribute(server)` to set the saved options for new Codex agents:
```ts ```ts
server.before("agent.create", ({ request }) => { server.before("agent.create", ({ request }) => {
@@ -672,27 +677,50 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
}, },
}; };
}); });
``` ```
| Input | Result | | Input | Result |
| ------------------------------------------- | ----------------------------- | | ------------------------------------------- | ----------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | Preserved by the spread | | `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` and must support the configured MCP servers. The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options
Explicit Codex sandbox and approval options override its mode presets. 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).
### Inject environment variables on every session opening ### Inject environment variables on every session opening
@@ -708,8 +736,8 @@ server.before("agent.session_open", ({ request }) => {
}); });
``` ```
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an Session-open overrides are re-derived on every open. `agent.create.env` applies only to the
`agent.create` callback instead. initial launch and is not saved in the agent's configuration.
### Attach tools to agents ### Attach tools to agents
@@ -992,11 +1020,11 @@ type PluginTurnOutcome =
### Before hooks ### Before hooks
| Name | Request fields | Editable | | Name | Request fields | Editable |
| -------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | | `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` | `env` and `tools` | | `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 | | `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
**`agent.create.config`** uses `AgentSessionConfig`: **`agent.create.config`** uses `AgentSessionConfig`:
@@ -1020,6 +1048,7 @@ type PluginTurnOutcome =
"title": "Workspace notes", "title": "Workspace notes",
"labels": { "notes/enabled": "true" }, "labels": { "notes/enabled": "true" },
"tools": [], "tools": [],
"mcpServers": {},
"reason": "resume", "reason": "resume",
"purpose": "interactive", "purpose": "interactive",
"env": { "COMPANY_ENV": "development" } "env": { "COMPANY_ENV": "development" }
@@ -1032,6 +1061,7 @@ type PluginTurnOutcome =
| `title` | Read-only string or `null`; creation title on create, stored title on resume. | | `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. | | `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). | | `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` | | `reason` | `create`, `resume`, `refresh`, `import` |
| `purpose` | `interactive`, `history` | | `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | | `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
@@ -1043,7 +1073,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin) → agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration → resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt → derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env and plugin-local tools) → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD → set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration → open provider session and save agent configuration
``` ```
@@ -1055,8 +1085,9 @@ Creation request
| `undefined` | Unchanged request | | `undefined` | Unchanged request |
| Throws or returns invalid data | Operation fails; later callbacks do not run | | Throws or returns invalid data | Operation fails; later callbacks do not run |
No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers`
saved; environment overrides are not persisted with it. accumulate only across callbacks within the same plugin. Returning a replacement map or array
replaces that scope's prior value.
### Context and cleanup ### Context and cleanup
+47 -21
View File
@@ -529,9 +529,12 @@ export default function contribute(server: PluginServerContext) {
只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。
### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server} `agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。
`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。
在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。 ### 修改已保存的配置 {#change-saved-configuration}
在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项:
```ts ```ts
server.before("agent.create", ({ request }) => { server.before("agent.create", ({ request }) => {
@@ -548,13 +551,6 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
}, },
}; };
}); });
@@ -564,10 +560,37 @@ server.before("agent.create", ({ request }) => {
| ------------------------------------------- | ------------------- | | ------------------------------------------- | ------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | 由展开运算保留 | | `providerOptions.web_search: "disabled"` | 由展开运算保留 |
| 已有的 `mcpServers.search` | 由展开运算保留 |
| 已有的 `mcpServers.company` | 被替换为上面的条目 |
所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 所选的 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)获取变化。
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} ### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
@@ -583,7 +606,7 @@ server.before("agent.session_open", ({ request }) => {
}); });
``` ```
它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。
### 为 Agent 附加工具 {#attach-tools-to-agents} ### 为 Agent 附加工具 {#attach-tools-to-agents}
@@ -852,11 +875,11 @@ type PluginTurnOutcome =
### before 钩子 {#before-hooks} ### before 钩子 {#before-hooks}
| 名称 | 请求字段 | 可编辑 | | 名称 | 请求字段 | 可编辑 |
| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- | | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | | `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools` | `env` 和 `tools` | | `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | | `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
**`agent.create.config`** 使用 `AgentSessionConfig`: **`agent.create.config`** 使用 `AgentSessionConfig`:
@@ -880,6 +903,7 @@ type PluginTurnOutcome =
"title": "Workspace notes", "title": "Workspace notes",
"labels": { "notes/enabled": "true" }, "labels": { "notes/enabled": "true" },
"tools": [], "tools": [],
"mcpServers": {},
"reason": "resume", "reason": "resume",
"purpose": "interactive", "purpose": "interactive",
"env": { "COMPANY_ENV": "development" } "env": { "COMPANY_ENV": "development" }
@@ -892,6 +916,7 @@ type PluginTurnOutcome =
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | | `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | | `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | | `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
| `reason` | `create`、`resume`、`refresh`、`import` | | `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` | | `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | | `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
@@ -903,7 +928,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin) → agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration → resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt → derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env and plugin-local tools) → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD → set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration → open provider session and save agent configuration
``` ```
@@ -915,7 +940,8 @@ Creation request
| `undefined` | 未改变的请求 | | `undefined` | 未改变的请求 |
| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | | 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 |
没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。 没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。
返回替换的映射或数组会替换该范围内之前的值。
### context 与清理 {#context-and-cleanup} ### context 与清理 {#context-and-cleanup}
@@ -1187,7 +1213,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) {
```tsx ```tsx
import { Markdown } from "@getpaseo/plugin/client/ui"; import { Markdown } from "@getpaseo/plugin/client/ui";
<Markdown>{"## 审查\n\n检查**通过**。"}</Markdown>; <Markdown>{"## Review\n\nThe checks **passed**."}</Markdown>;
``` ```
`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 `children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
+51 -21
View File
@@ -27,28 +27,29 @@ 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. 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 | | 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 | | 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 | | 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` | | 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 | | 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 | | 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 | | 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 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 | | 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` | | 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` | | 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 | | 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) | | 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) | | 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 | | Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example | | Lifecycle task | Example |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | | 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) | | 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) | | 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) |
## Create the project ## Create the project
@@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam
Use TanStack Query for async request state, caching, and mutations. 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 ### Debug daemon-side behavior
Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout; Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;