From 986687b8fd46c996df8c561af40e005667555508 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Oct 2026 17:04:52 +0700 Subject: [PATCH] feat(plugins): unify lifecycle hooks and await worktree initialization (#8081) * refactor(plugins): make lifecycle hook timing explicit Use one registration method and subject.operation.step names so plugin authors can read whether an operation waits. Preserve old registrations through a single SDK adapter and keep timing and payloads unchanged. * fix(plugins): align hook notes and example fixtures with release contracts Keep the changelog inside its versioned beta entry for app parsing. Run current plugin examples against the checkout version so their declared requirements remain valid. * docs(plugins): align translated hook dependency guidance * fix(plugins): align hook exports and release documentation * feat(plugins): use lifecycle positions and await worktree initialization * test(plugins): cover first-agent worktree initialization * test(plugins): verify rollback against workspace catalog entries --- CHANGELOG.md | 2 + CHANGELOG.zh-CN.md | 2 + docs/glossary.md | 1 + docs/plugins.md | 21 +- packages/plugin/package.json | 5 + packages/plugin/src/boundaries.test.ts | 50 +- packages/plugin/src/server/index.ts | 6 + packages/plugin/src/server/lifecycle.ts | 131 +++- .../src/server/agent/agent-manager.test.ts | 8 +- .../server/src/server/agent/agent-manager.ts | 10 +- packages/server/src/server/bootstrap.ts | 3 +- .../src/server/paseo-worktree-service.ts | 15 +- .../plugins/agent-configuration.e2e.test.ts | 4 +- .../server/plugins/agent-tools.e2e.test.ts | 26 +- packages/server/src/server/plugins/index.ts | 5 + .../plugins/lifecycle-archive.e2e.test.ts | 24 +- .../src/server/plugins/lifecycle.e2e.test.ts | 385 +++++++++-- .../server/plugins/lifecycle.real.e2e.test.ts | 63 +- .../server/plugins/lifecycle/handlers.test.ts | 148 ++++- .../src/server/plugins/lifecycle/index.ts | 152 ++--- .../server/plugins/plugin-process-protocol.ts | 9 +- .../src/server/plugins/plugin-process.ts | 3 +- .../src/server/plugins/runtime.posix.test.ts | 7 +- packages/server/src/server/plugins/runtime.ts | 60 +- packages/server/src/server/session.ts | 4 +- .../workspace-provisioning-service.test.ts | 2 +- .../workspace-provisioning-service.ts | 4 +- .../src/plugin-docs-navigation.test.ts | 3 +- plugin-examples/agent-configuration/README.md | 8 +- .../agent-configuration/index.server.ts | 4 +- .../agent-configuration/paseo-plugin.json | 2 +- plugin-examples/agent-tools/README.md | 2 +- plugin-examples/agent-tools/index.server.ts | 2 +- plugin-examples/agent-tools/paseo-plugin.json | 2 +- .../lifecycle-actions/index.server.ts | 12 +- .../lifecycle-actions/paseo-plugin.json | 2 +- .../lifecycle-logger/index.server.ts | 52 +- .../lifecycle-logger/paseo-plugin.json | 2 +- public-docs/plugins/hooks.md | 618 ++++++++++++++++++ public-docs/plugins/hooks.zh-CN.md | 600 +++++++++++++++++ public-docs/plugins/reference.md | 485 +------------- public-docs/plugins/reference.zh-CN.md | 461 +------------ public-docs/worktrees.md | 2 + public-docs/worktrees.zh-CN.md | 2 + 44 files changed, 2139 insertions(+), 1270 deletions(-) create mode 100644 public-docs/plugins/hooks.md create mode 100644 public-docs/plugins/hooks.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 18454f14e6..162040ca45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Voice settings now let you configure speech providers and download local models ### Improved +- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081)) + - Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 51f974221f..062e46cd13 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -21,6 +21,8 @@ ### 改进 +- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) + - 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) diff --git a/docs/glossary.md b/docs/glossary.md index 4bb6391870..8c9f54896c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -34,6 +34,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). +- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). - **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). diff --git a/docs/plugins.md b/docs/plugins.md index 40c138b082..fe5190e3bc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -436,21 +436,16 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho ## Lifecycle hooks -Server entries register lifecycle observers with `server.on()` and request transforms with -`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) -owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved -configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`, -re-derived on each open and following the plugin's lifecycle. See -[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook. -`plugin-examples/lifecycle-logger` registers all -eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. -Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents), -as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session; -executable definitions stay in the plugin process. +Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup +function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook +registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md) +owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved +configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on +every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary. 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. +live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt +or answer a permission through its own daemon session. Awaiting it there deadlocks that command. ## Contribute a provider diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a55ca023ac..15c6f633e0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -52,6 +52,11 @@ "types": "./dist/client/index.d.ts", "source": "./src/client/index.ts", "default": "./dist/client/index.js" + }, + "./internal/lifecycle": { + "types": "./dist/server/lifecycle.d.ts", + "source": "./src/server/lifecycle.ts", + "default": "./dist/server/lifecycle.js" } }, "publishConfig": { diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 56f0d0d78c..1699451858 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -13,6 +13,7 @@ const entries = { "./server/provider": "server", "./server/usage": "server", "./server/acp": "server", + "./internal/lifecycle": "server", "./client": "client", "./client/host": "client", "./client/ui": "client", @@ -134,12 +135,8 @@ describe("plugin SDK import boundaries", () => { it.each(Object.entries(entries))( "%s respects its %s boundary, including type dependencies", (specifier, runtime) => { - const name = - specifier === "." - ? "index" - : specifier.slice(2) + - (specifier === "./client" || specifier === "./server" ? "/index" : ""); - expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source); + expect(boundaryViolations(file, runtime)).toEqual([]); }, ); }); @@ -170,6 +167,7 @@ describe("plugin example import boundaries", () => { const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; if ( entry === "./client/host" || + entry.startsWith("./internal/") || !(entry in entries) || (entries[entry] !== "shared" && entries[entry] !== runtime) ) @@ -233,7 +231,7 @@ describe("plugin agent tools SDK boundary", () => { }, }); export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { const title: string | null = request.title; // @ts-expect-error labels are read-only request.labels.x = "changed"; @@ -251,6 +249,44 @@ describe("plugin agent tools SDK boundary", () => { }); }); +describe("lifecycle hook SDK types", () => { + it("infers payloads and replacement requests and requires async handlers and new names", () => { + expect( + typeErrors(` + import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server"; + export default function contribute(server: PluginServerContext) { + server.addHook("worktree.create.after", async ({ worktree }) => { + const path: string = worktree.path; + const branch: string = worktree.branch; + const source: string = worktree.sourceCheckout; + // @ts-expect-error Worktree facts have no workspace identity. + worktree.workspaceId; + }); + server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" })); + server.addHook("workspace.create.before", async () => {}); + server.addHook("agent.open.before", async () => {}); + server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => { + const kind: "completed" | "failed" | "canceled" = outcome.kind; + // @ts-expect-error Notification payloads have no creation request. + outcome.request; + }); + // @ts-expect-error Async handlers are required even for notifications. + server.addHook("agent.turn.before", () => {}); + // @ts-expect-error Legacy names are only accepted by deprecated registration methods. + server.addHook("agent.session_open", async () => {}); + // @ts-expect-error Unknown names fail typechecking. + server.addHook("nonsense", async () => {}); + // @ts-expect-error A prepare handler must return the whole request or undefined. + server.addHook("agent.create.before", async () => ({ invalid: true })); + server.before("agent.session_open", ({ request }) => request); + server.on("agent.turn_ended", () => {}); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + describe("agent session reload SDK boundary", () => { it("exposes reload separately from snapshot refresh", () => { expect( diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 264effafdf..3c1559b775 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -13,8 +13,14 @@ export type { PluginSettingsState, } from "./contracts.js"; export type { + PluginHooks, + PluginHookEvents, + PluginHookRequests, + PluginHookPayload, + PluginHookResult, PluginHookContext, PluginHookWorkspace, + PluginHookWorktree, PluginHookAgent, PluginSessionOpenRequest, PluginTurnOutcome, diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 9486383072..339529347a 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -21,6 +21,12 @@ export interface PluginHookWorkspace { archivedAt: string | null; } +export interface PluginHookWorktree { + path: string; + branch: string; + sourceCheckout: string; +} + export interface PluginHookAgent { id: string; workspaceId: string | null; @@ -49,9 +55,9 @@ export type PluginTurnOutcome = | { 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": { +export interface PluginHookEvents { + "agent.turn.before": { agent: PluginHookAgent; turnId: string | null }; + "agent.turn.after": { agent: PluginHookAgent; turnId: string | null; outcome: PluginTurnOutcome; @@ -61,26 +67,57 @@ export interface PluginLifecycleEvents { */ timeline: readonly AgentTimelineItem[]; }; - "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; - "agent.permission_resolved": { + "agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest }; + "agent.permission.after": { agent: PluginHookAgent; requestId: string; resolution: AgentPermissionResponse; }; - "agent.archived": { agent: PluginHookAgent; archivedAt: string }; - "agent.created": { agent: PluginHookAgent }; - "agent.closed": { agent: PluginHookAgent }; - "workspace.created": { workspace: PluginHookWorkspace }; - "workspace.archived": { workspace: PluginHookWorkspace }; + "agent.archive.after": { agent: PluginHookAgent; archivedAt: string }; + "agent.create.after": { agent: PluginHookAgent }; + "agent.close.after": { agent: PluginHookAgent }; + "workspace.create.after": { workspace: PluginHookWorkspace }; + "workspace.archive.after": { workspace: PluginHookWorkspace }; } -export interface PluginBeforeRequests { - "agent.create": { config: AgentSessionConfig; env?: Record }; - "agent.session_open": PluginSessionOpenRequest; - "workspace.create": Omit; +export interface PluginHookRequests { + "agent.create.before": { config: AgentSessionConfig; env?: Record }; + "agent.open.before": PluginSessionOpenRequest; + "workspace.create.before": Omit; } +export type PluginHooks = { + [Name in keyof PluginHookRequests]: { + request: PluginHookRequests[Name]; + result: PluginHookRequests[Name]; + }; +} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & { + "worktree.create.after": { payload: { worktree: PluginHookWorktree } }; +}; + +export type PluginHookPayload = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request } + ? { request: Request } + : PluginHooks[Key] extends { payload: infer Payload } + ? Payload + : never; +}[Name]; +export type PluginHookResult = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result } + ? Result | void + : void; +}[Name]; + +type HookHandlers = { + [Name in keyof PluginHooks]: ( + payload: PluginHookPayload, + context: PluginHookContext, + ) => Promise>; +}; + export interface PluginLifecycleRegistration { + addHook(name: Name, handler: HookHandlers[Name]): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ on( name: Name, handler: ( @@ -88,6 +125,7 @@ export interface PluginLifecycleRegistration { context: PluginHookContext, ) => void | Promise, ): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ before( name: Name, handler: ( @@ -96,3 +134,68 @@ export interface PluginLifecycleRegistration { ) => PluginBeforeRequests[Name] | void | Promise, ): () => void; } + +/** @internal Lifecycle policy shared by the daemon and plugin process. */ +export const pluginHookRegistry = { + "workspace.create.before": { waits: true }, + "workspace.create.after": { waits: false }, + "workspace.archive.after": { waits: false }, + "worktree.create.after": { waits: true }, + "agent.create.before": { waits: true }, + "agent.create.after": { waits: false }, + "agent.open.before": { waits: true }, + "agent.close.after": { waits: false }, + "agent.archive.after": { waits: false }, + "agent.turn.before": { waits: false }, + "agent.turn.after": { waits: false }, + "agent.permission.before": { waits: false }, + "agent.permission.after": { waits: false }, +} as const satisfies Record; + +// COMPAT(pluginHookNames): added in v0.14, remove after 2027-04-30 +const legacyEvents = { + "workspace.created": "workspace.create.after", + "workspace.archived": "workspace.archive.after", + "agent.created": "agent.create.after", + "agent.closed": "agent.close.after", + "agent.archived": "agent.archive.after", + "agent.turn_started": "agent.turn.before", + "agent.turn_ended": "agent.turn.after", + "agent.permission_requested": "agent.permission.before", + "agent.permission_resolved": "agent.permission.after", +} as const; +const legacyRequests = { + "workspace.create": "workspace.create.before", + "agent.create": "agent.create.before", + "agent.session_open": "agent.open.before", +} as const; +export type PluginLifecycleEvents = { + [Name in keyof typeof legacyEvents]: PluginHookEvents[(typeof legacyEvents)[Name]]; +}; +export type PluginBeforeRequests = { + [Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]]; +}; + +/** @internal SDK adapter for the daemon's hook registry. */ +export function createLifecycleRegistration( + register: ( + name: keyof PluginHooks, + handler: (input: never, context: PluginHookContext) => unknown, + ) => () => void, +): PluginLifecycleRegistration { + const names = new Set(Object.keys(pluginHookRegistry)); + return { + addHook(name, handler) { + if (!names.has(name)) throw new Error(`Unknown lifecycle hook: ${name}`); + return register(name, handler); + }, + on(name, handler) { + if (!Object.hasOwn(legacyEvents, name)) throw new Error(`Unknown lifecycle event: ${name}`); + return register(legacyEvents[name], handler); + }, + before(name, handler) { + if (!Object.hasOwn(legacyRequests, name)) throw new Error(`Unknown before hook: ${name}`); + return register(legacyRequests[name], handler); + }, + }; +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2d21bdc63c..eb1c05b50f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7807,7 +7807,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); expect(await storage.get(agentId)).not.toBeNull(); // Workspace ownership preserves ordinary agent lifecycle hooks. - expect(beforeHookCalls).toContain("agent.create"); + expect(beforeHookCalls).toContain("agent.create.before"); }); test("global subscribers receive internal agent events only when they opt in", async () => { @@ -12072,15 +12072,15 @@ test.each([ const session = client.sessions[0]!; session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" }); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_started").length) + .poll(() => events.filter(({ name }) => name === "agent.turn.before").length) .toBe(1); expect(timelineStore.itemReads).toBe(readsBeforeDispatch); session.pushEvent(terminalEvent); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_ended")) + .poll(() => events.filter(({ name }) => name === "agent.turn.after")) .toEqual([ { - name: "agent.turn_ended", + name: "agent.turn.after", event: { agent: { id: agent.id, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 8cbdfc068e..966542ff62 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1358,7 +1358,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); if (this.pluginLifecycle && !config.internal) { - const request = await this.pluginLifecycle.before("agent.create", { + const request = await this.pluginLifecycle.before("agent.create.before", { config, env: options.env, }); @@ -1402,7 +1402,7 @@ export class AgentManager { historyPrimed: true, }); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.created", { + this.pluginLifecycle?.emit("agent.create.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -2114,7 +2114,7 @@ export class AgentManager { const archivedRecord = buildArchivedAgentRecord(record, options); await this.requireRegistry().upsert(archivedRecord); if (!record.archivedAt && !record.internal) { - this.pluginLifecycle?.emit("agent.archived", { + this.pluginLifecycle?.emit("agent.archive.after", { agent: describeHookAgent(archivedRecord), archivedAt: archivedRecord.archivedAt, }); @@ -4051,7 +4051,7 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.closed", { + this.pluginLifecycle?.emit("agent.close.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -5552,7 +5552,7 @@ export class AgentManager { tools: [], mcpServers: {}, }; - const transformed = await this.pluginLifecycle.before("agent.session_open", request); + const transformed = await this.pluginLifecycle.before("agent.open.before", request); env = transformed.env; if (request.purpose === "interactive") { this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 83ef2acb83..b3eb6f0f66 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -957,7 +957,7 @@ export async function createPaseoDaemon( if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); } if (mutation.kind === "archive" && mutation.workspace) { - pluginRuntime.emit("workspace.archived", { + pluginRuntime.emit("workspace.archive.after", { workspace: describeHookWorkspace(mutation.workspace), }); } @@ -1206,6 +1206,7 @@ export async function createPaseoDaemon( worktreesRoot: config.worktreesRoot, createPaseoWorktree: async (workflowInput, workflowOptions) => { return createRegisteredPaseoWorktree(workflowInput, { + lifecycle: pluginRuntime, github, ...(workflowOptions?.resolveDefaultBranch ? { diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 4080efd580..9b08e487e9 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,3 +1,4 @@ +import type { PluginLifecycle } from "./plugins/lifecycle/index.js"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; @@ -59,6 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult { } export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { + lifecycle?: Pick; workspaceGitService: WorkspaceGitService; workspaceProvisioning: Pick; } @@ -77,6 +79,15 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { + if (createdWorktree.created) { + await deps.lifecycle?.wait("worktree.create.after", { + worktree: { + path: createdWorktree.worktree.worktreePath, + branch: createdWorktree.worktree.branchName, + sourceCheckout: workspaceCwdPlan.sourceCheckout, + }, + }); + } await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, @@ -155,7 +166,7 @@ async function isDirectory(targetPath: string): Promise { async function planWorkspaceCwdForWorktree( inputCwd: string, workspaceGitService: Pick, -): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> { +): Promise<{ inputCwd: string; relativeWorkspaceCwd: string; sourceCheckout: string }> { const normalizedInputCwd = resolve(inputCwd); const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; @@ -163,7 +174,7 @@ async function planWorkspaceCwdForWorktree( if (relativeWorkspaceCwd === null) { throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); } - return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd }; + return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd, sourceCheckout: sourceWorktreePath }; } export async function attemptFirstAgentBranchAutoName(options: { diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index 1cfad77e05..9f62075125 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -12,7 +12,7 @@ test("the configuration example saves Codex options and injects launch-only MCP const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true, @@ -25,7 +25,7 @@ test("the configuration example saves Codex options and injects launch-only MCP }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.13.2", + appVersion: "0.14.0-beta.1", }); try { await client.connect(); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts index 5b03bae09b..b652c569fe 100644 --- a/packages/server/src/server/plugins/agent-tools.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -80,7 +80,7 @@ const tool = defineAgentTool({ }, }); export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels.x !== "y") return; return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] }; }); @@ -97,14 +97,14 @@ async function setup( const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-")); const recorder = recordingProvider(native, providerOptions); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: recorder.provider }, mcpEnabled: true, providerOverrides: { codex: { paseoTools: policy } }, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.13.2", + appVersion: "0.14.0-beta.1", }); const mcps: Client[] = []; async function install(id: string, code: string) { @@ -112,7 +112,7 @@ async function setup( await mkdir(pluginDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }), + JSON.stringify({ id, requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile(path.join(pluginDirectory, "index.server.ts"), code); await client.installDirectoryPlugin(pluginDirectory); @@ -305,7 +305,7 @@ test("each plugin owns its tool array and callbacks can remove earlier attachmen const source = pluginSource.replace( " return () => {};", ` - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); return { ...request, tools: [] }; }); @@ -501,12 +501,12 @@ test("built-in plugins attach and execute tools while configured plugins are dis await mkdir(directory); await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }), + JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); const recorder = recordingProvider(true); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.13.2", + daemonVersion: "0.14.0-beta.1", agentClients: { codex: recorder.provider }, pluginsEnabled: false, builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), @@ -762,7 +762,7 @@ test("the agent-tools example appends notes only for labeled agents", async () = const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; const mcpSource = ` export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.provider !== "codex") return; return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env: { ...request.env, OPEN_REASON: request.reason } }; @@ -993,16 +993,16 @@ test("session-open MCP maps are plugin-local and accumulate only across their ow "z-extend", ` export default function contribute(server) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; }); - server.before("agent.session_open", () => undefined); + server.addHook("agent.open.before", async () => undefined); return () => {}; }`, ); @@ -1016,7 +1016,7 @@ export default function contribute(server) { await h.install( "zz-remove", `export default function contribute(server) { - server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} })); + server.addHook("agent.open.before", async ({ request }) => ({ ...request, mcpServers: {} })); return () => {}; }`, ); @@ -1034,7 +1034,7 @@ test("session-open MCP configs are validated before a provider launches", async mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), ); try { - await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s); + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*agent.open.before/s); expect(h.launches).toHaveLength(0); } finally { await h.close(); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 98e7ce5623..5dad547d5c 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -45,6 +45,7 @@ interface PluginRuntimePort { emit?: PluginLifecycle["emit"]; drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; + wait?: PluginLifecycle["wait"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; @@ -168,6 +169,10 @@ export class PluginService { return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); }; + readonly wait: PluginLifecycle["wait"] = async (name, payload) => { + await this.runtime.wait?.(name, payload); + }; + async drainEvents(): Promise { await this.runtime.drainEvents?.(); } diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 6f3d8661e7..1b05b56b53 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -8,8 +8,11 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; test("closing and archiving agents publish their distinct lifecycle hooks once", 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" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -39,7 +42,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id); }) .toEqual([closed.id]); @@ -49,7 +52,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", const logs = await client.getPluginLogs("lifecycle-logger"); return logs .filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }) .map((entry) => { return JSON.parse(entry.message).data.agent.id; @@ -61,7 +64,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id) .sort(); }) @@ -75,20 +78,23 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); - 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" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); const completedFile = path.join(directory, "closed.txt"); try { await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile( path.join(directory, "index.server.ts"), `import { writeFile } from "node:fs/promises"; import { setTimeout } from "node:timers/promises"; export default function contribute(server) { - server.on("agent.closed", async (event, context) => { + server.addHook("agent.close.after", async (event, context) => { await setTimeout(200, undefined, { signal: context.signal }); await writeFile(${JSON.stringify(completedFile)}, event.agent.id); }); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 9c75f63746..d91b5f39bf 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -74,6 +75,76 @@ export default function contribute(server) { } }, 60_000); +test("addHook transforms workspace creation once across receipt replays 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.addHook("workspace.create.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); + return { ...request, title: "Created through a hook" }; + }); + server.addHook("workspace.create.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.create.after", event })); + }); + server.addHook("workspace.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.archive.after", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(created.workspace?.name).toBe("Created through a hook"); + const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + 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.before" }, + { + hook: "workspace.create.after", + event: { workspace: { id: workspaceId, cwd: directory } }, + }, + { hook: "workspace.archive.after", 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" }); @@ -87,24 +158,24 @@ test("plugins observe turns, answer permissions, and observe archive without blo 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.addHook("agent.turn.before", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.before", event })); }); - server.on("agent.turn_ended", (event) => { - console.log(JSON.stringify({ hook: "agent.turn_ended", event })); + server.addHook("agent.turn.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.after", event })); }); - server.on("agent.permission_requested", async (event, context) => { - console.log(JSON.stringify({ hook: "agent.permission_requested", event })); + server.addHook("agent.permission.before", async (event, context) => { + console.log(JSON.stringify({ hook: "agent.permission.before", 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.addHook("agent.permission.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.permission.after", event })); }); - server.on("agent.archived", (event) => { - console.log(JSON.stringify({ hook: "agent.archived", event })); + server.addHook("agent.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.archive.after", event })); }); return () => {}; } @@ -139,16 +210,16 @@ export default function contribute(server) { { timeout: 10_000 }, ) .toMatchObject([ + { hook: "agent.permission.after", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.permission_requested", + hook: "agent.permission.before", event: { request: { input: { command: "rm -f permission.txt" } } }, }, - { hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.turn_ended", + hook: "agent.turn.after", event: { outcome: { kind: "completed" }, timeline: [] }, }, - { hook: "agent.turn_started", event: { agent: { id: agent.id } } }, + { hook: "agent.turn.before", event: { agent: { id: agent.id } } }, ]); const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 }); expect(page.entries.length).toBeGreaterThan(0); @@ -157,7 +228,7 @@ export default function contribute(server) { .poll(async () => { const logs = await client.getPluginLogs("turn-hooks"); return logs.filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }).length; }) .toBe(1); @@ -170,44 +241,41 @@ export default function contribute(server) { 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" } }; + const pluginDirectory = await createProviderPlugin(directory, { + acknowledgeClose: true, + hooks: ` + server.addHook("agent.create.before", async ({ request }) => { + return { ...request, config: { ...request.config, provider: "${PROVIDER_ID}", model: "shutdown-model" }, env: { ...request.env, HOOK_CREATE: "created" } }; }); - server.before("agent.session_open", ({ request }) => { - console.log(JSON.stringify({ hook: "agent.session_open", request })); + server.addHook("agent.open.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "agent.open.before", request })); return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; }); - server.on("agent.created", (event) => { - console.log(JSON.stringify({ hook: "agent.created", event })); + server.addHook("agent.create.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.create.after", event })); + });`, + reportOpen: true, }); - return () => {}; -} -`, - ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { await client.connect(); await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); - await client.installDirectoryPlugin(directory); + await client.installDirectoryPlugin(pluginDirectory); const agent = await client.createAgent({ provider: "claude", cwd: directory, title: "Hook test", }); - expect(agent.provider).toBe("codex"); + expect(agent.provider).toBe(PROVIDER_ID); + expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({ + provider: PROVIDER_ID, + config: { model: "shutdown-model" }, + }); await expect .poll(async () => { - const logs = await client.getPluginLogs("agent-hooks"); + const logs = await client.getPluginLogs("shutdown-provider-plugin"); return logs .filter((entry) => { return entry.message.startsWith('{"hook":'); @@ -218,10 +286,17 @@ export default function contribute(server) { }) .toMatchObject([ { - hook: "agent.session_open", + hook: "agent.open.before", request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, }, - { hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } }, + { + hook: "provider.open", + config: { env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" } }, + }, + { + hook: "agent.create.after", + event: { agent: { id: agent.id, provider: PROVIDER_ID } }, + }, ]); await client.archiveAgent(agent.id); } finally { @@ -244,10 +319,10 @@ test("invalid output from an untyped plugin rejects creation before later callba path.join(directory, "index.server.ts"), ` export default function contribute(server) { - server.before("workspace.create", () => { + server.addHook("workspace.create.before", async () => { return { source: { kind: "worktree", branchName: 42 } }; }); - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { console.log("Unexpected later callback"); return request; }); @@ -260,7 +335,7 @@ export default function contribute(server) { 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"); + expect(result.error).toContain("Plugin invalid-hook workspace.create.before failed"); const logs = await client.getPluginLogs("invalid-hook"); expect( logs.some((entry) => { @@ -278,7 +353,9 @@ const PROVIDER_ID = "shutdown-provider"; async function createProviderPlugin( root: string, - options: { acknowledgeClose: boolean } = { acknowledgeClose: true }, + options: { acknowledgeClose: boolean; hooks?: string; reportOpen?: boolean } = { + acknowledgeClose: true, + }, ): Promise { const directory = path.join(root, "plugin"); await mkdir(directory, { recursive: true }); @@ -324,6 +401,7 @@ const provider: ProviderRegistration = { return; } if (input.type === "session.open") { + if (${JSON.stringify(options.reportOpen ?? false)}) console.log(JSON.stringify({ hook: "provider.open", config: input.config })); emit({ type: "session.opened", requestId: input.requestId, @@ -375,6 +453,7 @@ const provider: ProviderRegistration = { export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) { server.registerProvider(provider); +${options.hooks ?? ""} return () => undefined; }`, ); @@ -586,3 +665,217 @@ test("reloading a plugin does not send on a closed IPC channel", async () => { await rm(pluginDirectory, { recursive: true, force: true }); } }, 60_000); + +test("waiting hook failures reject the operation while notification failures are logged and later handlers run", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hook-failures-")); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "hook-failures", + requirements: { paseo: ">=0.14.0-beta.1" }, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.addHook("workspace.create.before", async ({ request }) => { + if (request.title === "reject") throw new Error("waiting hook rejected"); + return request; + }); + server.addHook("workspace.create.after", async () => { + throw new Error("notification rejected"); + }); + server.addHook("workspace.create.after", async ({ workspace }) => { + console.log("Later notification for " + workspace.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const failed = await client.createWorkspace({ + title: "reject", + source: { kind: "directory", path: directory }, + }); + expect(failed.workspace).toBeNull(); + expect(failed.error).toContain( + "Plugin hook-failures workspace.create.before failed: waiting hook rejected", + ); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceDirectory).toBe(directory); + await expect + .poll(async () => + (await client.getPluginLogs("hook-failures")).map(({ message }) => message).join("\n"), + ) + .toContain("Later notification for " + created.workspace!.id); + const messages = (await client.getPluginLogs("hook-failures")) + .map(({ message }) => message) + .join("\n"); + expect(messages).toContain("Lifecycle hook workspace.create.after failed"); + expect(messages).toContain("notification rejected"); + expect(messages).toContain("Later notification for " + created.workspace!.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test.each([false, true])( + "worktree creation waits for plugins before its first agent and rolls back failures (firstAgentContext: %s)", + async (withFirstAgentContext) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-hook-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + const orderFile = path.join(root, "order.jsonl"); + const pluginDirectory = await createProviderPlugin(root, { + hooks: ` + server.addHook("worktree.create.after", async ({ worktree }) => { + const { appendFile, writeFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n"); + if (worktree.branch === "reject-hook") throw new Error("worktree rejected"); + await writeFile(worktree.path + "/plugin-ready.txt", "ready"); + }); + server.addHook("workspace.create.after", async () => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "workspace.create.after" }) + "\\n"); + }); + server.addHook("agent.create.before", async ({ request }) => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "agent.create.before" }) + "\\n"); + return request; + }); + server.addHook("agent.open.before", async ({ request }) => { + const { readFile } = await import("node:fs/promises"); + const contents = await readFile(request.cwd + "/plugin-ready.txt", "utf8"); + if (contents !== "ready") throw new Error("missing plugin output"); + console.log(JSON.stringify({ hook: "first-agent-ready", agentId: request.agentId, cwd: request.cwd, contents })); + return request; + });`, + }); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + const installed = await client.installDirectoryPlugin(pluginDirectory); + expect(installed.status).toBe("running"); + const created = await client.createWorkspace({ + ...(withFirstAgentContext + ? { + firstAgentContext: { prompt: "Check the initialized worktree" }, + agent: { + provider: PROVIDER_ID, + cwd: repository, + initialPrompt: "Check the initialized worktree", + }, + } + : {}), + source: { + kind: "worktree", + cwd: repository, + branchName: "accept-hook", + worktreeSlug: "accept-hook", + }, + }); + expect(created.error).toBeNull(); + const cwd = created.workspace!.workspaceDirectory; + expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready"); + const firstAgent = withFirstAgentContext + ? created.agent! + : await client.createAgent({ + provider: PROVIDER_ID, + cwd, + workspaceId: created.workspace!.id, + }); + expect(firstAgent).toMatchObject({ + cwd, + workspaceId: created.workspace!.id, + provider: PROVIDER_ID, + }); + await expect + .poll(async () => + (await client.getPluginLogs("shutdown-provider-plugin")) + .filter(({ message }) => message.startsWith('{"hook":"first-agent-ready"')) + .map(({ message }) => JSON.parse(message)), + ) + .toContainEqual({ + hook: "first-agent-ready", + agentId: firstAgent.id, + cwd, + contents: "ready", + }); + const records = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.map((record) => record.hook)).toEqual([ + "worktree.create.after", + "workspace.create.after", + "agent.create.before", + ]); + expect(records[0].worktree).toEqual({ + path: cwd, + branch: "accept-hook", + sourceCheckout: repository, + }); + const beforeFailure = await client.fetchWorkspaces(); + const rejected = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "reject-hook", + worktreeSlug: "reject-hook", + }, + }); + expect(rejected.workspace).toBeNull(); + expect(rejected.error).toContain( + "Plugin shutdown-provider-plugin worktree.create.after failed: worktree rejected", + ); + const failedRecords = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const failedPath = failedRecords.at(-1).worktree.path; + await expect(stat(failedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual( + beforeFailure.entries.map(({ id }) => id).sort(), + ); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }), + ).not.toContain(failedPath); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { 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 index fc9cfbd84d..40de1215d8 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -6,7 +6,7 @@ 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 type { PluginHookRequests, PluginHookEvents } 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"; @@ -14,14 +14,14 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; type RecordedHook = | { - [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] }; - }[keyof PluginLifecycleEvents] + [Name in keyof PluginHookEvents]: { hook: Name; data: PluginHookEvents[Name] }; + }[keyof PluginHookEvents] | { - [Name in keyof PluginBeforeRequests]: { - hook: `before ${Name}`; - data: { request: PluginBeforeRequests[Name] }; + [Name in keyof PluginHookRequests]: { + hook: Name; + data: { request: PluginHookRequests[Name] }; }; - }[keyof PluginBeforeRequests]; + }[keyof PluginHookRequests]; const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); @@ -39,8 +39,8 @@ async function readHooks(client: DaemonClient): Promise { 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; + return events.filter((event): event is Extract => { + return event.hook === "agent.turn.after" && event.data.agent.id === agentId; }); } @@ -63,7 +63,7 @@ async function waitForPermission(client: DaemonClient, agentId: string, command: const events = await readHooks(client); for (const event of events) { if ( - event.hook === "agent.permission_requested" && + event.hook === "agent.permission.before" && event.data.agent.id === agentId && event.data.request.input?.command === command ) { @@ -111,7 +111,7 @@ test.skipIf(process.platform !== "linux")( }); const logger = pino({ level: "info" }, destination); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", + daemonVersion: "0.14.0-beta.1", logger, agentClients: { claude: new ClaudeAgentClient({ logger }), @@ -121,7 +121,7 @@ test.skipIf(process.platform !== "linux")( }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.8.0", + appVersion: "0.14.0-beta.1", }); const proof: Record = { port: daemon.port, @@ -169,7 +169,7 @@ test.skipIf(process.platform !== "linux")( const approvalId = await waitForPermission(client, agent.id, "git status"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: approvalId, resolution: expect.objectContaining({ behavior: "allow" }), @@ -186,7 +186,7 @@ test.skipIf(process.platform !== "linux")( const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: deniedId, resolution: expect.objectContaining({ behavior: "deny" }), @@ -224,7 +224,7 @@ test.skipIf(process.platform !== "linux")( .poll( async () => { return (await readHooks(client)).filter((event) => { - return event.hook === "agent.turn_started" && event.data.agent.id === agent.id; + return event.hook === "agent.turn.before" && event.data.agent.id === agent.id; }).length; }, { timeout: 30_000 }, @@ -278,23 +278,26 @@ test.skipIf(process.platform !== "linux")( ); return [...names].sort(); }) - .toEqual([ - "agent.archived", - "agent.closed", - "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", - ]); + .toEqual( + [ + "agent.archive.after", + "agent.close.after", + "agent.create.after", + "agent.permission.before", + "agent.permission.after", + "agent.turn.after", + "agent.turn.before", + "agent.create.before", + "agent.open.before", + "worktree.create.after", + "workspace.create.before", + "workspace.archive.after", + "workspace.create.after", + ].sort(), + ); const openReasons = (await readHooks(client)) .filter((event) => { - return event.hook === "before agent.session_open"; + return event.hook === "agent.open.before"; }) .map((event) => { return event.data.request.reason; diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index fe86f6a754..5de6c7b061 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -9,18 +9,17 @@ const paseo = createPaseoApi( 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 }) => { + const remove = hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "old" }; }); remove(); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "new" }; }); remove(); const output = await hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -31,17 +30,17 @@ test("removing an old registration twice preserves a newer registration for the test("before hooks compose returned requests and preserve the original input", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "first" }; }); - hooks.before("workspace.create", () => { + hooks.addHook("workspace.create.before", async () => { return; }); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ 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({ + expect(await hooks.invoke("operation", "workspace.create.before", input, paseo)).toEqual({ source: { kind: "directory", path: "/project" }, title: "first:second", }); @@ -50,7 +49,7 @@ test("before hooks compose returned requests and preserve the original input", a test("teardown aborts an active callback and removes its registrations", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", async (_input, context) => { + hooks.addHook("workspace.create.before", async (_input, context) => { await new Promise((_resolve, reject) => { context.signal.addEventListener( "abort", @@ -63,8 +62,7 @@ test("teardown aborts an active callback and removes its registrations", async ( }); const invocation = hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -72,19 +70,18 @@ test("teardown aborts an active callback and removes its registrations", async ( ); hooks.close(); await expect(invocation).rejects.toThrow("Hook aborted"); - expect(hooks.catalog()).toEqual({ events: [], before: [] }); + expect(hooks.catalog()).toEqual([]); }); 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 }) => { + hooks.addHook("agent.open.before", async ({ request }) => { return { ...request, provider: "another-provider" }; }); await expect( hooks.invoke( "operation", - "before", - "agent.session_open", + "agent.open.before", { agentId: "agent", workspaceId: "workspace", @@ -100,19 +97,18 @@ test("session-open hooks reject changes to session identity instead of silently }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env"); + ).rejects.toThrow("agent.open.before hooks can only change env"); }); test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( "session-open targeting metadata is read-only: %j", async (patch) => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch })); + hooks.addHook("agent.open.before", async ({ request }) => ({ ...request, ...patch })); await expect( hooks.invoke( "operation", - "before", - "agent.session_open", + "agent.open.before", { agentId: "agent", workspaceId: null, @@ -128,6 +124,118 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers"); + ).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers"); }, ); + +test("new request-changing registrations compose with legacy registrations in registration order", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", ({ request }) => ({ ...request, title: "legacy" })); + hooks.addHook("workspace.create.before", async ({ request }) => ({ + ...request, + title: request.title + ":new", + })); + hooks.before("workspace.create", ({ request }) => ({ + ...request, + title: request.title + ":last", + })); + expect( + await hooks.invoke( + "operation", + "workspace.create.before", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ), + ).toEqual({ source: { kind: "directory", path: "/project" }, title: "legacy:new:last" }); +}); + +test.each(["agent.session_open", "agent.create.prepare", "nonsense"])( + "addHook rejects unsupported name %s at registration", + (name) => { + const hooks = new PluginHookHandlers(() => {}); + // @ts-expect-error Runtime validation also protects untyped plugins. + expect(() => hooks.addHook(name, async () => {})).toThrow(name); + }, +); + +test.each([ + ["workspace.create", "workspace.create.before"], + ["workspace.created", "workspace.create.after"], + ["workspace.archived", "workspace.archive.after"], + ["agent.create", "agent.create.before"], + ["agent.created", "agent.create.after"], + ["agent.session_open", "agent.open.before"], + ["agent.closed", "agent.close.after"], + ["agent.archived", "agent.archive.after"], + ["agent.turn_started", "agent.turn.before"], + ["agent.turn_ended", "agent.turn.after"], + ["agent.permission_requested", "agent.permission.before"], + ["agent.permission_resolved", "agent.permission.after"], +] as const)("legacy %s registers only %s across the process boundary", (oldName, newName) => { + const hooks = new PluginHookHandlers(() => {}); + const handler = async () => {}; + const remove = + oldName === "workspace.create" || oldName === "agent.create" || oldName === "agent.session_open" + ? hooks.before(oldName, handler) + : hooks.on(oldName, handler); + hooks.addHook(newName, async () => {}); + expect(hooks.catalog()).toEqual([newName]); + remove(); + expect(hooks.catalog()).toEqual([newName]); + hooks.close(); + expect(hooks.catalog()).toEqual([]); +}); + +test("legacy notifications and addHook share payloads, registration order, and cleanup", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: unknown[] = []; + const remove = hooks.on("workspace.created", (event) => { + received.push(["old", event]); + }); + hooks.addHook("workspace.create.after", async (event) => { + received.push(["new", event]); + }); + const payload = { + workspace: { + id: "workspace", + projectId: "project", + cwd: "/project", + name: null, + archivedAt: null, + }, + }; + await hooks.invoke("first", "workspace.create.after", payload, paseo); + remove(); + remove(); + await hooks.invoke("second", "workspace.create.after", payload, paseo); + expect(received).toEqual([ + ["old", payload], + ["new", payload], + ["new", payload], + ]); +}); + +test("a waiting after hook propagates failures and stops later handlers", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: string[] = []; + hooks.addHook("worktree.create.after", async ({ worktree }) => { + received.push(worktree.path); + throw new Error("cannot initialize worktree"); + }); + hooks.addHook("worktree.create.after", async () => { + received.push("unexpected later handler"); + }); + await expect( + hooks.invoke( + "worktree", + "worktree.create.after", + { + worktree: { path: "/worktree", branch: "feature", sourceCheckout: "/source" }, + }, + paseo, + ), + ).rejects.toThrow("cannot initialize worktree"); + expect(received).toEqual(["/worktree"]); +}); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index c43e113e38..05f62373f6 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -1,3 +1,7 @@ +import { + createLifecycleRegistration, + pluginHookRegistry, +} from "@getpaseo/plugin/internal/lifecycle"; import { isDeepStrictEqual } from "node:util"; import { serializeAgentTool, @@ -11,43 +15,34 @@ import { z } from "zod"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; import type { PluginHookAgent, + PluginHookPayload, PluginHookContext, PluginLifecycleRegistration, } from "@getpaseo/plugin/server"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { - PluginBeforeRequests, + PluginHookRequests, PluginHookWorkspace, - PluginLifecycleEvents, + PluginHookEvents, } from "@getpaseo/plugin/server"; import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; -export type PluginWireBeforeRequests = Omit & { - "agent.session_open": SessionOpenWireRequest; +export type PluginWireBeforeRequests = Omit & { + "agent.open.before": SessionOpenWireRequest; }; /** Daemon-only results; ownership never crosses the plugin process boundary. */ export type PluginBeforeResults = PluginWireBeforeRequests & { - "agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record }; + "agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record }; }; -export const lifecycleEventNames = [ - "agent.created", - "agent.closed", - "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 + "agent.create.before": CreateAgentRequestMessageSchema.pick({ + config: true, + env: true, + }).strict(), + "agent.open.before": z .object({ agentId: z.string(), workspaceId: z.string().nullable(), @@ -62,10 +57,13 @@ const beforeSchemas = { mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), - "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), + "workspace.create.before": WorkspaceCreateRequestSchema.omit({ + type: true, + requestId: true, + }).strict(), }; -const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({ +const sessionOpenLocalResultSchema = beforeSchemas["agent.open.before"].extend({ tools: z.array( z.custom( (tool) => @@ -79,10 +77,11 @@ const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend( }); export interface PluginLifecycle { - emit( - name: Name, - event: PluginLifecycleEvents[Name], - ): void; + wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise; + emit(name: Name, event: PluginHookEvents[Name]): void; before( name: Name, request: PluginWireBeforeRequests[Name], @@ -130,32 +129,32 @@ export function publishAgentStream( event: AgentStreamEvent, ): void { if (event.type === "turn_started") { - lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null }); + lifecycle.emit("agent.turn.before", { agent, turnId: event.turnId ?? null }); } else if (event.type === "turn_completed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "completed" }, }); } else if (event.type === "turn_failed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { 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", { + lifecycle.emit("agent.turn.after", { 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 }); + lifecycle.emit("agent.permission.before", { agent, request: event.request }); } else if (event.type === "permission_resolved") { - lifecycle.emit("agent.permission_resolved", { + lifecycle.emit("agent.permission.after", { agent, requestId: event.requestId, resolution: event.resolution, @@ -169,9 +168,9 @@ export function validateBeforeResult unknown; export class PluginHookHandlers implements PluginLifecycleRegistration { - private readonly events = new Map>(); - private readonly transforms = new Map>(); + private readonly handlers = new Map>(); private readonly active = new Map(); private readonly changed: () => void; @@ -209,29 +207,24 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { constructor(changed: () => void, agentTools?: PluginAgentTools) { this.agentTools = agentTools; this.changed = changed; + const registration = createLifecycleRegistration((name, handler) => + this.register(name, handler as Handler), + ); + this.addHook = registration.addHook; + this.on = registration.on; + this.before = registration.before; } - 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 addHook: PluginLifecycleRegistration["addHook"]; + readonly on: PluginLifecycleRegistration["on"]; + readonly before: PluginLifecycleRegistration["before"]; - 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()] }; + catalog(): string[] { + return [...this.handlers.keys()]; } async invoke( id: string, - kind: "event" | "before", name: string, input: unknown, paseo: PluginHookContext["paseo"], @@ -239,20 +232,13 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { 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; - if (hookName === "agent.session_open") { + if (Object.hasOwn(beforeSchemas, name)) { + const hookName = name as keyof PluginHookRequests; + if (hookName === "agent.open.before") { return await this.openSession(input, { paseo, signal: controller.signal }); } let request = validateBeforeRequest(hookName, input); - for (const handler of this.transforms.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); const result = await handler( { request: structuredClone(request) }, @@ -262,11 +248,16 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { } return request; } - for (const handler of this.events.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); try { await handler(structuredClone(input), { paseo, signal: controller.signal }); } catch (error) { + if ( + Object.hasOwn(pluginHookRegistry, name) && + pluginHookRegistry[name as keyof typeof pluginHookRegistry].waits + ) + throw error; console.error(`Lifecycle hook ${name} failed`, error); } } @@ -280,10 +271,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { input: unknown, context: PluginHookContext, ): Promise { - let request = validateBeforeRequest("agent.session_open", input); + let request = validateBeforeRequest("agent.open.before", input); this.agentTools?.detach(request.agentId); let tools: AgentToolDefinition[] = []; - for (const handler of this.transforms.get("agent.session_open") ?? []) { + for (const handler of this.handlers.get("agent.open.before") ?? []) { context.signal.throwIfAborted(); const result = await handler( { request: { ...structuredClone(request), tools: [...tools] } }, @@ -292,7 +283,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { if (result === undefined) continue; const local = sessionOpenLocalResultSchema.parse(result); const metadata = local.tools.map(serializeAgentTool); - request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata }); + request = validateBeforeResult("agent.open.before", request, { ...local, tools: metadata }); tools = local.tools; } context.signal.throwIfAborted(); @@ -311,19 +302,14 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { for (const controller of this.active.values()) { controller.abort(); } - this.events.clear(); - this.transforms.clear(); + this.handlers.clear(); } - private register( - registry: Map>, - name: string, - handler: Handler, - ): () => void { - let handlers = registry.get(name); + private register(name: string, handler: Handler): () => void { + let handlers = this.handlers.get(name); if (!handlers) { handlers = new Set(); - registry.set(name, handlers); + this.handlers.set(name, handlers); } handlers.add(handler); this.changed(); @@ -332,7 +318,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { return; } if (handlers.size === 0) { - registry.delete(name); + this.handlers.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 c3fa5dda26..e582387d48 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -55,7 +55,7 @@ export type PluginProcessRequest = providerId: string; options: ProviderCatalogOptions; } - | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } + | { type: "hook"; requestId: string; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } @@ -79,13 +79,13 @@ export type PluginProcessRequest = export type PluginProcessMessage = | { type: "settings.changed"; settingsId: string } - | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } } + | { type: "hooks.changed"; hooks: string[] } | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[]; usageSources?: PluginUsageSourceMetadata[]; - hooks?: { events: string[]; before: string[] }; + hooks?: string[]; } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } @@ -109,7 +109,7 @@ 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 hooksSchema = z.array(z.string()); const providerMetadataSchema = z .object({ @@ -211,7 +211,6 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis .object({ type: z.literal("hook"), requestId: z.string(), - kind: z.enum(["event", "before"]), name: z.string(), input: z.unknown(), }) diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index e93ded990e..a72e268e4f 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -288,6 +288,7 @@ export function createPluginWorker(options: { registerProvider, registerUsageSource, registerSettings, + addHook: hooks.addHook, on: hooks.on, before: hooks.before, }); @@ -563,7 +564,7 @@ export function createPluginWorker(options: { }); return; } - void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + void hooks.invoke(message.requestId, message.name, message.input, paseo).then( (output) => { return send({ type: "result", requestId: message.requestId, output }); }, diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 8004fd52bf..cfa3292567 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; @@ -2078,7 +2079,11 @@ const examples = (await readdir(examplesDirectory, { withFileTypes: true })) it.each(examples)("builds and loads the %s plugin example", async (example) => { const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); temporaryDirectories.push(settingsDirectory); - const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2"); + const runtime = createTestRuntime( + { settingsDirectory }, + undefined, + resolveDaemonVersion(import.meta.url), + ); try { await runtime.startPlugin(example, path.join(examplesDirectory, example)); expect(runtime.catalog()).toMatchObject([{ id: example }]); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 5a15020cef..be348d5868 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,3 +1,4 @@ +import type { PluginHookPayload } from "@getpaseo/plugin/server"; import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; @@ -6,11 +7,7 @@ import { type ProviderStatus, type ProviderStatusRequest, } from "@getpaseo/plugin/server/provider"; -import type { - PluginLifecycleEvents, - PluginHookAgent, - AgentToolResult, -} from "@getpaseo/plugin/server"; +import type { PluginHookEvents, PluginHookAgent, AgentToolResult } from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; import path from "node:path"; @@ -77,7 +74,7 @@ interface LoadedPlugin { /** Lets a client that already holds this bundle skip downloading it again. */ clientBundleHash: string; methods: ReadonlySet; - hooks: { events: string[]; before: string[] }; + hooks: string[]; providers: readonly PluginProviderMetadata[]; usageSources: readonly PluginUsageSourceMetadata[]; child: PluginChild | null; @@ -311,7 +308,7 @@ export class PluginRuntime { } private notifySessionOpenChange(loaded: LoadedPlugin): void { - if (!loaded.hooks.before.includes("agent.session_open")) return; + if (!loaded.hooks.includes("agent.open.before")) return; for (const listener of this.sessionOpenListeners) listener(); } @@ -516,18 +513,14 @@ export class PluginRuntime { return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); } - emit( - name: Name, - event: PluginLifecycleEvents[Name], - ): void { + emit(name: Name, event: PluginHookEvents[Name]): void { for (const loaded of this.plugins.values()) { - if (!loaded.hooks.events.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } const request = this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "event", name, input: event, }) @@ -548,6 +541,25 @@ export class PluginRuntime { await Promise.all(this.pendingEvents); } + async wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise { + const plugins = [...this.plugins.values()].sort((left, right) => + left.id.localeCompare(right.id), + ); + for (const loaded of plugins) { + if (!loaded.hooks.includes(name)) continue; + try { + await this.request(loaded, { type: "hook", requestId: randomUUID(), name, input: payload }); + } catch (error) { + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { + cause: error, + }); + } + } + } + async before( name: Name, input: PluginWireBeforeRequests[Name], @@ -560,33 +572,31 @@ export class PluginRuntime { const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; const mcpServerOwners: Record = {}; for (const loaded of plugins) { - if (!loaded.hooks.before.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } try { const output = await this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "before", name, - input: - name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request, + input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request, }); request = validateBeforeResult(name, request, output); - if (name === "agent.session_open") { + if (name === "agent.open.before") { const opened = request as SessionOpenWireRequest; this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); this.collectAgentTools(attached, loaded, opened.tools); } } catch (error) { - if (name === "agent.session_open") + if (name === "agent.open.before") this.detachTools((request as SessionOpenWireRequest).agentId); - throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { cause: error, }); } } - if (name === "agent.session_open") { + if (name === "agent.open.before") { return { ...request, tools: attached, @@ -763,7 +773,7 @@ export class PluginRuntime { clientBundleHash: hashClientBundle(clientBundle), requirements: manifest.requirements, methods: new Set(), - hooks: { events: [], before: [] }, + hooks: [], providers: [], usageSources: [], child: null, @@ -893,7 +903,7 @@ export class PluginRuntime { clientBundleHash: hashClientBundle(clientBundle), requirements, methods: new Set(ready.methods), - hooks: ready.hooks ?? { events: [], before: [] }, + hooks: ready.hooks ?? [], providers: ready.providers ?? [], usageSources: ready.usageSources ?? [], child, @@ -973,11 +983,11 @@ export class PluginRuntime { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { if (message.type === "hooks.changed") { - const hadSessionOpen = loaded.hooks.before.includes("agent.session_open"); + const hadSessionOpen = loaded.hooks.includes("agent.open.before"); loaded.hooks = message.hooks; if ( this.plugins.get(loaded.id) === loaded && - hadSessionOpen !== loaded.hooks.before.includes("agent.session_open") + hadSessionOpen !== loaded.hooks.includes("agent.open.before") ) { for (const listener of this.sessionOpenListeners) listener(); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b52c9dbfe4..2ff8e917ab 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -485,6 +485,7 @@ export interface SessionOptions { workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { + wait: import("./plugins/lifecycle/index.js").PluginLifecycle["wait"]; before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; listPlugins(): Promise; @@ -5987,6 +5988,7 @@ export class Session { }, ): Promise { const result = await createPaseoWorktree(input, { + lifecycle: this.pluginRuntime, github: this.github, ...(options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } @@ -6795,7 +6797,7 @@ export class Session { // and must not rerun hooks or compare their potentially changing output. if (this.pluginRuntime) { const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); + const transformed = await this.pluginRuntime.before("workspace.create.before", input); creationRequest = { ...transformed, type, requestId }; } return creationRequest.source.kind === "directory" diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0a3b8cb257..c93e535709 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -175,7 +175,7 @@ test("background workspace creation preserves hooks, inheritance and durable vis expect(child.background).toBe(true); expect(visible.background).toBe(false); expect(human.background).toBe(false); - expect(events).toEqual(Array(4).fill("workspace.created")); + expect(events).toEqual(Array(4).fill("workspace.create.after")); const reloaded = new FileBackedWorkspaceRegistry( path.join(tmpDir, "projects", "workspaces.json"), logger, 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 3ac91a834a..08d7b93bf4 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 @@ -307,7 +307,9 @@ export function createWorkspaceProvisioningService(deps: { } function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + deps.lifecycle?.emit("workspace.create.after", { + workspace: describeHookWorkspace(workspace), + }); } async function resolveSourceProjectForWorktree(input: { diff --git a/packages/website/src/plugin-docs-navigation.test.ts b/packages/website/src/plugin-docs-navigation.test.ts index 0aeed8ff88..b6f8e5128f 100644 --- a/packages/website/src/plugin-docs-navigation.test.ts +++ b/packages/website/src/plugin-docs-navigation.test.ts @@ -14,6 +14,7 @@ describe("plugin documentation", () => { { type: "page", label: "Publishing", href: "/docs/plugins/publishing" }, { type: "page", label: "Provider plugins", href: "/docs/plugins/providers" }, { type: "page", label: "Migration", href: "/docs/plugins/migration" }, + { type: "page", label: "Hooks", href: "/docs/plugins/hooks" }, { type: "page", label: "Reference", href: "/docs/plugins/reference" }, ], }); @@ -32,7 +33,7 @@ describe("plugin documentation", () => { }); it("serves canonical pages without redirect loops", () => { - for (const page of ["", "/publishing", "/reference", "/providers", "/migration"]) { + for (const page of ["", "/publishing", "/reference", "/providers", "/migration", "/hooks"]) { expect(getLegacyDocsRedirect(`/docs/plugins${page}`)).toBeUndefined(); expect(getLegacyDocsRedirect(`/docs/plugins${page}.md`)).toBeUndefined(); } diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index 2a34c1222d..cb191bd3e1 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -1,15 +1,15 @@ # Agent configuration -Sets saved Codex options through `server.before("agent.create", ...)`: +Sets saved Codex options through `server.addHook("agent.create.before", async ...)`: - Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.approval_policy` to `on-request`. - Preserves other configuration and provider options. Adds the `company` MCP server to Codex launches through -`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration; +`server.addHook("agent.open.before", async ...)`. The server is never saved in the agent's configuration; existing saved MCP servers are preserved. Existing agents receive the server through the -[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents). +[session reopen behavior](../../public-docs/plugins/hooks.md#attach-tools-to-agents). Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using this example. A `company` name collision with a saved server or another @@ -26,7 +26,7 @@ entries added by this plugin's earlier callbacks. The create callback receives c earlier plugins have transformed it; its explicit Codex options replace the named fields in the saved configuration of new agents and remain after this plugin is disabled. -See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing +See [Inject MCP servers](../../public-docs/plugins/hooks.md#inject-mcp-servers) for choosing between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and environment injection. Its provider-switch callback clears provider options when changing Codex to Claude, so use these examples separately when trying the diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index bc5abc9e3d..cfb236caa1 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -1,7 +1,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider !== "codex") { return request; } @@ -19,7 +19,7 @@ export default function contribute(server: PluginServerContext) { }; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.provider !== "codex") { return; } diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json index 019394b8f7..98e1460c12 100644 --- a/plugin-examples/agent-configuration/paseo-plugin.json +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "agent-configuration", "requirements": { - "paseo": ">=0.13.2" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/agent-tools/README.md b/plugin-examples/agent-tools/README.md index e6ece3978e..e8674a9d66 100644 --- a/plugin-examples/agent-tools/README.md +++ b/plugin-examples/agent-tools/README.md @@ -29,5 +29,5 @@ From the repository root, `npm run typecheck` includes this example. Outside thi use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts` and the manifest into that project. -See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents) +See [Attach tools to agents](../../public-docs/plugins/hooks.md#attach-tools-to-agents) for targeting, execution, provider gates, collisions, and plugin reload behavior. diff --git a/plugin-examples/agent-tools/index.server.ts b/plugin-examples/agent-tools/index.server.ts index 600d743fbd..1bede92569 100644 --- a/plugin-examples/agent-tools/index.server.ts +++ b/plugin-examples/agent-tools/index.server.ts @@ -18,7 +18,7 @@ const appendWorkspaceNote = defineAgentTool({ }); export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { if (request.labels["notes/enabled"] !== "true") return; return { ...request, tools: [...request.tools, appendWorkspaceNote] }; }); diff --git a/plugin-examples/agent-tools/paseo-plugin.json b/plugin-examples/agent-tools/paseo-plugin.json index eaec7ffaf6..e67c53f072 100644 --- a/plugin-examples/agent-tools/paseo-plugin.json +++ b/plugin-examples/agent-tools/paseo-plugin.json @@ -1,4 +1,4 @@ { "id": "agent-tools", - "requirements": { "paseo": ">=0.13.2" } + "requirements": { "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index c97f2a7254..7b1fb1e761 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -2,7 +2,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText, shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") { return { ...request, @@ -17,7 +17,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.on("agent.turn_ended", async (event, context) => { + server.addHook("agent.turn.after", async (event, context) => { if (event.outcome.kind === "canceled") { return; } @@ -30,7 +30,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.on("agent.permission_requested", async (event, context) => { + server.addHook("agent.permission.before", async (event, context) => { const command = shellCommand(event.request); if (command === null) { return; @@ -56,7 +56,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { return { ...request, env: { @@ -66,7 +66,7 @@ export default function contribute(server: PluginServerContext) { }; }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider === "codex") { return { ...request, @@ -82,7 +82,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { return { ...request, env: { diff --git a/plugin-examples/lifecycle-actions/paseo-plugin.json b/plugin-examples/lifecycle-actions/paseo-plugin.json index fe578fae6d..df289d0aa2 100644 --- a/plugin-examples/lifecycle-actions/paseo-plugin.json +++ b/plugin-examples/lifecycle-actions/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-actions", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 0f5fd4ed95..e87be2c2f6 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -2,55 +2,59 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { logHook } from "./server/log"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { - logHook("before workspace.create", { request }); + server.addHook("workspace.create.before", async ({ request }) => { + logHook("workspace.create.before", { request }); return request; }); - server.on("workspace.created", (event) => { - logHook("workspace.created", event); + server.addHook("workspace.create.after", async (event) => { + logHook("workspace.create.after", event); }); - server.on("workspace.archived", (event) => { - logHook("workspace.archived", event); + server.addHook("workspace.archive.after", async (event) => { + logHook("workspace.archive.after", event); }); - server.before("agent.create", ({ request }) => { - logHook("before agent.create", { request }); + server.addHook("worktree.create.after", async (event) => { + logHook("worktree.create.after", event); + }); + + server.addHook("agent.create.before", async ({ request }) => { + logHook("agent.create.before", { request }); return request; }); - server.before("agent.session_open", ({ request }) => { - logHook("before agent.session_open", { request }); + server.addHook("agent.open.before", async ({ request }) => { + logHook("agent.open.before", { request }); return request; }); - server.on("agent.created", (event) => { - logHook("agent.created", event); + server.addHook("agent.create.after", async (event) => { + logHook("agent.create.after", event); }); - server.on("agent.closed", (event) => { - logHook("agent.closed", event); + server.addHook("agent.close.after", async (event) => { + logHook("agent.close.after", event); }); - server.on("agent.turn_started", (event) => { - logHook("agent.turn_started", event); + server.addHook("agent.turn.before", async (event) => { + logHook("agent.turn.before", event); }); - server.on("agent.turn_ended", (event) => { - logHook("agent.turn_ended", event); + server.addHook("agent.turn.after", async (event) => { + logHook("agent.turn.after", event); }); - server.on("agent.permission_requested", (event) => { - logHook("agent.permission_requested", event); + server.addHook("agent.permission.before", async (event) => { + logHook("agent.permission.before", event); }); - server.on("agent.permission_resolved", (event) => { - logHook("agent.permission_resolved", event); + server.addHook("agent.permission.after", async (event) => { + logHook("agent.permission.after", event); }); - server.on("agent.archived", (event) => { - logHook("agent.archived", event); + server.addHook("agent.archive.after", async (event) => { + logHook("agent.archive.after", event); }); return () => {}; diff --git a/plugin-examples/lifecycle-logger/paseo-plugin.json b/plugin-examples/lifecycle-logger/paseo-plugin.json index 8385fbf14c..2c964d4622 100644 --- a/plugin-examples/lifecycle-logger/paseo-plugin.json +++ b/plugin-examples/lifecycle-logger/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-logger", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md new file mode 100644 index 0000000000..8d3fae125b --- /dev/null +++ b/public-docs/plugins/hooks.md @@ -0,0 +1,618 @@ +--- +title: Plugin hooks +description: Register hooks to change requests or observe agent and workspace operations. +nav: Hooks +order: 48 +category: Plugins +--- + +# Plugin hooks + +Use `server.addHook(name, handler)` in `index.server.ts`. Hooks run while the plugin is enabled, even with no app connected. `addHook` is available from Paseo 0.14; set `requirements.paseo` to the release you build against. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## Naming and timing + +Names follow `subject.operation.position`: the subject, the operation, then `before` or `after`. The name identifies a lifecycle position. Whether Paseo waits is a per-hook property shown in the table below. `workspace` covers every workspace; `worktree` covers only git worktrees. + +- Waiting hooks finish before Paseo continues. Throwing, returning invalid data, or exceeding 30 seconds fails the operation with `Plugin failed: …`. Request-changing hooks receive `{ request }` and may return a complete replacement request or `undefined`. +- Notifications receive the event object and return `Promise`. Paseo does not wait; errors are logged against the plugin and the operation continues. `agent.turn.before` and `agent.permission.before` are notifications fired when a turn starts or a permission becomes pending. + +All handlers must be async. Turn completion includes success, failure, and cancellation; inspect `outcome`. Permission completion includes every resolution; inspect `resolution`. + +Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action. + +`worktree.create.after` runs before the workspace is registered. It precedes `workspace.create.after`, and both precede the first agent's `agent.create.before` / `agent.open.before`. + +### Return early from a waiting hook + +Start detached work and return when the operation should continue immediately: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo only sees the returned promise, so detached work gets no error reporting and no cancellation `signal`. + +## Hooks + +| Hook | Waits | Payload | Handler may change | +| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- | +| `workspace.create.before` | yes | `{ request }` | Entire create request | +| `workspace.create.after` | no | `{ workspace }` | | +| `workspace.archive.after` | no | `{ workspace }` | | +| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation | +| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` | +| `agent.create.after` | no | `{ agent }` | | +| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` | +| `agent.close.after` | no | `{ agent }` | | +| `agent.archive.after` | no | `{ agent, archivedAt }` | | +| `agent.turn.before` | no | `{ agent, turnId }` | | +| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | no | `{ agent, request }` | | +| `agent.permission.after` | no | `{ agent, requestId, resolution }` | | + +## Shared payloads + +```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` | Deprecated, always `[]`; fetch history with the SDK. | +| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | +| `resolution` | SDK `AgentPermissionResponse` | +| `archivedAt` | Timestamp string | + +## workspace.create.before + +Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected. + +### Choose workspace isolation + +```ts +server.addHook("workspace.create.before", async ({ 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. + +## workspace.create.after + +Fires after the workspace record is created and its directory is available. Receives `{ workspace }`. + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after + +Fires after archive state is saved. Receives `{ workspace }`; worktree cleanup can still be pending. + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.after + +Paseo waits for this hook after a new git worktree exists and before workspace registration, setup, terminals, or agents. Receives `{ worktree: { path, branch, sourceCheckout } }`: the absolute worktree path, branch name, and source checkout directory. There is no workspace id yet. Return nothing; write files directly into the worktree. Throwing or exceeding 30 seconds aborts creation and removes the new worktree. + +This covers branch-off, branch checkout, and PR checkout creation. Restoring a missing archived worktree uses a separate recovery path and does not fire this hook. + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before + +Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation. + +**`agent.create.before.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` | Opaque provider-specific options | +| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | +| `cwd` | Cannot change | + +### Change saved configuration + +Add this callback inside `contribute(server)` to set the saved options for new Codex agents: + +```ts +server.addHook("agent.create.before", async ({ 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", + }, + }, + }; +}); +``` + +| Input | Result | +| ------------------------------------------- | ----------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | + +The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options +override its mode presets. + +## agent.create.after + +Ordinary creation finishes. Receives `{ agent }`; import and resume do not fire it. + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before + +Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open. + +**`agent.open.before` request example:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| Field | Values | +| ------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | String or `null` | +| `title` | Read-only string or `null`; creation title on create, stored title on resume. | +| `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | +| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | +| `reason` | `create`, `resume`, `refresh`, `import` | +| `purpose` | `interactive`, `history` | +| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | + +### Inject environment variables on every session opening + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +Session-open overrides are re-derived on every open. `agent.create.before.env` applies only to the +initial launch and is not saved in the agent's configuration. + +### Inject MCP servers + +Use `agent.create.before.config.mcpServers` when the server should become part of the agent's saved +configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing +the placeholder URL with your MCP endpoint: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. +The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never +contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, +or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' +final maps in plugin-ID order. + +A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server +fails the session open and names the server and both sources. A provider without MCP support fails +with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore +these servers. Install, disable, and reload changes reach existing agents through the +[session reopen behavior](#attach-tools-to-agents). + +### Attach tools to agents + +Define a tool in `index.server.ts` and attach it in `server.addHook("agent.open.before", async handler)`. +This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +Set the label when creating an agent with the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +Select agents using any of the session-open request fields: + +| Select by | Condition in the callback | +| ---------------- | -------------------------------------------- | +| Label | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| Workspace | `request.workspaceId === "workspace-456"` | +| Individual agent | `request.agentId === chosenAgentId` | + +`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier +callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves +the request. Tools are ignored for `purpose: "history"`. + +Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from +`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| Execution context | Value | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | +| `paseo` | SDK connected to this daemon | +| `signal` | Abort signal for this tool call; pass it to file operations or external requests | + +`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool +metadata and call inputs/results cross the process boundary. Paseo validates input before calling +`execute`. Invalid input returns schema issues to the agent. A thrown error becomes +`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its +transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate +from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. + +Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is +omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. + +Paseo delivers attached tools through its agent tool transport, using MCP or native registration +as the provider supports. The same definition and callback work with either. Attached tools obey +the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. + +Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo +tool or another plugin's tool on the same agent fails the session open and names both owners. +A duplicate name within one plugin also fails the session open. Agents the callback does not +target are unaffected. + +| Change | When the agent receives it | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start, stop, or reload a plugin declaring `agent.open.before` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | +| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | +| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | +| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | + +If an automatic reopen fails, the triggering prompt fails with that error. See the +[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). + +## agent.close.after + +A live agent runtime closes. Receives `{ agent }`. + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after + +Archive state is saved. Receives `{ agent, archivedAt }`; runtime cleanup can still be pending. + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before + +A live turn starts. Receives `{ agent, turnId }`. History replay does not fire it. + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after + +A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below. + +### 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. The helper imports types from `@getpaseo/protocol/agent-types`; add +`@getpaseo/protocol` at the version matching your daemon to your development dependencies +and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +The handler fetches only the last 20 timeline items. Output before that page is not inspected. +Use `cursor` and `direction` to page further when your plugin needs more history. + +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. + +## agent.permission.before + +A permission or question becomes pending. Receives `{ agent, request }`; `request` is an `AgentPermissionRequest`. Send the answer through the SDK. + +### 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/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", 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. + +## agent.permission.after + +A pending request is answered or cleared. Receives `{ agent, requestId, resolution }`; `resolution` is an `AgentPermissionResponse`. + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks. + +## Ordering and returned values + +```text +Creation request + → agent.create.before 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.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → 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 | + +There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` +accumulate only across callbacks within the same plugin. Returning a replacement map or array +replaces that scope's prior value. + +## 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.addHook(...); remove();` | +| Reload, disable, removal, shutdown | Remaining registrations removed | +| Unknown hook name | Registration fails | +| Hook timeout | 30 seconds; aborts the signal. A waiting hook fails the pending operation; a notification 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 thirteen hooks; JSON logs with environment values redacted | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | + +Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. + +## Renamed hooks + +The old registration methods are deprecated for removal after 2027-04-30. Existing plugins keep their behavior. Mixed old and new registrations within a plugin run in registration order. `addHook` accepts only the new names. + +| Old registration | New registration | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md new file mode 100644 index 0000000000..c4465e3008 --- /dev/null +++ b/public-docs/plugins/hooks.zh-CN.md @@ -0,0 +1,600 @@ +--- +title: 插件钩子 +description: 为 Agent 和工作区操作注册钩子,修改请求或接收通知。 +nav: 钩子 +--- + +# 插件钩子 {#plugin-hooks} + +在 `index.server.ts` 中使用 `server.addHook(name, handler)`。钩子在插件启用时运行,即使没有连接的应用。`addHook` 从 Paseo 0.14 起可用;将 `requirements.paseo` 设置为你开发插件时使用的版本。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## 命名与等待 {#naming-and-timing} + +名称遵循 `subject.operation.position`:主体、操作、`before` 或 `after`。名称标识生命周期中的位置;Paseo 是否等待由每个钩子决定,见下表。`workspace` 包括所有工作区,`worktree` 仅包括 git worktree。 + +- 等待型钩子结束后 Paseo 才继续。抛出错误、返回无效数据或超过 30 秒会让操作失败,错误包含 `Plugin failed: …`。修改请求的钩子收到 `{ request }`,可以返回完整的替换请求或 `undefined`。 +- 通知收到事件对象,返回 `Promise`。Paseo 不等待;错误会记录在插件名下,原操作继续。`agent.turn.before` 和 `agent.permission.before` 分别在轮次开始或权限进入待处理状态时发出通知。 + +所有处理函数都必须为异步函数。轮次完成包括成功、失败和取消,请查看 `outcome`。权限完成包括所有处理结果,请查看 `resolution`。 + +创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。 + +`worktree.create.after` 在工作区注册前运行,随后是 `workspace.create.after`,两者都早于第一个 Agent 的 `agent.create.before` / `agent.open.before`。 + +### 从等待型钩子提前返回 {#return-early-from-a-waiting-hook} + +如果操作应立即继续,可以启动独立任务并返回: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没有取消 `signal`。 + +## 钩子一览 {#hooks} + +| 钩子 | 等待 | 载荷 | 可修改 | +| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- | +| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 | +| `workspace.create.after` | 否 | `{ workspace }` | | +| `workspace.archive.after` | 否 | `{ workspace }` | | +| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 | +| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` | +| `agent.create.after` | 否 | `{ agent }` | | +| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `agent.close.after` | 否 | `{ agent }` | | +| `agent.archive.after` | 否 | `{ agent, archivedAt }` | | +| `agent.turn.before` | 否 | `{ agent, turnId }` | | +| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | 否 | `{ agent, request }` | | +| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | | + +## 共享载荷 {#shared-payloads} + +```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 }; +``` + +| 字段 | 结构 / 含义 | +| ------------ | ---------------------------------------------------------------------------------------- | +| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | +| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 | +| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | +| `resolution` | SDK 的 `AgentPermissionResponse` | +| `archivedAt` | 时间戳字符串 | + +## workspace.create.before {#workspacecreatebefore} + +收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。 + +### 选择工作区隔离方式 {#choose-workspace-isolation} + +```ts +server.addHook("workspace.create.before", async ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 + +## workspace.create.after {#workspacecreateafter} + +工作区记录创建且目录可用后触发,收到 `{ workspace }`。 + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after {#workspacearchiveafter} + +归档状态保存后触发,收到 `{ workspace }`;worktree 清理可能尚未完成。 + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.after {#worktreecreateafter} + +新的 git worktree 在磁盘上创建后,Paseo 会等待此钩子,然后才注册工作区、运行 setup、打开终端或启动 Agent。收到 `{ worktree: { path, branch, sourceCheckout } }`,分别是 worktree 绝对路径、分支名、来源 checkout 目录。此时还没有工作区 id。无需返回数据,直接写入 worktree 即可。抛出错误或超过 30 秒会中止创建并删除新 worktree。 + +此钩子覆盖新建分支、检出分支和检出 PR。恢复缺失的已归档 worktree 使用独立路径,不触发此钩子。 + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before {#agentcreatebefore} + +收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。 + +**`agent.create.before.config`** 使用 `AgentSessionConfig`: + +| 字段 | 约束 | +| --------------------------------------------- | -------------------------------------------------------- | +| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | +| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | +| `title`, `systemPrompt` | Agent 配置 | +| `providerOptions` | 不透明的、特定于 Provider 的选项 | +| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | +| `cwd` | 不能修改 | + +### 修改已保存的配置 {#change-saved-configuration} + +在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: + +```ts +server.addHook("agent.create.before", async ({ 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", + }, + }, + }; +}); +``` + +| 输入 | 结果 | +| ------------------------------------------- | ------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | 由展开运算保留 | + +所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 + +## agent.create.after {#agentcreateafter} + +常规创建完成后触发,收到 `{ agent }`;导入和恢复不会触发。 + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before {#agentopenbefore} + +收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。 + +**`agent.open.before` 请求示例:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| 字段 | 取值 | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `workspaceId` | 字符串或 `null` | +| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | +| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | +| `reason` | `create`、`resume`、`refresh`、`import` | +| `purpose` | `interactive`、`history` | +| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | + +### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.before.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 + +### 注入 MCP 服务器 {#inject-mcp-servers} + +需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.before.config.mcpServers`。 +插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 +每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 +替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 +Paseo 按插件 ID 顺序合并各插件的最终映射。 + +名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 +不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 +`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent +通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 + +### 为 Agent 附加工具 {#attach-tools-to-agents} + +在 `index.server.ts` 中定义工具,并在 `server.addHook("agent.open.before", async handler)` 中附加它。 +此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +通过 SDK 创建 Agent 时设置标签: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +使用会话打开请求中的字段选择 Agent: + +| 选择依据 | 回调中的条件 | +| ---------- | -------------------------------------------- | +| 标签 | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| 工作区 | `request.workspaceId === "workspace-456"` | +| 单个 Agent | `request.agentId === chosenAgentId` | + +每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; +替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 + +从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` +和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| 执行 context | 值 | +| ------------ | --------------------------------------------------------------------------------------------------- | +| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | +| `paseo` | 已连接到此 daemon 的 SDK | +| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | + +`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 +输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 +Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 +其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 +关闭或重新打开 Agent 会话会移除旧定义。 + +在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, +Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 + +Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 +同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 +只有该 Agent 能看到这些工具。 + +名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo +工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 +会话打开失败。回调未选中的 Agent 不受影响。 + +| 变更 | Agent 何时收到变更 | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 启动、停止或重新加载声明了 `agent.open.before` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | +| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | +| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | +| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | + +如果自动重新打开失败,触发它的提示词会返回该错误。参阅 +[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 + +## agent.close.after {#agentcloseafter} + +运行中的 Agent 实例关闭后触发,收到 `{ agent }`。 + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after {#agentarchiveafter} + +归档状态保存后触发,收到 `{ agent, archivedAt }`;运行时清理可能尚未完成。 + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before {#agentturnbefore} + +实时轮次开始后触发,收到 `{ agent, turnId }`。历史重放不会触发。 + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after {#agentturnafter} + +实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。 + +### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} + +把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与 daemon 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 + +这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 + +## agent.permission.before {#agentpermissionbefore} + +权限或问题进入待处理状态后触发,收到 `{ agent, request }`;`request` 为 `AgentPermissionRequest`。通过 SDK 回应。 + +### 回应权限请求 {#answer-a-permission-request} + +使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", 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 () => {}; +} +``` + +| 请求 | 结果 | +| ---------------- | -------------------- | +| `rm -rf build` | 拒绝 | +| `git status` | 批准 | +| 其他命令或请求 | 保持待处理,留给用户 | +| 已经处理过的请求 | SDK 响应失败 | + +这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 + +## agent.permission.after {#agentpermissionafter} + +待处理请求得到回应或清除后触发,收到 `{ agent, requestId, resolution }`;`resolution` 为 `AgentPermissionResponse`。 + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。 + +## 顺序与返回值 {#ordering-and-returned-values} + +```text +Creation request + → agent.create.before 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.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| 回调返回 | 下一个回调收到 | +| ------------------------------------------------------- | --------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | +| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | +| `undefined` | 未改变的请求 | +| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | + +没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 +返回替换的映射或数组会替换该范围内之前的值。 + +## context 与清理 {#context-and-cleanup} + +| 契约 | 行为 | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `context.paseo` | 已连接到此 daemon 的现有 SDK | +| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | +| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | +| 注册结果 | 幂等的移除函数,例如 `const remove = server.addHook(...); remove();` | +| 重新加载、停用、移除、关闭 | 移除剩余的注册 | +| 未知的钩子名称 | 注册失败 | +| 钩子超时 | 30 秒;会中止 signal。等待型钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | +| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | +| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | +| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | + +## 完整示例 {#complete-examples} + +| 插件 | 包含 | +| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十三个钩子;隐去环境变量值的 JSON 日志 | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | + +用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 + +## 已重命名的钩子 {#renamed-hooks} + +旧注册方法已弃用,会在 2027-04-30 之后移除。现有插件继续按原来的方式运行。同一插件中混合使用新旧注册方法时,处理函数按注册顺序运行。`addHook` 只接受新名称。 + +| 旧注册方式 | 新注册方式 | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 14e141a0f6..a5565b71c7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2,7 +2,7 @@ title: Plugin reference description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands. nav: Reference -order: 48 +order: 49 category: Plugins --- @@ -632,488 +632,7 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre ## Lifecycle hooks -In `index.server.ts`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -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. - -`agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain -after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`, -and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's -lifecycle without being saved in the agent record. - -### Change saved configuration - -Add this callback inside `contribute(server)` to set the saved options for new Codex agents: - -```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", - }, - }, - }; -}); -``` - -| Input | Result | -| ------------------------------------------- | ----------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | Preserved by the spread | - -The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options -override its mode presets. - -### Inject MCP servers - -Use `agent.create.config.mcpServers` when the server should become part of the agent's saved -configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing -the placeholder URL with your MCP endpoint: - -```ts -server.before("agent.session_open", ({ request }) => { - if (request.provider !== "codex") return; - - return { - ...request, - mcpServers: { - ...request.mcpServers, - company: { type: "http", url: "https://tools.example.com/mcp" }, - }, - }; -}); -``` - -`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. -The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never -contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, -or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' -final maps in plugin-ID order. - -A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server -fails the session open and names the server and both sources. A provider without MCP support fails -with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore -these servers. Install, disable, and reload changes reach existing agents through the -[session reopen behavior](#attach-tools-to-agents). - -### Inject environment variables on every session opening - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -Session-open overrides are re-derived on every open. `agent.create.env` applies only to the -initial launch and is not saved in the agent's configuration. - -### Attach tools to agents - -Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`. -This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: - -```ts -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; - -const appendWorkspaceNote = defineAgentTool({ - name: "append_workspace_note", - description: "Append a note to AGENT_NOTES.md in this agent's working directory.", - input: z.object({ note: z.string().min(1).describe("Note to append") }), - async execute({ note }, { agent, signal }) { - const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); - await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); - return { - text: `Saved note to ${notePath}`, - structuredContent: { path: notePath }, - }; - }, -}); - -export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { - if (request.labels["notes/enabled"] !== "true") return; - return { ...request, tools: [...request.tools, appendWorkspaceNote] }; - }); - return () => {}; -} -``` - -Set the label when creating an agent with the SDK: - -```ts -const agent = await paseo.agents.create({ - config: { provider: "codex/gpt-5.4" }, - cwd: "/projects/shop", - labels: { "notes/enabled": "true" }, -}); -``` - -Select agents using any of the session-open request fields: - -| Select by | Condition in the callback | -| ---------------- | -------------------------------------------- | -| Label | `request.labels["notes/enabled"] === "true"` | -| Provider | `request.provider === "codex"` | -| Workspace | `request.workspaceId === "workspace-456"` | -| Individual agent | `request.agentId === chosenAgentId` | - -`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier -callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves -the request. Tools are ignored for `purpose: "history"`. - -Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from -`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: - -```ts -declare function defineAgentTool(tool: { - name: string; - title?: string; - description: string; - input: Input; - execute( - input: z.output, - context: AgentToolContext, - ): AgentToolResult | Promise; -}): AgentToolDefinition; - -interface AgentToolResult { - text?: string; - structuredContent?: unknown; - isError?: boolean; -} -``` - -| Execution context | Value | -| ----------------- | --------------------------------------------------------------------------------------------------- | -| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | -| `paseo` | SDK connected to this daemon | -| `signal` | Abort signal for this tool call; pass it to file operations or external requests | - -`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool -metadata and call inputs/results cross the process boundary. Paseo validates input before calling -`execute`. Invalid input returns schema issues to the agent. A thrown error becomes -`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its -transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate -from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. - -Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is -omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. - -Paseo delivers attached tools through its agent tool transport, using MCP or native registration -as the provider supports. The same definition and callback work with either. Attached tools obey -the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. - -Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo -tool or another plugin's tool on the same agent fails the session open and names both owners. -A duplicate name within one plugin also fails the session open. Agents the callback does not -target are unaffected. - -| Change | When the agent receives it | -| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | -| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | -| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | -| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | - -If an automatic reopen fails, the triggering prompt fails with that error. See the -[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). - -### 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. The helper imports types from `@getpaseo/protocol/agent-types`; add -`@getpaseo/protocol` at the same version as your plugin SDK to your development dependencies -and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -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 agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -The handler fetches only the last 20 timeline items. Output before that page is not inspected. -Use `cursor` and `direction` to page further when your plugin needs more history. - -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/server"; -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.closed` | `agent` | A live agent runtime closes | -| `agent.turn_started` | `agent`, `turnId` | Live turn starts | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, deprecated `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 the daemon's private disposable helpers. Public background workspaces and -their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede -runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters. - -`agent.turn_ended.timeline` is deprecated and always empty. Fetch history on demand with -`context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })`. -The hook payload size does not grow with conversation history. - -**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`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | - -**`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` | Opaque provider-specific options | -| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | -| `cwd` | Cannot change | - -**`agent.session_open` request example:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "title": "Workspace notes", - "labels": { "notes/enabled": "true" }, - "tools": [], - "mcpServers": {}, - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| Field | Values | -| ------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `workspaceId` | String or `null` | -| `title` | Read-only string or `null`; creation title on create, stored title on resume. | -| `labels` | Read-only string map; creation labels on create, stored labels on resume. | -| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | -| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | -| `reason` | `create`, `resume`, `refresh`, `import` | -| `purpose` | `interactive`, `history` | -| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | - -### 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 and plugin-local tools and MCP servers) - → 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 | - -There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` -accumulate only across callbacks within the same plugin. Returning a replacement map or array -replaces that scope's prior value. - -### 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 | -| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | - -Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. +Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. ## Screens and sidebar items diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 6963836696..5375b98c68 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -508,466 +508,7 @@ export default function contribute(client: PluginClientContext) { ## 生命周期钩子 {#lifecycle-hooks} -在 `index.server.ts` 中: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", (event) => { - console.log(event.agent.id, event.outcome); - }); - - return () => {}; -} -``` - -| 注册 | 回调接收 | 返回 | -| ------------------------------- | ---------------------------------- | --------------------------------------------------- | -| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` 或 `Promise` | -| `server.before(name, callback)` | `({ request }, { paseo, signal })` | 修改后的请求,或返回 `undefined` 保持不变;支持异步 | - -只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 - -`agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。 -`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。 - -### 修改已保存的配置 {#change-saved-configuration} - -在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: - -```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", - }, - }, - }; -}); -``` - -| 输入 | 结果 | -| ------------------------------------------- | ------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | 由展开运算保留 | - -所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 - -### 注入 MCP 服务器 {#inject-mcp-servers} - -需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。 -插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: - -```ts -server.before("agent.session_open", ({ request }) => { - if (request.provider !== "codex") return; - - return { - ...request, - mcpServers: { - ...request.mcpServers, - company: { type: "http", url: "https://tools.example.com/mcp" }, - }, - }; -}); -``` - -`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 -每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 -替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 -Paseo 按插件 ID 顺序合并各插件的最终映射。 - -名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 -不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 -`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent -通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 - -### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 - -### 为 Agent 附加工具 {#attach-tools-to-agents} - -在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。 -此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: - -```ts -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; - -const appendWorkspaceNote = defineAgentTool({ - name: "append_workspace_note", - description: "Append a note to AGENT_NOTES.md in this agent's working directory.", - input: z.object({ note: z.string().min(1).describe("Note to append") }), - async execute({ note }, { agent, signal }) { - const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); - await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); - return { - text: `Saved note to ${notePath}`, - structuredContent: { path: notePath }, - }; - }, -}); - -export default function contribute(server: PluginServerContext) { - server.before("agent.session_open", ({ request }) => { - if (request.labels["notes/enabled"] !== "true") return; - return { ...request, tools: [...request.tools, appendWorkspaceNote] }; - }); - return () => {}; -} -``` - -通过 SDK 创建 Agent 时设置标签: - -```ts -const agent = await paseo.agents.create({ - config: { provider: "codex/gpt-5.4" }, - cwd: "/projects/shop", - labels: { "notes/enabled": "true" }, -}); -``` - -使用会话打开请求中的字段选择 Agent: - -| 选择依据 | 回调中的条件 | -| ---------- | -------------------------------------------- | -| 标签 | `request.labels["notes/enabled"] === "true"` | -| Provider | `request.provider === "codex"` | -| 工作区 | `request.workspaceId === "workspace-456"` | -| 单个 Agent | `request.agentId === chosenAgentId` | - -每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; -替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 - -从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` -和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: - -```ts -declare function defineAgentTool(tool: { - name: string; - title?: string; - description: string; - input: Input; - execute( - input: z.output, - context: AgentToolContext, - ): AgentToolResult | Promise; -}): AgentToolDefinition; - -interface AgentToolResult { - text?: string; - structuredContent?: unknown; - isError?: boolean; -} -``` - -| 执行 context | 值 | -| ------------ | --------------------------------------------------------------------------------------------------- | -| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | -| `paseo` | 已连接到此 daemon 的 SDK | -| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | - -`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 -输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 -Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 -其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 -关闭或重新打开 Agent 会话会移除旧定义。 - -在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, -Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 - -Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 -同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 -只有该 Agent 能看到这些工具。 - -名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo -工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 -会话打开失败。回调未选中的 Agent 不受影响。 - -| 变更 | Agent 何时收到变更 | -| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | -| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | -| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | -| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | - -如果自动重新打开失败,触发它的提示词会返回该错误。参阅 -[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 - -### 选择工作区隔离方式 {#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", - }, - }; -}); -``` - -**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 - -### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} - -把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与插件 SDK 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -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 agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 - -这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 - -### 回应权限请求 {#answer-a-permission-request} - -使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -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 () => {}; -} -``` - -| 请求 | 结果 | -| ---------------- | -------------------- | -| `rm -rf build` | 拒绝 | -| `git status` | 批准 | -| 其他命令或请求 | 保持待处理,留给用户 | -| 已经处理过的请求 | SDK 响应失败 | - -这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 - -### 事件 {#events} - -| 名称 | 事件字段 | 触发时机 | -| ---------------------------- | ----------------------------------------------------- | ----------------------------- | -| `agent.created` | `agent` | 普通创建完成;不包括导入/恢复 | -| `agent.closed` | `agent` | 一个运行中的 Agent 运行时关闭 | -| `agent.turn_started` | `agent`, `turnId` | 实时轮次开始 | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, 已弃用的 `timeline: []` | 实时轮次完成、失败或被取消 | -| `agent.permission_requested` | `agent`, `request` | 权限请求或问题进入待处理状态 | -| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | 待处理的请求得到回应或被清除 | -| `agent.archived` | `agent`, `archivedAt` | 归档状态已保存 | -| `workspace.created` | `workspace` | 记录已创建;目录可用 | -| `workspace.archived` | `workspace` | 归档状态已保存 | - -Agent 事件不包括 daemon 私有的一次性辅助 Agent。公开的后台工作区及其普通 Agent 与其他工作区运行相同的生命周期钩子。归档事件可能早于运行时/worktree 清理;当清理很重要时,请单独观察生命周期何时完成。 - -`agent.turn_ended.timeline` 已弃用,并且始终为空。需要历史时,请用 `context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })` 按需获取。钩子负载的大小不会随对话历史增长。 - -**共享的负载结构**(`@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 }; -``` - -| 字段 | 结构 / 含义 | -| ------------ | ---------------------------------------------------------------------------------------- | -| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | -| `timeline` | `readonly AgentTimelineItem[]`;包含之前对话的完整快照;文本可能跨越多个条目 | -| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | -| `resolution` | SDK 的 `AgentPermissionResponse` | -| `archivedAt` | 时间戳字符串 | - -### before 钩子 {#before-hooks} - -| 名称 | 请求字段 | 可编辑 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | - -**`agent.create.config`** 使用 `AgentSessionConfig`: - -| 字段 | 约束 | -| --------------------------------------------- | -------------------------------------------------------- | -| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | -| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | -| `title`, `systemPrompt` | Agent 配置 | -| `providerOptions` | 不透明的、特定于 Provider 的选项 | -| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | -| `cwd` | 不能修改 | - -**`agent.session_open` 请求示例:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "title": "Workspace notes", - "labels": { "notes/enabled": "true" }, - "tools": [], - "mcpServers": {}, - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| 字段 | 取值 | -| ------------- | -------------------------------------------------------------------------------------------------------- | -| `workspaceId` | 字符串或 `null` | -| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | -| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | -| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | -| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | -| `reason` | `create`、`resume`、`refresh`、`import` | -| `purpose` | `interactive`、`history` | -| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | - -### 顺序与返回值 {#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 and plugin-local tools and MCP servers) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD - → open provider session and save agent configuration -``` - -| 回调返回 | 下一个回调收到 | -| ------------------------------------------------------- | --------------------------------- | -| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | -| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | -| `undefined` | 未改变的请求 | -| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | - -没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 -返回替换的映射或数组会替换该范围内之前的值。 - -### context 与清理 {#context-and-cleanup} - -| 契约 | 行为 | -| -------------------------- | ----------------------------------------------------------------------------------- | -| `context.paseo` | 已连接到此 daemon 的现有 SDK | -| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | -| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | -| 注册结果 | 幂等的移除函数,例如 `const remove = server.on(...); remove();` | -| 重新加载、停用、移除、关闭 | 移除剩余的注册 | -| 未知的钩子名称 | 注册失败 | -| 钩子超时 | 30 秒;会中止 signal。before 钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | -| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | -| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | -| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | - -### 完整示例 {#complete-examples} - -| 插件 | 包含 | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | -| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | -| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | -| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | - -用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 +使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 ## 界面和侧边栏项 {#screens-and-sidebar-items} diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 5bb1db793f..952f48687e 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -99,6 +99,8 @@ Drop a `paseo.json` in your repo root. Paseo reads it from the committed version ## Setup and teardown +Plugins can use [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) to write files before setup, terminals, and the first agent. + `setup` runs once after the worktree is created. A fresh worktree has no installed dependencies and no ignored files (like `.env`), so use setup to install and copy what you need. `teardown` runs during archive, before the directory is removed. ```json diff --git a/public-docs/worktrees.zh-CN.md b/public-docs/worktrees.zh-CN.md index 0bf3af55a4..aff3251af4 100644 --- a/public-docs/worktrees.zh-CN.md +++ b/public-docs/worktrees.zh-CN.md @@ -97,6 +97,8 @@ paseo workspace create \ ## Setup 与 teardown {#setup-and-teardown} +插件可以通过 [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) 在 setup、终端和第一个 Agent 运行之前写入文件。 + `setup` 在 worktree 创建后运行一次。新的 worktree 没有安装依赖,也没有被忽略的文件(例如 `.env`),因此请用 setup 安装和复制你需要的内容。`teardown` 在归档期间、目录被删除之前运行。 ```json