diff --git a/docs/plugins.md b/docs/plugins.md index 3dcf5716e1..76214a6dca 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 628d8ee355..3e06485f19 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -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( definition: import("./settings.js").SettingsDefinition, ): void; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 2f0229acc8..272524de39 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -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"; diff --git a/packages/plugin/src/lifecycle.ts b/packages/plugin/src/lifecycle.ts new file mode 100644 index 0000000000..9115145cde --- /dev/null +++ b/packages/plugin/src/lifecycle.ts @@ -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; +} + +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 }; + "agent.session_open": PluginSessionOpenRequest; + "workspace.create": Omit; +} + +export interface PluginLifecycleRegistration { + on( + name: Name, + handler: ( + event: PluginLifecycleEvents[Name], + context: PluginHookContext, + ) => void | Promise, + ): () => void; + before( + name: Name, + handler: ( + input: { request: PluginBeforeRequests[Name] }, + context: PluginHookContext, + ) => PluginBeforeRequests[Name] | void | Promise, + ): () => void; +} diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server.ts index 8aa3325a83..69c8cc6c4c 100644 --- a/packages/plugin/src/server.ts +++ b/packages/plugin/src/server.ts @@ -1 +1,12 @@ export type { PluginHandlerContext } from "./contracts.js"; + +export type { + PluginBeforeRequests, + PluginHookAgent, + PluginHookContext, + PluginHookWorkspace, + PluginLifecycleEvents, + PluginLifecycleRegistration, + PluginSessionOpenRequest, + PluginTurnOutcome, +} from "./lifecycle.js"; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4d3c1447f7..d16f0cf4cf 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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): AgentLabelPatc } export class AgentManager { + private readonly pluginLifecycle: PluginLifecycle | undefined; private readonly clients = new Map(); private readonly providerEnabled = new Map(); private readonly providerDefinitions = new Map(); @@ -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 { 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 { - 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 { + 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 { 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, + opening?: { + reason: PluginSessionOpenRequest["reason"]; + purpose: PluginSessionOpenRequest["purpose"]; + workspaceId?: string | null; + }, ): Promise { + 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: { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 42121b6404..81aee8aa7f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts new file mode 100644 index 0000000000..adb0c6d2da --- /dev/null +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 459fdc7de8..894b1663c4 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -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 = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { + emit?: PluginLifecycle["emit"]; + before?: PluginLifecycle["before"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; 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); diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts new file mode 100644 index 0000000000..06ced0f347 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..f21d1b86e5 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..7dad32752e --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -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 { + 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 => { + return event.hook === "agent.turn_ended" && event.data.agent.id === agentId; + }); +} + +async function waitForTurns(client: DaemonClient, agentId: string, count: number): Promise { + 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 = { + 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; +} diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts new file mode 100644 index 0000000000..5e30535953 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -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((_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"); +}); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts new file mode 100644 index 0000000000..766ccc0bce --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -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: Name, + event: PluginLifecycleEvents[Name], + ): void; + before( + name: Name, + request: PluginBeforeRequests[Name], + ): Promise; +} + +export function validateBeforeRequest( + 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; +}): 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: 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>(); + private readonly transforms = new Map>(); + private readonly active = new Map(); + + 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 { + 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>, + 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(); + }; + } +} diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 41462b999e..80d925652a 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -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 = 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 = 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 diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 473fcc7e0b..41fe8fcff0 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -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(); const providers = new Map(); 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 left.id.localeCompare(right.id)) .map(providerMetadata), @@ -283,6 +294,7 @@ async function initialize(message: Extract { 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, +): 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 { + return message.type === "hook" || message.type === "hook.cancel"; +} diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 516c320c4f..8902793fcf 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -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; + 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: 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: Name, + input: PluginBeforeRequests[Name], + ): Promise { + 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; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ea16a7f56f..04406b21f0 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -489,6 +489,10 @@ test("routes plugin requests and releases its owned catalog subscription on clea status: "running" as const, }; const pluginRuntime: NonNullable = { + before: async (_name, request) => { + return request; + }, + emit: () => {}, listPlugins: () => [plugin], getLogs: () => [ { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bc59f68ee5..6a6d9ba030 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, ): Promise { 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; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index eb9905016d..a89e044883 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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; 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; } diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md new file mode 100644 index 0000000000..0d283483e2 --- /dev/null +++ b/plugin-examples/agent-configuration/README.md @@ -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. diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts new file mode 100644 index 0000000000..6d74bae0f8 --- /dev/null +++ b/plugin-examples/agent-configuration/index.server.ts @@ -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 () => {}; +} diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json new file mode 100644 index 0000000000..1a049eda62 --- /dev/null +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "agent-configuration", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-actions/README.md b/plugin-examples/lifecycle-actions/README.md new file mode 100644 index 0000000000..0ff23d4cc2 --- /dev/null +++ b/plugin-examples/lifecycle-actions/README.md @@ -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. diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts new file mode 100644 index 0000000000..9ca88734b8 --- /dev/null +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -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 () => {}; +} diff --git a/plugin-examples/lifecycle-actions/paseo-plugin.json b/plugin-examples/lifecycle-actions/paseo-plugin.json new file mode 100644 index 0000000000..fe578fae6d --- /dev/null +++ b/plugin-examples/lifecycle-actions/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "lifecycle-actions", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-actions/server/inspect.test.ts b/plugin-examples/lifecycle-actions/server/inspect.test.ts new file mode 100644 index 0000000000..5a70cc4476 --- /dev/null +++ b/plugin-examples/lifecycle-actions/server/inspect.test.ts @@ -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"); +}); diff --git a/plugin-examples/lifecycle-actions/server/inspect.ts b/plugin-examples/lifecycle-actions/server/inspect.ts new file mode 100644 index 0000000000..8df057c2dc --- /dev/null +++ b/plugin-examples/lifecycle-actions/server/inspect.ts @@ -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; +} diff --git a/plugin-examples/lifecycle-logger/README.md b/plugin-examples/lifecycle-logger/README.md new file mode 100644 index 0000000000..de943c4d43 --- /dev/null +++ b/plugin-examples/lifecycle-logger/README.md @@ -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. diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts new file mode 100644 index 0000000000..92f5ca85b5 --- /dev/null +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -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 () => {}; +} diff --git a/plugin-examples/lifecycle-logger/paseo-plugin.json b/plugin-examples/lifecycle-logger/paseo-plugin.json new file mode 100644 index 0000000000..8385fbf14c --- /dev/null +++ b/plugin-examples/lifecycle-logger/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "lifecycle-logger", + "requirements": { + "paseo": ">=0.8.0" + } +} diff --git a/plugin-examples/lifecycle-logger/server/log.ts b/plugin-examples/lifecycle-logger/server/log.ts new file mode 100644 index 0000000000..e6557700c0 --- /dev/null +++ b/plugin-examples/lifecycle-logger/server/log.ts @@ -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 = {}; + for (const name of Object.keys(value)) { + redacted[name] = "[redacted]"; + } + return redacted; + } + return value; + }); + console.log(line); +} diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index a4529d013a..eeb703ae82 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -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` | +| `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: diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 69349fd90c..cc2c4eddc7 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -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