Files
paseo/packages/plugin/src/server/lifecycle.ts
T

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. An omitted `metadata` 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);
},
};
}