Let plugins customize agent creation and observe lifecycle events (#4435)

* feat(plugins): expose agent and workspace lifecycle hooks

* test(plugins): verify lifecycle examples with version requirements

* fix(plugins): publish archive hooks for closed agents

* test(plugins): remove diagnostic catch from lifecycle test

* refactor(plugins): define hook handlers in lifecycle module
This commit is contained in:
Mohamed Boudra
2026-09-07 19:17:42 +02:00
committed by GitHub
parent 8c89e72a06
commit 9c8d775895
34 changed files with 2163 additions and 25 deletions
+11
View File
@@ -262,6 +262,17 @@ optional client-owned agent and workspace navigation; its absence is the compati
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
Plugins do not receive Expo Router or workspace-layout store access.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/v0.8/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
+2 -1
View File
@@ -1,3 +1,4 @@
import type { PluginLifecycleRegistration } from "./lifecycle.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
@@ -319,7 +320,7 @@ export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginServerContext {
export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("./settings.js").SettingsDefinition<Schema>,
): void;
+11
View File
@@ -66,3 +66,14 @@ export type {
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginSessionOpenRequest,
PluginTurnOutcome,
} from "./lifecycle.js";
+88
View File
@@ -0,0 +1,88 @@
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
} from "@getpaseo/protocol/agent-types";
import type { PaseoApi } from "@getpaseo/client";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
export interface PluginHookContext {
paseo: PaseoApi;
signal: AbortSignal;
}
export interface PluginHookWorkspace {
id: string;
projectId: string;
cwd: string;
name: string | null;
archivedAt: string | null;
}
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
parentAgentId: string | null;
provider: string;
cwd: string;
title: string | null;
}
export interface PluginSessionOpenRequest {
agentId: string;
workspaceId: string | null;
provider: string;
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
env: Record<string, string>;
}
export type PluginTurnOutcome =
| { kind: "completed" }
| { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string };
export interface PluginLifecycleEvents {
"agent.turn_started": { agent: PluginHookAgent; turnId: string | null };
"agent.turn_ended": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
timeline: readonly AgentTimelineItem[];
};
"agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission_resolved": {
agent: PluginHookAgent;
requestId: string;
resolution: AgentPermissionResponse;
};
"agent.archived": { agent: PluginHookAgent; archivedAt: string };
"agent.created": { agent: PluginHookAgent };
"workspace.created": { workspace: PluginHookWorkspace };
"workspace.archived": { workspace: PluginHookWorkspace };
}
export interface PluginBeforeRequests {
"agent.create": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.session_open": PluginSessionOpenRequest;
"workspace.create": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
export interface PluginLifecycleRegistration {
on<Name extends keyof PluginLifecycleEvents>(
name: Name,
handler: (
event: PluginLifecycleEvents[Name],
context: PluginHookContext,
) => void | Promise<void>,
): () => void;
before<Name extends keyof PluginBeforeRequests>(
name: Name,
handler: (
input: { request: PluginBeforeRequests[Name] },
context: PluginHookContext,
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
): () => void;
}
+11
View File
@@ -1 +1,12 @@
export type { PluginHandlerContext } from "./contracts.js";
export type {
PluginBeforeRequests,
PluginHookAgent,
PluginHookContext,
PluginHookWorkspace,
PluginLifecycleEvents,
PluginLifecycleRegistration,
PluginSessionOpenRequest,
PluginTurnOutcome,
} from "./lifecycle.js";
@@ -1,3 +1,6 @@
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
import { stat } from "node:fs/promises";
@@ -286,6 +289,7 @@ export interface CreateAgentOptions {
}
export interface AgentManagerOptions {
pluginLifecycle?: PluginLifecycle;
clients?: ProviderClientMap;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
@@ -685,6 +689,7 @@ function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatc
}
export class AgentManager {
private readonly pluginLifecycle: PluginLifecycle | undefined;
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
@@ -725,6 +730,7 @@ export class AgentManager {
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
@@ -1192,6 +1198,14 @@ export class AgentManager {
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) {
const request = await this.pluginLifecycle.before("agent.create", {
config,
env: options.env,
});
config = { ...request.config, internal: config.internal };
options = { ...options, env: request.env };
}
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
config,
@@ -1209,18 +1223,25 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
historyPrimed: true,
});
if (!agent.internal) {
this.pluginLifecycle?.emit("agent.created", {
agent: describeHookAgent({ ...agent, title: agent.config.title }),
});
}
return agent;
}
private buildCreateSessionOptions(options?: {
@@ -1295,6 +1316,12 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "resume",
purpose: resumeOptions?.purpose ?? "interactive",
workspaceId: options?.workspaceId ?? null,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(
@@ -1349,6 +1376,8 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
@@ -1442,6 +1471,8 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
@@ -1725,11 +1756,11 @@ export class AgentManager {
}
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry();
const archivedAt = new Date().toISOString();
const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt });
await registry.upsert(archivedRecord);
const archivedRecord = await this.persistArchivedRecord(record, {
archivedAt,
updatedAt: archivedAt,
});
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
@@ -1744,6 +1775,21 @@ export class AgentManager {
return archivedRecord;
}
private async persistArchivedRecord(
record: StoredAgentRecord,
options: { archivedAt: string; updatedAt?: string },
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = buildArchivedAgentRecord(record, options);
await this.requireRegistry().upsert(archivedRecord);
if (!record.archivedAt && !record.internal) {
this.pluginLifecycle?.emit("agent.archived", {
agent: describeHookAgent(archivedRecord),
archivedAt: archivedRecord.archivedAt,
});
}
return archivedRecord;
}
private async fireAgentArchived(agentId: string): Promise<void> {
const callback = this.onAgentArchived;
if (!callback) {
@@ -2015,8 +2061,7 @@ export class AgentManager {
throw new Error(`Agent not found: ${agentId}`);
}
const nextRecord = buildArchivedAgentRecord(record, { archivedAt });
await registry.upsert(nextRecord);
const nextRecord = await this.persistArchivedRecord(record, { archivedAt });
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
@@ -4757,6 +4802,14 @@ export class AgentManager {
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
if (this.pluginLifecycle && agent && !agent.internal && event.type !== "timeline") {
publishAgentStream(
this.pluginLifecycle,
describeHookAgent({ ...agent, title: agent.config.title }),
event,
this.timelineStore.getItems(agentId),
);
}
}
private dispatch(event: AgentManagerEvent): void {
@@ -4941,7 +4994,25 @@ export class AgentManager {
cwd: string,
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
env?: Record<string, string>,
opening?: {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
},
): Promise<AgentLaunchContext> {
if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = {
agentId,
provider: client.provider,
cwd,
workspaceId: opening?.workspaceId ?? null,
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env;
}
const context: AgentLaunchContext = {
agentId,
env: {
+10
View File
@@ -1,3 +1,4 @@
import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
import express from "express";
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
import { constants, existsSync, unlinkSync } from "fs";
@@ -877,7 +878,15 @@ export async function createPaseoDaemon(
forgeOverrides: { github },
},
});
workspaceRegistry.subscribeToMutations((mutation) => {
if (mutation.kind === "archive" && mutation.workspace) {
pluginRuntime.emit("workspace.archived", {
workspace: describeHookWorkspace(mutation.workspace),
});
}
});
const workspaceProvisioning = createWorkspaceProvisioningService({
lifecycle: pluginRuntime,
serverId,
projectRegistry,
workspaceRegistry,
@@ -911,6 +920,7 @@ export async function createPaseoDaemon(
});
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime,
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
@@ -0,0 +1,56 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.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 () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0",
agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) },
mcpEnabled: false,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "configuration-example" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(
fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)),
);
const agent = await client.createAgent({
provider: "codex",
cwd: directory,
systemPrompt: "Keep the user's instructions.",
providerOptions: {
sandbox_mode: "read-only",
approval_policy: "never",
web_search: "disabled",
},
mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" } },
});
expect(daemon.daemon.agentManager.getAgent(agent.id)?.config).toMatchObject({
provider: "codex",
cwd: directory,
systemPrompt: "Keep the user's instructions.",
providerOptions: {
sandbox_mode: "workspace-write",
approval_policy: "on-request",
web_search: "disabled",
},
mcpServers: {
existing: { type: "http", url: "https://existing.example.com/mcp" },
company: { type: "http", url: "https://tools.example.com/mcp" },
},
});
await client.archiveAgent(agent.id);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
@@ -1,3 +1,4 @@
import type { PluginLifecycle } from "./lifecycle/index.js";
import path from "node:path";
import { stat, rm } from "node:fs/promises";
import type pino from "pino";
@@ -24,6 +25,8 @@ import { readPluginProviderIcon } from "./provider-icon.js";
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
interface PluginRuntimePort {
emit?: PluginLifecycle["emit"];
before?: PluginLifecycle["before"];
catalog: PluginRuntime["catalog"];
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
getLogs(pluginId: string): PluginLogEntry[];
@@ -91,6 +94,17 @@ export class PluginService {
});
}
readonly emit: PluginLifecycle["emit"] = (name, event) => {
this.runtime.emit?.(name, event);
};
readonly before: PluginLifecycle["before"] = async (name, request) => {
if (this.runtime.before) {
return this.runtime.before(name, request);
}
return request;
};
subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void {
this.settingsListeners.add(listener);
return () => this.settingsListeners.delete(listener);
@@ -0,0 +1,56 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("workspace archive publishes agent archive hooks for both live and closed agents", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "archive-hooks" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(
fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)),
);
const created = await client.createWorkspace({
source: { kind: "directory", path: directory },
});
expect(created.error).toBeNull();
const workspace = created.workspace!;
const live = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId: workspace.id,
});
const closed = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId: workspace.id,
});
await daemon.daemon.agentManager.closeAgent(closed.id);
expect(daemon.daemon.agentManager.getAgent(closed.id)).toBeNull();
await client.archiveWorkspace(workspace.id);
await expect
.poll(async () => {
const logs = await client.getPluginLogs("lifecycle-logger");
return logs
.filter((entry) => {
return entry.message.startsWith('{"hook":"agent.archived"');
})
.map((entry) => {
return JSON.parse(entry.message).data.agent.id;
})
.sort();
})
.toEqual([live.id, closed.id].sort());
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
@@ -0,0 +1,262 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("a plugin transforms workspace creation and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "lifecycle", requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
server.before("workspace.create", ({ request }) => {
return { ...request, title: "Created through a hook" };
});
server.on("workspace.created", (event) => {
console.log(JSON.stringify({ hook: "workspace.created", event }));
});
server.on("workspace.archived", (event) => {
console.log(JSON.stringify({ hook: "workspace.archived", event }));
});
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "lifecycle" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const created = await client.createWorkspace({
source: { kind: "directory", path: directory },
});
expect(created.workspace?.name).toBe("Created through a hook");
const workspaceId = created.workspace!.id;
await client.archiveWorkspace(workspaceId);
await expect
.poll(async () => {
const logs = await client.getPluginLogs("lifecycle");
return logs
.filter((entry) => {
return entry.message.startsWith('{"hook":');
})
.map((entry) => {
return JSON.parse(entry.message);
});
})
.toMatchObject([
{ hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } },
{ hook: "workspace.archived", event: { workspace: { id: workspaceId } } },
]);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("plugins observe turns, answer permissions, and observe archive without blocking the agent", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "turn-hooks", requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
server.on("agent.turn_started", (event) => {
console.log(JSON.stringify({ hook: "agent.turn_started", event }));
});
server.on("agent.turn_ended", (event) => {
console.log(JSON.stringify({ hook: "agent.turn_ended", event }));
});
server.on("agent.permission_requested", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission_requested", event }));
await context.paseo.agents.ref(event.agent.id).respondToPermission({
requestId: event.request.id,
response: { behavior: "deny", message: "Declined by plugin" },
});
});
server.on("agent.permission_resolved", (event) => {
console.log(JSON.stringify({ hook: "agent.permission_resolved", event }));
});
server.on("agent.archived", (event) => {
console.log(JSON.stringify({ hook: "agent.archived", event }));
});
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "turn-hooks" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const agent = await client.createAgent({
provider: "claude",
cwd: directory,
title: "Turn hooks",
modeId: "default",
});
await client.sendMessage(agent.id, "Run rm -f permission.txt");
await expect
.poll(
async () => {
const logs = await client.getPluginLogs("turn-hooks");
return logs
.filter((entry) => {
return entry.message.startsWith('{"hook":');
})
.map((entry) => {
return JSON.parse(entry.message);
})
.sort((left, right) => {
return left.hook.localeCompare(right.hook);
});
},
{ timeout: 10_000 },
)
.toMatchObject([
{
hook: "agent.permission_requested",
event: { request: { input: { command: "rm -f permission.txt" } } },
},
{ hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } },
{
hook: "agent.turn_ended",
event: { outcome: { kind: "completed" }, timeline: expect.any(Array) },
},
{ hook: "agent.turn_started", event: { agent: { id: agent.id } } },
]);
await client.archiveAgent(agent.id);
await expect
.poll(async () => {
const logs = await client.getPluginLogs("turn-hooks");
return logs.filter((entry) => {
return entry.message.startsWith('{"hook":"agent.archived"');
}).length;
})
.toBe(1);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("agent creation hooks change the provider and environment before the session opens", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "agent-hooks", requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
server.before("agent.create", ({ request }) => {
return { ...request, config: { ...request.config, provider: "codex" }, env: { ...request.env, HOOK_CREATE: "created" } };
});
server.before("agent.session_open", ({ request }) => {
console.log(JSON.stringify({ hook: "agent.session_open", request }));
return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } };
});
server.on("agent.created", (event) => {
console.log(JSON.stringify({ hook: "agent.created", event }));
});
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "agent-hooks" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const agent = await client.createAgent({
provider: "claude",
cwd: directory,
title: "Hook test",
});
expect(agent.provider).toBe("codex");
await expect
.poll(async () => {
const logs = await client.getPluginLogs("agent-hooks");
return logs
.filter((entry) => {
return entry.message.startsWith('{"hook":');
})
.map((entry) => {
return JSON.parse(entry.message);
});
})
.toMatchObject([
{
hook: "agent.session_open",
request: { agentId: agent.id, env: { HOOK_CREATE: "created" } },
},
{ hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } },
]);
await client.archiveAgent(agent.id);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("invalid output from an untyped plugin rejects creation before later callbacks run", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-invalid-hook-"));
const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "invalid-hook", requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
server.before("workspace.create", () => {
return { source: { kind: "worktree", branchName: 42 } };
});
server.before("workspace.create", ({ request }) => {
console.log("Unexpected later callback");
return request;
});
return () => {};
}
`,
);
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const result = await client.createWorkspace({ source: { kind: "directory", path: directory } });
expect(result.workspace).toBeNull();
expect(result.error).toContain("Plugin invalid-hook before workspace.create failed");
const logs = await client.getPluginLogs("invalid-hook");
expect(
logs.some((entry) => {
return entry.message.includes("Unexpected later callback");
}),
).toBe(false);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
@@ -0,0 +1,341 @@
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { expect, test } from "vitest";
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
type RecordedHook =
| {
[Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] };
}[keyof PluginLifecycleEvents]
| {
[Name in keyof PluginBeforeRequests]: {
hook: `before ${Name}`;
data: { request: PluginBeforeRequests[Name] };
};
}[keyof PluginBeforeRequests];
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof");
async function readHooks(client: DaemonClient): Promise<RecordedHook[]> {
const logs = await client.getPluginLogs("lifecycle-logger");
const events: RecordedHook[] = [];
for (const entry of logs) {
if (entry.message.startsWith('{"hook":')) {
events.push(JSON.parse(entry.message) as RecordedHook);
}
}
return events;
}
async function endedTurns(client: DaemonClient, agentId: string) {
const events = await readHooks(client);
return events.filter((event): event is Extract<RecordedHook, { hook: "agent.turn_ended" }> => {
return event.hook === "agent.turn_ended" && event.data.agent.id === agentId;
});
}
async function waitForTurns(client: DaemonClient, agentId: string, count: number): Promise<void> {
await expect
.poll(
async () => {
return (await endedTurns(client, agentId)).length;
},
{ timeout: 120_000, interval: 250 },
)
.toBe(count);
}
async function waitForPermission(client: DaemonClient, agentId: string, command: string) {
let requestId = "";
await expect
.poll(
async () => {
const events = await readHooks(client);
for (const event of events) {
if (
event.hook === "agent.permission_requested" &&
event.data.agent.id === agentId &&
event.data.request.input?.command === command
) {
requestId = event.data.request.id;
}
}
return requestId;
},
{ timeout: 120_000, interval: 250 },
)
.not.toBe("");
return requestId;
}
test.skipIf(process.platform !== "linux")(
"all lifecycle hooks and the example actions work with a real Claude agent on an isolated daemon",
async () => {
await mkdir(evidenceDirectory, { recursive: true });
const fixture = await mkdtemp(path.join(tmpdir(), "paseo-real-hooks-"));
const project = path.join(fixture, "project");
await mkdir(path.join(project, ".claude"), { recursive: true });
await writeFile(
path.join(project, ".claude", "settings.json"),
JSON.stringify({ permissions: { ask: ["Bash"] } }),
);
await writeFile(path.join(project, "canary.txt"), "preserve me\n");
execFileSync("git", ["init", "-b", "main"], { cwd: project, stdio: "pipe" });
execFileSync("git", ["add", "."], { cwd: project });
execFileSync(
"git",
[
"-c",
"user.name=Lifecycle Test",
"-c",
"user.email=lifecycle@example.invalid",
"commit",
"-m",
"Fixture",
],
{ cwd: project, stdio: "pipe" },
);
const destination = pino.destination({
dest: path.join(evidenceDirectory, "daemon.log"),
sync: true,
});
const logger = pino({ level: "info" }, destination);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0",
logger,
agentClients: {
claude: new ClaudeAgentClient({ logger }),
codex: new CodexAppServerAgentClient(logger),
},
mcpEnabled: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
const proof: Record<string, unknown> = {
port: daemon.port,
paseoHome: daemon.paseoHome,
startedAt: new Date().toISOString(),
};
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "real-hooks" } });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-logger"));
await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-actions"));
const created = await client.createWorkspace({
title: "Isolated lifecycle example",
source: { kind: "directory", path: project },
});
expect(created.error).toBeNull();
const workspace = created.workspace!;
expect(workspace.workspaceKind).toBe("worktree");
expect(workspace.workspaceDirectory).not.toBe(project);
proof.workspace = workspace;
const agent = await client.createAgent({
provider: "codex",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: "Real lifecycle example",
});
expect(agent.provider).toBe("claude");
proof.agent = { id: agent.id, requestedProvider: "codex", actualProvider: agent.provider };
await client.sendMessage(
agent.id,
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
);
await waitForTurns(client, agent.id, 2);
const retryTurns = await endedTurns(client, agent.id);
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
proof.followup = "RETRY_OK";
await client.sendMessage(
agent.id,
"Call the Bash tool with exactly git status. Do not combine it with other commands. Then reply STATUS_OK.",
);
await waitForTurns(client, agent.id, 3);
const approvalId = await waitForPermission(client, agent.id, "git status");
expect(await readHooks(client)).toContainEqual(
expect.objectContaining({
hook: "agent.permission_resolved",
data: expect.objectContaining({
requestId: approvalId,
resolution: expect.objectContaining({ behavior: "allow" }),
}),
}),
);
proof.approvedRequestId = approvalId;
await client.sendMessage(
agent.id,
"Call the Bash tool with exactly rm -rf canary.txt. If declined, do not try any other command or tool. Reply DECLINED and stop.",
);
await waitForTurns(client, agent.id, 4);
const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt");
expect(await readHooks(client)).toContainEqual(
expect.objectContaining({
hook: "agent.permission_resolved",
data: expect.objectContaining({
requestId: deniedId,
resolution: expect.objectContaining({ behavior: "deny" }),
}),
}),
);
expect(await readFile(path.join(workspace.workspaceDirectory, "canary.txt"), "utf8")).toBe(
"preserve me\n",
);
proof.deniedRequestId = deniedId;
proof.canaryPreserved = true;
const envCommand = 'printf \'%s|%s\' "$PASEO_HOOK_CREATE_EXAMPLE" "$PASEO_HOOK_OPEN_EXAMPLE"';
await client.sendMessage(
agent.id,
`Call Bash with exactly this command: ${envCommand}. Reply with its exact output only.`,
);
const envPermission = await waitForPermission(client, agent.id, envCommand);
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
await waitForTurns(client, agent.id, 5);
const envTurns = await endedTurns(client, agent.id);
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
proof.environmentOutput = "created|opened";
await client.refreshAgent(agent.id);
await daemon.daemon.agentManager.closeAgent(agent.id);
await client.sendMessage(agent.id, "Reply exactly RESUMED. Do not use tools.");
await waitForTurns(client, agent.id, 6);
await client.sendMessage(
agent.id,
"Write a detailed 2000-word essay about binary trees. Do not use tools.",
);
await expect
.poll(
async () => {
return (await readHooks(client)).filter((event) => {
return event.hook === "agent.turn_started" && event.data.agent.id === agent.id;
}).length;
},
{ timeout: 30_000 },
)
.toBe(7);
await client.cancelAgent(agent.id);
await waitForTurns(client, agent.id, 7);
expect((await endedTurns(client, agent.id))[6].data.outcome.kind).toBe("canceled");
await client.archiveAgent(agent.id);
const failed = await client.createAgent({
provider: "claude",
model: "haiku",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: "Expected runtime crash",
});
await client.sendMessage(failed.id, "Reply READY. Do not use tools.");
await waitForTurns(client, failed.id, 1);
const rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf8" }).split(
"\n",
);
const providerPids: number[] = [];
for (const row of rows) {
const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(row);
if (match && Number(match[2]) === process.pid && match[3].includes("claude")) {
const cwd = await readlink(`/proc/${match[1]}/cwd`);
if (cwd === workspace.workspaceDirectory) {
providerPids.push(Number(match[1]));
}
}
}
expect(providerPids).toHaveLength(1);
const providerPid = providerPids[0];
await client.sendMessage(
failed.id,
"Write a detailed 2000-word essay about binary trees. Do not use tools.",
);
process.kill(providerPid, "SIGKILL");
await waitForTurns(client, failed.id, 2);
expect((await endedTurns(client, failed.id))[1].data.outcome.kind).toBe("failed");
proof.crashedProviderPid = providerPid;
await client.archiveAgent(failed.id);
await client.archiveWorkspace(workspace.id);
await expect
.poll(async () => {
const names = new Set(
(await readHooks(client)).map((event) => {
return event.hook;
}),
);
return [...names].sort();
})
.toEqual([
"agent.archived",
"agent.created",
"agent.permission_requested",
"agent.permission_resolved",
"agent.turn_ended",
"agent.turn_started",
"before agent.create",
"before agent.session_open",
"before workspace.create",
"workspace.archived",
"workspace.created",
]);
const openReasons = (await readHooks(client))
.filter((event) => {
return event.hook === "before agent.session_open";
})
.map((event) => {
return event.data.request.reason;
});
expect(openReasons).toContain("create");
expect(openReasons).toContain("refresh");
expect(openReasons).toContain("resume");
proof.passed = true;
} finally {
proof.finishedAt = new Date().toISOString();
await writeFile(path.join(evidenceDirectory, "proof.json"), JSON.stringify(proof, null, 2));
const logs = await client.getPluginLogs("lifecycle-logger").catch(() => {
return [];
});
await writeFile(
path.join(evidenceDirectory, "hooks.jsonl"),
logs
.filter((entry) => {
return entry.message.startsWith('{"hook":');
})
.map((entry) => {
return entry.message;
})
.join("\n") + "\n",
);
await client.close();
await daemon.close();
destination.end();
await rm(fixture, { recursive: true, force: true });
}
},
600_000,
);
function assistantText(timeline: readonly AgentTimelineItem[]): string {
let text = "";
for (const item of timeline) {
if (item.type === "user_message") {
text = "";
} else if (item.type === "assistant_message") {
text += item.text;
}
}
return text;
}
@@ -0,0 +1,100 @@
import { expect, test } from "vitest";
import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { PluginHookHandlers } from "./index.js";
const paseo = createPaseoApi(
new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }),
);
test("removing an old registration twice preserves a newer registration for the same hook", async () => {
const hooks = new PluginHookHandlers(() => {});
const remove = hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "old" };
});
remove();
hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "new" };
});
remove();
const output = await hooks.invoke(
"operation",
"before",
"workspace.create",
{
source: { kind: "directory", path: "/project" },
},
paseo,
);
expect(output).toEqual({ source: { kind: "directory", path: "/project" }, title: "new" });
});
test("before hooks compose returned requests and preserve the original input", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.before("workspace.create", ({ request }) => {
return { ...request, title: "first" };
});
hooks.before("workspace.create", () => {
return;
});
hooks.before("workspace.create", ({ request }) => {
return { ...request, title: request.title + ":second" };
});
const input = { source: { kind: "directory", path: "/project" } };
expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({
source: { kind: "directory", path: "/project" },
title: "first:second",
});
expect(input).toEqual({ source: { kind: "directory", path: "/project" } });
});
test("teardown aborts an active callback and removes its registrations", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.before("workspace.create", async (_input, context) => {
await new Promise<void>((_resolve, reject) => {
context.signal.addEventListener(
"abort",
() => {
reject(new Error("Hook aborted"));
},
{ once: true },
);
});
});
const invocation = hooks.invoke(
"operation",
"before",
"workspace.create",
{
source: { kind: "directory", path: "/project" },
},
paseo,
);
hooks.close();
await expect(invocation).rejects.toThrow("Hook aborted");
expect(hooks.catalog()).toEqual({ events: [], before: [] });
});
test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.before("agent.session_open", ({ request }) => {
return { ...request, provider: "another-provider" };
});
await expect(
hooks.invoke(
"operation",
"before",
"agent.session_open",
{
agentId: "agent",
workspaceId: "workspace",
provider: "claude",
cwd: "/project",
reason: "resume",
purpose: "interactive",
env: {},
},
paseo,
),
).rejects.toThrow("agent.session_open hooks can only change env");
});
@@ -0,0 +1,273 @@
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent/agent-sdk-types.js";
import { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
import type {
PluginHookAgent,
PluginHookContext,
PluginLifecycleRegistration,
} from "@getpaseo/plugin/server";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type {
PluginBeforeRequests,
PluginHookWorkspace,
PluginLifecycleEvents,
} from "@getpaseo/plugin/server";
import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
export const lifecycleEventNames = [
"agent.created",
"agent.turn_started",
"agent.turn_ended",
"agent.permission_requested",
"agent.permission_resolved",
"agent.archived",
"workspace.created",
"workspace.archived",
] as const;
export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const;
const beforeSchemas = {
"agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(),
"agent.session_open": z
.object({
agentId: z.string(),
workspaceId: z.string().nullable(),
provider: z.string(),
cwd: z.string(),
reason: z.enum(["create", "resume", "refresh", "import"]),
purpose: z.enum(["interactive", "history"]),
env: z.record(z.string(), z.string()),
})
.strict(),
"workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
};
export interface PluginLifecycle {
emit<Name extends keyof PluginLifecycleEvents>(
name: Name,
event: PluginLifecycleEvents[Name],
): void;
before<Name extends keyof PluginBeforeRequests>(
name: Name,
request: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]>;
}
export function validateBeforeRequest<Name extends keyof PluginBeforeRequests>(
name: Name,
value: unknown,
): PluginBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name];
}
export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace {
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
cwd: workspace.cwd,
name: workspace.title,
archivedAt: workspace.archivedAt,
};
}
export function describeHookAgent(agent: {
id: string;
workspaceId?: string;
provider: string;
cwd: string;
title?: string | null;
labels: Record<string, string>;
}): PluginHookAgent {
return {
id: agent.id,
workspaceId: agent.workspaceId ?? null,
parentAgentId: agent.labels[PARENT_AGENT_ID_LABEL] ?? null,
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
};
}
export function publishAgentStream(
lifecycle: PluginLifecycle,
agent: PluginHookAgent,
event: AgentStreamEvent,
timeline: readonly AgentTimelineItem[],
): void {
if (event.type === "turn_started") {
lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null });
} else if (event.type === "turn_completed") {
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline,
outcome: { kind: "completed" },
});
} else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline,
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
});
} else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn_ended", {
agent,
turnId: event.turnId ?? null,
timeline,
outcome: { kind: "canceled", reason: event.reason },
});
} else if (event.type === "permission_requested") {
lifecycle.emit("agent.permission_requested", { agent, request: event.request });
} else if (event.type === "permission_resolved") {
lifecycle.emit("agent.permission_resolved", {
agent,
requestId: event.requestId,
resolution: event.resolution,
});
}
}
export function validateBeforeResult<Name extends keyof PluginBeforeRequests>(
name: Name,
input: PluginBeforeRequests[Name],
output: unknown,
): PluginBeforeRequests[Name] {
const result = validateBeforeRequest(name, output);
if (name === "agent.session_open") {
const previous = beforeSchemas["agent.session_open"].parse(input);
const next = beforeSchemas["agent.session_open"].parse(result);
if (
previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId ||
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose
) {
throw new Error("agent.session_open hooks can only change env");
}
}
if (name === "agent.create") {
const previous = beforeSchemas["agent.create"].parse(input);
const next = beforeSchemas["agent.create"].parse(result);
if (previous.config.cwd !== next.config.cwd) {
throw new Error("agent.create hooks cannot change the workspace directory");
}
}
return result;
}
type Handler = (input: unknown, context: PluginHookContext) => unknown;
export class PluginHookHandlers implements PluginLifecycleRegistration {
private readonly events = new Map<string, Set<Handler>>();
private readonly transforms = new Map<string, Set<Handler>>();
private readonly active = new Map<string, AbortController>();
private readonly changed: () => void;
constructor(changed: () => void) {
this.changed = changed;
}
readonly on: PluginLifecycleRegistration["on"] = (name, handler) => {
if (!lifecycleEventNames.includes(name)) {
throw new Error(`Unknown lifecycle event: ${name}`);
}
return this.register(this.events, name, handler as Handler);
};
readonly before: PluginLifecycleRegistration["before"] = (name, handler) => {
if (!beforeHookNames.includes(name)) {
throw new Error(`Unknown before hook: ${name}`);
}
return this.register(this.transforms, name, handler as Handler);
};
catalog(): { events: string[]; before: string[] } {
return { events: [...this.events.keys()], before: [...this.transforms.keys()] };
}
async invoke(
id: string,
kind: "event" | "before",
name: string,
input: unknown,
paseo: PluginHookContext["paseo"],
): Promise<unknown> {
const controller = new AbortController();
this.active.set(id, controller);
try {
if (kind === "before") {
if (
!beforeHookNames.includes(
name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests,
)
) {
throw new Error(`Unknown before hook: ${name}`);
}
const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests;
let request = validateBeforeRequest(hookName, input);
for (const handler of this.transforms.get(name) ?? []) {
controller.signal.throwIfAborted();
const result = await handler(
{ request: structuredClone(request) },
{ paseo, signal: controller.signal },
);
if (result !== undefined) {
request = validateBeforeResult(hookName, request, result);
}
}
return request;
}
for (const handler of this.events.get(name) ?? []) {
controller.signal.throwIfAborted();
try {
await handler(structuredClone(input), { paseo, signal: controller.signal });
} catch (error) {
console.error(`Lifecycle hook ${name} failed`, error);
}
}
return null;
} finally {
this.active.delete(id);
}
}
cancel(id: string): void {
this.active.get(id)?.abort();
}
close(): void {
for (const controller of this.active.values()) {
controller.abort();
}
this.events.clear();
this.transforms.clear();
}
private register(
registry: Map<string, Set<Handler>>,
name: string,
handler: Handler,
): () => void {
let handlers = registry.get(name);
if (!handlers) {
handlers = new Set();
registry.set(name, handlers);
}
handlers.add(handler);
this.changed();
return () => {
if (!handlers.delete(handler)) {
return;
}
if (handlers.size === 0) {
registry.delete(name);
}
this.changed();
};
}
}
@@ -29,6 +29,8 @@ export type PluginProcessRequest =
providerId: string;
options: ProviderCatalogOptions;
}
| { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown }
| { type: "hook.cancel"; requestId: string }
| { type: "invoke"; requestId: string; method: string; input: unknown }
| {
type: "provider.connect";
@@ -49,7 +51,13 @@ export type PluginProcessRequest =
export type PluginProcessMessage =
| { type: "settings.changed"; settingsId: string }
| { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] }
| { type: "hooks.changed"; hooks: { events: string[]; before: string[] } }
| {
type: "ready";
methods: string[];
providers: PluginProviderMetadata[];
hooks?: { events: string[]; before: string[] };
}
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "fatal"; error: string }
@@ -72,6 +80,8 @@ export type PluginProcessMessage =
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
| { type: "paseo_close" };
const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict();
const providerMetadataSchema = z
.object({
id: z.string().min(1),
@@ -121,6 +131,16 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
]),
})
.strict(),
z
.object({
type: z.literal("hook"),
requestId: z.string(),
kind: z.enum(["event", "before"]),
name: z.string(),
input: z.unknown(),
})
.strict(),
z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(),
z
.object({
type: z.literal("invoke"),
@@ -156,11 +176,13 @@ export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.dis
"type",
[
z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(),
z.object({ type: z.literal("hooks.changed"), hooks: hooksSchema }).strict(),
z
.object({
type: z.literal("ready"),
methods: z.array(z.string()),
providers: z.array(providerMetadataSchema),
hooks: hooksSchema.optional(),
})
.strict(),
z
@@ -1,3 +1,4 @@
import { PluginHookHandlers } from "./lifecycle/index.js";
import {
PluginProcessRequestSchema,
type PluginProcessMessage,
@@ -48,6 +49,9 @@ interface RegisteredRpc {
handler: RpcHandler;
}
const hooks = new PluginHookHandlers(() => {
send({ type: "hooks.changed", hooks: hooks.catalog() });
});
const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const providerConnections = new Map<
@@ -239,7 +243,13 @@ function evaluateBundle(bundle: string): void {
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
const contributedCleanup = setup({ handle: register, registerProvider, registerSettings });
const contributedCleanup = setup({
handle: register,
registerProvider,
registerSettings,
on: hooks.on,
before: hooks.before,
});
if (typeof contributedCleanup !== "function") {
throw new Error("Plugin contribution must return a cleanup function");
}
@@ -274,6 +284,7 @@ async function initialize(message: Extract<PluginProcessRequest, { type: "initia
send({
type: "ready",
methods: [...handlers.keys()].sort(),
hooks: hooks.catalog(),
providers: [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(providerMetadata),
@@ -283,6 +294,7 @@ async function initialize(message: Extract<PluginProcessRequest, { type: "initia
async function shutdown(): Promise<void> {
if (stopping) return;
stopping = true;
hooks.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
cleanup = null;
@@ -397,6 +409,10 @@ process.on("message", (rawMessage: unknown) => {
return;
}
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
if (isHookMessage(message)) {
handleHookMessage(message);
return;
}
const registered = handlers.get(message.method);
if (!registered) {
send({
@@ -418,3 +434,37 @@ process.on("message", (rawMessage: unknown) => {
(error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
});
function handleHookMessage(
message: Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }>,
): void {
if (message.type === "hook.cancel") {
hooks.cancel(message.requestId);
return;
}
if (message.type === "hook") {
if (!paseo) {
send({
type: "error",
requestId: message.requestId,
error: "Plugin Paseo API is unavailable",
});
return;
}
void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then(
(output) => {
return send({ type: "result", requestId: message.requestId, output });
},
(error) => {
return send({ type: "error", requestId: message.requestId, error: describeError(error) });
},
);
return;
}
}
function isHookMessage(
message: PluginProcessRequest,
): message is Extract<PluginProcessRequest, { type: "hook" | "hook.cancel" }> {
return message.type === "hook" || message.type === "hook.cancel";
}
@@ -1,3 +1,5 @@
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
@@ -61,6 +63,7 @@ interface LoadedPlugin {
requirements: PluginRequirements | undefined;
clientBundle: string;
methods: ReadonlySet<string>;
hooks: { events: string[]; before: string[] };
providers: readonly PluginProviderMetadata[];
child: PluginChild | null;
outputCapture: PluginOutputCapture | null;
@@ -423,6 +426,60 @@ export class PluginRuntime {
return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input });
}
emit<Name extends keyof PluginLifecycleEvents>(
name: Name,
event: PluginLifecycleEvents[Name],
): void {
for (const loaded of this.plugins.values()) {
if (!loaded.hooks.events.includes(name)) {
continue;
}
void this.request(loaded, {
type: "hook",
requestId: randomUUID(),
kind: "event",
name,
input: event,
}).catch((error) => {
this.appendLog(
loaded.id,
"stderr",
`Lifecycle hook ${name} failed: ${describeError(error)}`,
);
});
}
}
async before<Name extends keyof PluginBeforeRequests>(
name: Name,
input: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]> {
let request = validateBeforeRequest(name, input);
const plugins = [...this.plugins.values()].sort((left, right) => {
return left.id.localeCompare(right.id);
});
for (const loaded of plugins) {
if (!loaded.hooks.before.includes(name)) {
continue;
}
try {
const output = await this.request(loaded, {
type: "hook",
requestId: randomUUID(),
kind: "before",
name,
input: request,
});
request = validateBeforeResult(name, request, output);
} catch (error) {
throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, {
cause: error,
});
}
}
return request;
}
async getProviderCatalogCacheKey(
pluginId: string,
providerId: string,
@@ -452,6 +509,9 @@ export class PluginRuntime {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
loaded.pending.delete(requestId);
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
@@ -488,6 +548,7 @@ export class PluginRuntime {
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(),
hooks: { events: [], before: [] },
providers: [],
child: null,
outputCapture: null,
@@ -584,6 +645,7 @@ export class PluginRuntime {
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(ready.methods),
hooks: ready.hooks ?? { events: [], before: [] },
providers: ready.providers ?? [],
child,
outputCapture,
@@ -601,6 +663,10 @@ export class PluginRuntime {
}
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "hooks.changed") {
loaded.hooks = message.hooks;
return;
}
if (message.type === "settings.changed") {
this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId);
return;
@@ -489,6 +489,10 @@ test("routes plugin requests and releases its owned catalog subscription on clea
status: "running" as const,
};
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
before: async (_name, request) => {
return request;
},
emit: () => {},
listPlugins: () => [plugin],
getLogs: () => [
{
+8
View File
@@ -479,6 +479,8 @@ export interface SessionOptions {
workspaceAutoName: WorkspaceAutoName;
daemonConfigStore: DaemonConfigStore;
pluginRuntime?: {
before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"];
emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"];
listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[];
getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[];
installDirectory(input: {
@@ -859,6 +861,7 @@ export class Session {
});
this.workspaceAutoName = workspaceAutoName;
this.workspaceProvisioning = createWorkspaceProvisioningService({
lifecycle: this.pluginRuntime,
serverId,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
@@ -6049,6 +6052,11 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
): Promise<void> {
try {
if (this.pluginRuntime) {
const { type, requestId, ...input } = request;
const transformed = await this.pluginRuntime.before("workspace.create", input);
request = { ...transformed, type, requestId };
}
if (request.source.kind === "directory") {
await this.handleWorkspaceCreateLocal(request);
return;
@@ -1,3 +1,5 @@
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
import { basename, resolve } from "node:path";
import type { Logger } from "pino";
import {
@@ -92,6 +94,7 @@ export function createWorkspaceProvisioningService(deps: {
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
logger: Logger;
lifecycle?: PluginLifecycle;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
@@ -216,6 +219,7 @@ export function createWorkspaceProvisioningService(deps: {
updatedAt: timestamp,
});
await workspaceRegistry.upsert(workspace, context);
deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) });
return workspace;
}
@@ -251,6 +255,7 @@ export function createWorkspaceProvisioningService(deps: {
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
});
deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) });
return workspace;
}
@@ -0,0 +1,26 @@
# Agent configuration
Changes new Codex agents through `server.before("agent.create", ...)`:
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
- Sets `config.providerOptions.approval_policy` to `on-request`.
- Adds the `company` server to `config.mcpServers`.
- Preserves other configuration, provider options, MCP servers, and environment overrides.
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name.
Install on a daemon with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
```
The callback receives the request after earlier plugins have transformed it. The nested spreads
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves
the resulting configuration before starting the agent. Existing agents are unchanged.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for ordering
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and
environment injection. Its provider-switch callback clears provider options when changing Codex to
Claude, so use these examples separately when trying the Codex-specific settings.
@@ -0,0 +1,30 @@
import type { PluginServerContext } from "@getpaseo/plugin";
export default function contribute(server: PluginServerContext) {
server.before("agent.create", ({ request }) => {
if (request.config.provider !== "codex") {
return request;
}
return {
...request,
config: {
...request.config,
providerOptions: {
...request.config.providerOptions,
sandbox_mode: "workspace-write",
approval_policy: "on-request",
},
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
},
};
});
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "agent-configuration",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -0,0 +1,32 @@
# Lifecycle actions
Small callbacks using the existing SDK:
- Send `Try again.` when the latest output contains `out of credits`.
- Decline a shell request matching `rm -rf`; approve exactly `git status`; leave other requests alone.
- Inject `PASEO_HOOK_CREATE_EXAMPLE=created` on creation and `PASEO_HOOK_OPEN_EXAMPLE=opened` on each session opening.
- Change requested Codex agents to Claude Haiku before creation.
- Create a worktree when a directory creation request is titled `Isolated lifecycle example`.
This is a demonstration plugin: installing it enables all these behaviors on its host. Install it
alongside `lifecycle-logger` on a test daemon with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-actions
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger
paseo plugin logs lifecycle-logger
```
Copy the callbacks you need into your own plugin. The follow-up example sends a new message; it does
not replay attachments or undo previous tool effects. If the provider keeps returning the matching
phrase, it keeps sending follow-ups. Add limits or delay in your plugin when your workflow needs them.
The command regex illustrates a policy; it does not parse shell syntax or cover every deletion command.
`server/inspect.ts` joins provider text chunks and excludes earlier turns and the current user prompt
from the phrase match. The failing-first regression test records the split text observed with a real
Claude agent.
See [agent configuration](../agent-configuration) for a focused example that injects MCP servers and
changes Codex sandbox and approval options while preserving other settings.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for the API.
@@ -0,0 +1,95 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { latestOutputText, shellCommand } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
server.before("workspace.create", ({ request }) => {
if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") {
return {
...request,
source: {
kind: "worktree",
cwd: request.source.path,
action: "branch-off",
branchName: "lifecycle-example",
},
};
}
return request;
});
server.on("agent.turn_ended", async (event, context) => {
if (event.outcome.kind === "canceled") {
return;
}
const text = latestOutputText(event.timeline);
if (/out of credits/i.test(text)) {
const agent = context.paseo.agents.ref(event.agent.id);
console.log("Sending a follow-up after out of credits", event.agent.id);
await agent.send("Try again.");
}
});
server.on("agent.permission_requested", async (event, context) => {
const command = shellCommand(event.request);
if (command === null) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
if (/\brm\s+-rf\b/.test(command)) {
await agent.respondToPermission({
requestId: event.request.id,
response: {
behavior: "deny",
message: "Recursive deletion is blocked by the example plugin.",
},
});
return;
}
if (command.trim() === "git status") {
await agent.respondToPermission({
requestId: event.request.id,
response: { behavior: "allow" },
});
}
});
server.before("agent.create", ({ request }) => {
return {
...request,
env: {
...request.env,
PASEO_HOOK_CREATE_EXAMPLE: "created",
},
};
});
server.before("agent.create", ({ request }) => {
if (request.config.provider === "codex") {
return {
...request,
config: {
...request.config,
provider: "claude",
model: "haiku",
modeId: "default",
providerOptions: undefined,
},
};
}
return request;
});
server.before("agent.session_open", ({ request }) => {
return {
...request,
env: {
...request.env,
PASEO_HOOK_OPEN_EXAMPLE: "opened",
},
};
});
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "lifecycle-actions",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { latestOutputText } from "./inspect";
test("matches provider text split across timeline chunks without matching old turns or user prompts", () => {
const output = latestOutputText([
{ type: "user_message", text: "An older request" },
{ type: "assistant_message", text: "old out of credits" },
{ type: "user_message", text: "The user mentions out of credits" },
{ type: "assistant_message", text: "out", messageId: "reply" },
{ type: "assistant_message", text: " of credits", messageId: "reply" },
]);
expect(output).toBe("out of credits");
expect(
latestOutputText([
{ type: "assistant_message", text: "out of credits" },
{ type: "user_message", text: "Try again." },
{ type: "assistant_message", text: "Done" },
]),
).toBe("Done");
});
@@ -0,0 +1,28 @@
import type { AgentPermissionRequest, AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export function latestOutputText(timeline: readonly AgentTimelineItem[]): string {
let output = "";
for (const item of timeline) {
if (item.type === "user_message") {
output = "";
} else if ("text" in item && typeof item.text === "string") {
output += item.text;
} else {
output += JSON.stringify(item) + "\n";
}
}
return output;
}
export function shellCommand(request: AgentPermissionRequest): string | null {
if (!request.input) {
return null;
}
if (typeof request.input.command === "string") {
return request.input.command;
}
if (typeof request.input.cmd === "string") {
return request.input.cmd;
}
return null;
}
@@ -0,0 +1,31 @@
# Lifecycle logger
Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation,
session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return
the request unchanged. Environment values are redacted; prompts and timeline content are logged.
Install it on a daemon where plugins are enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger
paseo plugin logs lifecycle-logger
```
The same entries appear in the target daemon's `daemon.log`. The plugin log viewer retains a bounded
tail; use the daemon log for longer captures. Log lines are capped at 16 KiB, so a large timeline can
be truncated.
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for callback
shapes and delivery rules. The real-provider test in
`packages/server/src/server/plugins/lifecycle.real.e2e.test.ts` runs both examples on an isolated daemon
and saves evidence under `.dev/lifecycle-proof/`.
Run it from `packages/server` on Linux with Claude configured:
```bash
npx vitest run src/server/plugins/lifecycle.real.e2e.test.ts --bail=1
```
The test uses real provider calls and a temporary daemon home. Its failure check uses Linux `/proc`
to identify and terminate only its own Claude subprocess. The credits check asks the real agent to
emit the matching phrase; it does not exhaust an account's credits.
@@ -0,0 +1,53 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { logHook } from "./server/log";
export default function contribute(server: PluginServerContext) {
server.before("workspace.create", ({ request }) => {
logHook("before workspace.create", { request });
return request;
});
server.on("workspace.created", (event) => {
logHook("workspace.created", event);
});
server.on("workspace.archived", (event) => {
logHook("workspace.archived", event);
});
server.before("agent.create", ({ request }) => {
logHook("before agent.create", { request });
return request;
});
server.before("agent.session_open", ({ request }) => {
logHook("before agent.session_open", { request });
return request;
});
server.on("agent.created", (event) => {
logHook("agent.created", event);
});
server.on("agent.turn_started", (event) => {
logHook("agent.turn_started", event);
});
server.on("agent.turn_ended", (event) => {
logHook("agent.turn_ended", event);
});
server.on("agent.permission_requested", (event) => {
logHook("agent.permission_requested", event);
});
server.on("agent.permission_resolved", (event) => {
logHook("agent.permission_resolved", event);
});
server.on("agent.archived", (event) => {
logHook("agent.archived", event);
});
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "lifecycle-logger",
"requirements": {
"paseo": ">=0.8.0"
}
}
@@ -0,0 +1,14 @@
export function logHook(hook: string, data: unknown): void {
const entry = { hook, timestamp: new Date().toISOString(), data };
const line = JSON.stringify(entry, (key, value: unknown) => {
if (key === "env" && value !== null && typeof value === "object") {
const redacted: Record<string, string> = {};
for (const name of Object.keys(value)) {
redacted[name] = "[redacted]";
}
return redacted;
}
return value;
});
console.log(line);
}
+321
View File
@@ -230,6 +230,327 @@ export default function contribute(client: PluginClientContext) {
Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown.
## Lifecycle hooks
In `index.server.ts`:
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
export default function contribute(server: PluginServerContext) {
server.on("agent.turn_ended", (event) => {
console.log(event.agent.id, event.outcome);
});
return () => {};
}
```
| Register | Callback receives | Return |
| ------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` or `Promise<void>` |
| `server.before(name, callback)` | `({ request }, { paseo, signal })` | Modified request, or `undefined` to keep it; async supported |
Hooks run on the daemon while the plugin is enabled, even with no app connected.
### Change configuration and inject an MCP server
Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint.
```ts
server.before("agent.create", ({ request }) => {
if (request.config.provider !== "codex") {
return request;
}
return {
...request,
config: {
...request.config,
providerOptions: {
...request.config.providerOptions,
sandbox_mode: "workspace-write",
approval_policy: "on-request",
},
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
},
};
});
```
| Input | Result |
| ------------------------------------------- | ----------------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | Preserved by the spread |
| 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.
Explicit Codex sandbox and approval options override its mode presets.
### Inject environment variables on every session opening
```ts
server.before("agent.session_open", ({ request }) => {
return {
...request,
env: {
...request.env,
COMPANY_ENV: "development",
},
};
});
```
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an
`agent.create` callback instead.
### Choose workspace isolation
```ts
server.before("workspace.create", ({ request }) => {
if (request.source.kind !== "directory") {
return request;
}
return {
...request,
source: {
kind: "worktree",
cwd: request.source.path,
action: "branch-off",
},
};
});
```
**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces
and directory lookup/import operations are unaffected.
### Send a follow-up when a turn ends
Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)
into your plugin. `latestOutputText` joins text chunks after the latest user message.
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { latestOutputText } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
server.on("agent.turn_ended", async (event, context) => {
if (event.outcome.kind === "canceled") {
return;
}
const text = latestOutputText(event.timeline);
if (/out of credits/i.test(text)) {
await context.paseo.agents.ref(event.agent.id).send("Try again.");
}
});
return () => {};
}
```
```text
Turn ends: "out of credits"
→ plugin sends "Try again."
→ a new turn starts
```
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
### Answer a permission request
Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts):
```ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { shellCommand } from "./server/inspect";
export default function contribute(server: PluginServerContext) {
server.on("agent.permission_requested", async (event, context) => {
const command = shellCommand(event.request);
if (command === null) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
if (/\brm\s+-rf\b/.test(command)) {
await agent.respondToPermission({
requestId: event.request.id,
response: { behavior: "deny", message: "Recursive deletion is blocked." },
});
return;
}
if (command.trim() === "git status") {
await agent.respondToPermission({
requestId: event.request.id,
response: { behavior: "allow" },
});
}
});
return () => {};
}
```
| Request | Result |
| ------------------------ | ------------------------- |
| `rm -rf build` | Declined |
| `git status` | Approved |
| Other command or request | Left pending for the user |
| Already-resolved request | SDK response fails |
The regex is an example policy, not a shell parser. Permission requests can also be questions,
plans, and mode changes; requesting permission does not end the turn.
### Events
| Name | Event fields | Trigger |
| ---------------------------- | ---------------------------------------- | -------------------------------------------------- |
| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume |
| `agent.turn_started` | `agent`, `turnId` | Live turn starts |
| `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled |
| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending |
| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared |
| `agent.archived` | `agent`, `archivedAt` | Archive state is saved |
| `workspace.created` | `workspace` | Record created; directory available |
| `workspace.archived` | `workspace` | Archive state is saved |
Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup;
`workspace.created` is not a setup barrier before agent startup.
**Shared payload shapes** (`@getpaseo/plugin/server`):
```ts
interface PluginHookAgent {
id: string;
workspaceId: string | null;
parentAgentId: string | null;
provider: string;
cwd: string;
title: string | null;
}
interface PluginHookWorkspace {
id: string;
projectId: string;
cwd: string;
name: string | null;
archivedAt: string | null;
}
type PluginTurnOutcome =
| { kind: "completed" }
| { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string };
```
| Field | Shape / meaning |
| ------------ | ----------------------------------------------------------------------------------------------------- |
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
| `timeline` | `readonly AgentTimelineItem[]`; complete snapshot including earlier conversation; text may span items |
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
| `resolution` | SDK `AgentPermissionResponse` |
| `archivedAt` | Timestamp string |
### Before hooks
| Name | Request fields | Editable |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
**`agent.create.config`** uses `AgentSessionConfig`:
| Fields | Constraint |
| --------------------------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Provider-specific validated options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
| `internal` | Daemon-owned; cannot change through this hook |
**`agent.session_open` request example:**
```json
{
"agentId": "agent-123",
"workspaceId": "workspace-456",
"provider": "codex",
"cwd": "/projects/shop",
"reason": "resume",
"purpose": "interactive",
"env": { "COMPANY_ENV": "development" }
}
```
| Field | Values |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | String or `null` |
| `reason` | `create`, `resume`, `refresh`, `import` |
| `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
### Ordering and returned values
```text
Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env only)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
| Callback returns | Next callback receives |
| ------------------------------------------------------- | --------------------------------------------------- |
| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced |
| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced |
| `undefined` | Unchanged request |
| 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
saved; environment overrides are not persisted with it.
### Context and cleanup
| Contract | Behavior |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `context.paseo` | Existing SDK connected to this daemon |
| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests |
| Input data | Detached snapshot; change state through returned requests or SDK commands |
| Registration result | Idempotent remover, e.g. `const remove = server.on(...); remove();` |
| Reload, disable, removal, shutdown | Remaining registrations removed |
| Unknown hook name | Registration fails |
| Hook timeout | 30 seconds; aborts the signal. A before hook fails the pending operation; an event handler logs an error. |
| Event-handler error | Logged against plugin; original operation continues |
| Event delivery | Live, best effort; no replay, persistence, or automatic retry |
| Event concurrency | Different events may overlap; callback completion order is not guaranteed |
### Complete examples
| Plugin | Includes |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`.
## Surfaces and sidebar items
Register a component, then point a sidebar item at its surface ID:
+25 -15
View File
@@ -1,6 +1,6 @@
---
name: paseo-plugin
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add a native surface, sidebar item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a native surface, sidebar item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
---
# Paseo plugins
@@ -18,25 +18,35 @@ Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. Select and f
Use the deployed docs when they disagree with this skill. Do not send the user away to read them instead of completing the work.
When working in the Paseo repository, also read `docs/plugins.md` and the relevant example under `plugin-examples/`.
In the Paseo repository, use `public-docs/plugins/v0.8/reference.md` for the checkout's API, including
unreleased changes. Use `docs/plugins.md` for maintainer guidance. Complete contracts belong in the
public docs; this skill indexes the references and examples.
## What a plugin can contribute
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 |
| -------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| 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 → Client slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Contribution | Registration | Use it when | Reference |
| ------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Sidebar surface | `addSurface` + `addSidebarItem` | A full screen of plugin UI reachable from the sidebar | reference.md → Surfaces and sidebar items; `plugin-examples/local-plugin` |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| 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 → Client slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/v0.8/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/v0.8/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
## Create the project