Files
paseo/packages/plugin/src/server/lifecycle.ts
T
Mohamed Boudra 21365c571b fix(plugins): omitted metadata in a hook result means unchanged
An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.
2026-11-02 03:42:12 +01:00

256 lines
8.6 KiB
TypeScript

import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
import type { PaseoApi } from "@getpaseo/client";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
export interface PluginHookContext {
paseo: PaseoApi;
signal: AbortSignal;
}
export interface PluginHookWorkspace {
id: string;
projectId: string;
cwd: string;
name: string | null;
archivedAt: string | null;
/** Data plugins stored on the workspace. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginHookWorktree {
path: string;
branch: string;
sourceCheckout: string;
}
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent {
id: string;
workspaceId: string | null;
parentAgentId: string | null;
provider: string;
cwd: string;
title: string | null;
/** Data plugins stored on the agent. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginSessionOpenRequest {
agentId: string;
workspaceId: string | null;
provider: string;
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
export type PluginTurnOutcome =
| { kind: "completed" }
| { kind: "failed"; error: { message: string; code?: string } }
| { kind: "canceled"; reason: string };
export interface PluginHookEvents {
"agent.turn.before": { agent: PluginHookAgent; turnId: string | null };
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
*/
timeline: readonly AgentTimelineItem[];
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
agent: PluginHookAgent;
requestId: string;
resolution: AgentPermissionResponse;
};
"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 PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": {
config: AgentSessionConfig;
env?: Record<string, string>;
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
metadata?: Record<string, JsonValue>;
};
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
/** What a hook may return: a field it leaves out stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
metadata?: Readonly<Record<string, JsonValue>>;
};
};
export type PluginHooks = {
[Name in keyof PluginHookRequests]: {
request: PluginHookRequests[Name];
result: PluginHookRequestResults[Name];
};
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
};
export type PluginHookPayload<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request }
? { request: Request }
: PluginHooks[Key] extends { payload: infer Payload }
? Payload
: never;
}[Name];
export type PluginHookResult<Name extends keyof PluginHooks> = {
[Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result }
? Result | void
: void;
}[Name];
type HookHandlers = {
[Name in keyof PluginHooks]: (
payload: PluginHookPayload<Name>,
context: PluginHookContext,
) => Promise<PluginHookResult<Name>>;
};
export interface PluginLifecycleRegistration {
addHook<Name extends keyof PluginHooks>(name: Name, handler: HookHandlers[Name]): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
on<Name extends keyof PluginLifecycleEvents>(
name: Name,
handler: (
event: PluginLifecycleEvents[Name],
context: PluginHookContext,
) => void | Promise<void>,
): () => void;
/** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */
before<Name extends keyof PluginBeforeRequests>(
name: Name,
handler: (
input: { request: PluginBeforeRequests[Name] },
context: PluginHookContext,
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
): () => 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.before": { waits: true },
"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<keyof PluginHooks, { waits: boolean }>;
// 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<string>(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);
},
};
}