mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bdf94e98 | ||
|
|
837d96ad9a |
+5
-1
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user