mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
* feat(worktrees): let plugins choose creation options and placement Await the worktree creation request hook before Git source planning so plugins can change placement, branch and base without undoing a fetch. Manage arbitrary destinations through marker and registry ownership, and restore them at their recorded paths. Co-authored-by: Matt Cowger <matt@cowger.us> * fix(worktrees): compare repository paths across Windows aliases Deletion must recognize the same repository through Git native and long path spellings. Reuse ownership path equivalence instead of comparing canonical strings, and let the recovery fixture accept Git platform line endings. Co-authored-by: Matt Cowger <matt@cowger.us> * fix(worktrees): respect recorded ownership and creation intent Keep branch checkout distinct from branch-off, validate plugin placement once at the daemon boundary, and share default path collision resolution. A restored external worktree's marker must never override its recorded non-ownership. Co-authored-by: Matt Cowger <matt@cowger.us> * test(worktrees): record ownership in session archive fixture The fixture creates a Paseo-owned worktree, so its durable record must say so. Archive now treats a recorded false value as authoritative instead of replacing it with filesystem discovery. Co-authored-by: Matt Cowger <matt@cowger.us> * test(plugins): retry temp cleanup after worktree archive on Windows Windows releases directory handles after the daemon closes, so rmdir on the source repository can hit EBUSY. Retry like the other git temp-dir tests. * test(plugins): compare plugin-chosen worktree paths in canonical spelling Windows temp directories come back as 8.3 aliases while the daemon records the resolved spelling, so normalize the expected root the same way. --------- Co-authored-by: Matt Cowger <matt@cowger.us>
238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
import type { AgentToolDefinition } from "./agent-tools.js";
|
|
import type {
|
|
AgentPermissionRequest,
|
|
AgentPermissionResponse,
|
|
AgentTimelineItem,
|
|
AgentSessionConfig,
|
|
} from "@getpaseo/protocol/agent-types";
|
|
import type { PaseoApi } from "@getpaseo/client";
|
|
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
|
|
|
export interface PluginHookContext {
|
|
paseo: PaseoApi;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export interface PluginHookWorkspace {
|
|
id: string;
|
|
projectId: string;
|
|
cwd: string;
|
|
name: string | null;
|
|
archivedAt: string | null;
|
|
}
|
|
|
|
export interface 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;
|
|
}
|
|
|
|
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>>;
|
|
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> };
|
|
"agent.open.before": PluginSessionOpenRequest;
|
|
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
|
}
|
|
|
|
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<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);
|
|
},
|
|
};
|
|
}
|