mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
* Auto-name workspaces created by agents * Require workspace auto-namer in websocket harnesses * Auto-title checkout worktrees without branch renames
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
import type { Logger } from "pino";
|
|
|
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
|
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
|
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
|
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
|
import { toWorktreeRequestError } from "../../worktree-errors.js";
|
|
import type {
|
|
AgentWorktreeSetupContinuation,
|
|
CreatePaseoWorktreeSetupContinuationInput,
|
|
CreatePaseoWorktreeWorkflowFn,
|
|
CreatePaseoWorktreeWorkflowResult,
|
|
} from "../../worktree-session.js";
|
|
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
|
import type { AgentManager, ManagedAgent } from "../agent-manager.js";
|
|
import type {
|
|
AgentPromptContentBlock,
|
|
AgentPromptInput,
|
|
AgentRunOptions,
|
|
AgentSessionConfig,
|
|
} from "../agent-sdk-types.js";
|
|
import type { AgentStorage } from "../agent-storage.js";
|
|
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
|
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
|
|
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
|
import { resolveRequiredProviderModel } from "../mcp-shared.js";
|
|
import {
|
|
appendTimelineItemIfAgentKnown,
|
|
emitLiveTimelineItemIfAgentKnown,
|
|
} from "../timeline-append.js";
|
|
|
|
export interface CreateAgentSessionWorktreeResult {
|
|
sessionConfig: AgentSessionConfig;
|
|
setupContinuation?: AgentWorktreeSetupContinuation;
|
|
// Set when this build created a fresh worktree workspace. The agent must be
|
|
// stamped with it so workspaceId-scoped archive can find the agent later.
|
|
createdWorkspaceId?: string;
|
|
}
|
|
|
|
interface CreateAgentCommandDependencies {
|
|
agentManager: AgentManager;
|
|
agentStorage: AgentStorage;
|
|
logger: Logger;
|
|
paseoHome?: string;
|
|
worktreesRoot?: string;
|
|
terminalManager?: TerminalManager | null;
|
|
providerSnapshotManager: ProviderSnapshotManager;
|
|
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
|
// Mints a fresh directory workspace for a cwd and returns its id.
|
|
ensureWorkspaceForCreate?: (
|
|
cwd: string,
|
|
firstAgentContext?: FirstAgentContext,
|
|
) => Promise<string>;
|
|
}
|
|
|
|
export interface CreateAgentFromSessionInput {
|
|
kind: "session";
|
|
config: AgentSessionConfig;
|
|
workspaceId?: string;
|
|
worktreeName?: string;
|
|
initialPrompt?: string;
|
|
clientMessageId?: string;
|
|
outputSchema?: Record<string, unknown>;
|
|
images?: Array<{ data: string; mimeType: string }>;
|
|
attachments?: AgentAttachment[];
|
|
git?: GitSetupOptions;
|
|
labels: Record<string, string>;
|
|
env?: Record<string, string>;
|
|
provisionalTitle: string | null;
|
|
firstAgentContext: FirstAgentContext;
|
|
buildSessionConfig: (
|
|
config: AgentSessionConfig,
|
|
gitOptions?: GitSetupOptions,
|
|
legacyWorktreeName?: string,
|
|
firstAgentContext?: FirstAgentContext,
|
|
) => Promise<CreateAgentSessionWorktreeResult>;
|
|
}
|
|
|
|
export interface CreateAgentFromMcpInput {
|
|
kind: "mcp";
|
|
provider: string;
|
|
title: string;
|
|
initialPrompt: string;
|
|
cwd?: string;
|
|
workspaceId?: string;
|
|
thinking?: string;
|
|
features?: Record<string, unknown>;
|
|
labels?: Record<string, string>;
|
|
mode?: string;
|
|
background: boolean;
|
|
notifyOnFinish: boolean;
|
|
detached?: boolean;
|
|
callerAgentId?: string;
|
|
callerContext?: {
|
|
lockedCwd?: string;
|
|
allowCustomCwd?: boolean;
|
|
childAgentDefaultLabels?: Record<string, string>;
|
|
} | null;
|
|
worktree?: {
|
|
worktreeName?: string;
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
refName?: string;
|
|
action?: "branch-off" | "checkout";
|
|
githubPrNumber?: number;
|
|
};
|
|
}
|
|
|
|
export type CreateAgentCommandInput = CreateAgentFromSessionInput | CreateAgentFromMcpInput;
|
|
|
|
export interface CreateAgentCommandResult {
|
|
snapshot: ManagedAgent;
|
|
liveSnapshot: ManagedAgent;
|
|
background: boolean;
|
|
initialPromptStarted: boolean;
|
|
}
|
|
|
|
interface ResolvedCreateAgent {
|
|
config: AgentSessionConfig;
|
|
createOptions?: AgentCreateOptions;
|
|
prompt?: AgentPromptInput;
|
|
runOptions?: AgentRunOptions;
|
|
setupContinuation?: AgentWorktreeSetupContinuation;
|
|
background: boolean;
|
|
promptFailure: "throw" | "log";
|
|
promptLogger?: Logger;
|
|
}
|
|
|
|
interface AgentCreateOptions {
|
|
labels?: Record<string, string>;
|
|
initialPrompt?: string;
|
|
env?: Record<string, string>;
|
|
initialTitle?: string | null;
|
|
workspaceId?: string;
|
|
}
|
|
|
|
export async function createAgentCommand(
|
|
dependencies: CreateAgentCommandDependencies,
|
|
input: CreateAgentCommandInput,
|
|
): Promise<CreateAgentCommandResult> {
|
|
const resolved =
|
|
input.kind === "session"
|
|
? await resolveSessionCreateAgent(dependencies, input)
|
|
: await resolveMcpCreateAgent(dependencies, input);
|
|
|
|
const snapshot = await dependencies.agentManager.createAgent(
|
|
resolved.config,
|
|
undefined,
|
|
resolved.createOptions,
|
|
);
|
|
|
|
resolved.setupContinuation?.startAfterAgentCreate({
|
|
agentId: snapshot.id,
|
|
});
|
|
|
|
let liveSnapshot = snapshot;
|
|
let initialPromptStarted = false;
|
|
if (resolved.prompt !== undefined) {
|
|
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
|
|
initialPromptStarted = sendResult.started;
|
|
liveSnapshot = sendResult.liveSnapshot;
|
|
}
|
|
|
|
if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) {
|
|
setupFinishNotification({
|
|
agentManager: dependencies.agentManager,
|
|
agentStorage: dependencies.agentStorage,
|
|
childAgentId: snapshot.id,
|
|
callerAgentId: input.callerAgentId,
|
|
logger: dependencies.logger,
|
|
});
|
|
}
|
|
|
|
return {
|
|
snapshot,
|
|
liveSnapshot,
|
|
background: resolved.background,
|
|
initialPromptStarted,
|
|
};
|
|
}
|
|
|
|
async function resolveSessionCreateAgent(
|
|
dependencies: CreateAgentCommandDependencies,
|
|
input: CreateAgentFromSessionInput,
|
|
): Promise<ResolvedCreateAgent> {
|
|
const trimmedPrompt = input.initialPrompt?.trim();
|
|
const { sessionConfig, setupContinuation, createdWorkspaceId } = await input.buildSessionConfig(
|
|
input.config,
|
|
input.git,
|
|
input.worktreeName,
|
|
input.firstAgentContext,
|
|
);
|
|
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
|
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
|
const clientMessageId = normalizeClientMessageId(input.clientMessageId);
|
|
const runOptions: AgentRunOptions | undefined =
|
|
input.outputSchema || clientMessageId
|
|
? {
|
|
...(input.outputSchema ? { outputSchema: input.outputSchema } : {}),
|
|
...(clientMessageId ? { messageId: clientMessageId } : {}),
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
config: sessionConfig,
|
|
createOptions: {
|
|
labels: input.labels,
|
|
initialPrompt: trimmedPrompt,
|
|
env: input.env,
|
|
initialTitle: input.provisionalTitle,
|
|
// A legacy git/worktreeName worktree creates a fresh workspace, so the
|
|
// agent belongs to that workspace, not the source one (mirrors the MCP
|
|
// path). createdWorkspaceId is the freshly created worktree's workspace.
|
|
workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
|
},
|
|
prompt: hasPromptContent ? prompt : undefined,
|
|
runOptions,
|
|
setupContinuation,
|
|
background: true,
|
|
promptFailure: "throw",
|
|
promptLogger: dependencies.logger.child({
|
|
clientMessageId: resolveClientMessageId(input.clientMessageId),
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function resolveMcpCreateAgent(
|
|
dependencies: CreateAgentCommandDependencies,
|
|
input: CreateAgentFromMcpInput,
|
|
): Promise<ResolvedCreateAgent> {
|
|
const resolvedProviderModel = resolveRequiredProviderModel(input.provider);
|
|
const provider = resolvedProviderModel.provider;
|
|
const parentAgent = input.callerAgentId
|
|
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
|
: null;
|
|
const cwd = parentAgent
|
|
? resolveChildAgentCwd({
|
|
parentCwd: parentAgent.cwd,
|
|
requestedCwd: input.cwd,
|
|
lockedCwd: input.callerContext?.lockedCwd,
|
|
allowCustomCwd: input.callerContext?.allowCustomCwd ?? true,
|
|
})
|
|
: expandUserPath(input.cwd ?? process.cwd());
|
|
const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({
|
|
dependencies,
|
|
cwd,
|
|
worktree: input.worktree,
|
|
initialPrompt: input.initialPrompt,
|
|
});
|
|
|
|
// MCP callers resolve workspace ownership before this point. Worktree
|
|
// creation wins because the new agent lives in the fresh worktree workspace.
|
|
// Otherwise use the explicit workspace id, then the parent workspace for
|
|
// direct internal callers. Ownership is never resolved from cwd.
|
|
const workspaceId = setupContinuation
|
|
? createdWorkspaceId
|
|
: (input.workspaceId ??
|
|
parentAgent?.workspaceId ??
|
|
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt)));
|
|
|
|
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
|
|
await dependencies.providerSnapshotManager.resolveCreateConfig({
|
|
cwd: resolvedCwd,
|
|
provider,
|
|
requestedMode: input.mode,
|
|
featureValues: input.features,
|
|
parent: parentAgent,
|
|
unattended: false,
|
|
});
|
|
|
|
const labels = mergeLabels({
|
|
callerAgentId: input.callerAgentId,
|
|
detached: input.detached ?? false,
|
|
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
|
|
labels: input.labels,
|
|
});
|
|
|
|
const trimmedPrompt = input.initialPrompt.trim();
|
|
return {
|
|
config: {
|
|
provider,
|
|
cwd: resolvedCwd,
|
|
modeId: resolvedMode,
|
|
title: input.title.trim(),
|
|
model: resolvedProviderModel.model,
|
|
thinkingOptionId: input.thinking,
|
|
...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}),
|
|
},
|
|
createOptions:
|
|
labels || workspaceId
|
|
? {
|
|
...(labels ? { labels } : {}),
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
}
|
|
: undefined,
|
|
prompt: trimmedPrompt,
|
|
setupContinuation,
|
|
background: input.background,
|
|
promptFailure: "log",
|
|
};
|
|
}
|
|
|
|
async function ensureWorkspaceForMcpCreate(
|
|
dependencies: CreateAgentCommandDependencies,
|
|
cwd: string,
|
|
initialPrompt: string,
|
|
): Promise<string | undefined> {
|
|
if (!dependencies.ensureWorkspaceForCreate) {
|
|
return undefined;
|
|
}
|
|
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
|
|
}
|
|
|
|
async function sendInitialPrompt(
|
|
dependencies: CreateAgentCommandDependencies,
|
|
resolved: ResolvedCreateAgent,
|
|
snapshot: ManagedAgent,
|
|
): Promise<{ started: boolean; liveSnapshot: ManagedAgent }> {
|
|
try {
|
|
const prompt = resolved.prompt;
|
|
if (prompt === undefined) {
|
|
return { started: false, liveSnapshot: snapshot };
|
|
}
|
|
const liveSnapshot = await startCreatedAgentInitialPrompt({
|
|
agentManager: dependencies.agentManager,
|
|
agentId: snapshot.id,
|
|
snapshot,
|
|
prompt,
|
|
runOptions: resolved.runOptions,
|
|
logger: resolved.promptLogger ?? dependencies.logger,
|
|
});
|
|
return { started: true, liveSnapshot };
|
|
} catch (error) {
|
|
if (resolved.promptFailure === "throw") {
|
|
throw error;
|
|
}
|
|
dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
|
|
return { started: false, liveSnapshot: snapshot };
|
|
}
|
|
}
|
|
|
|
function buildAgentPrompt(
|
|
text: string,
|
|
images?: Array<{ data: string; mimeType: string }>,
|
|
attachments?: AgentAttachment[],
|
|
): AgentPromptInput {
|
|
const normalized = text.trim();
|
|
const hasImages = (images?.length ?? 0) > 0;
|
|
const hasAttachments = (attachments?.length ?? 0) > 0;
|
|
if (!hasImages && !hasAttachments) {
|
|
return normalized;
|
|
}
|
|
const blocks: AgentPromptContentBlock[] = [];
|
|
if (normalized.length > 0) {
|
|
blocks.push({ type: "text", text: normalized });
|
|
}
|
|
for (const image of images ?? []) {
|
|
blocks.push({ type: "image", data: image.data, mimeType: image.mimeType });
|
|
}
|
|
for (const attachment of attachments ?? []) {
|
|
blocks.push(attachment);
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent {
|
|
const parentAgent = agentManager.getAgent(parentAgentId);
|
|
if (!parentAgent) {
|
|
throw new Error(`Parent agent ${parentAgentId} not found`);
|
|
}
|
|
return parentAgent;
|
|
}
|
|
|
|
function resolveChildAgentCwd(params: {
|
|
parentCwd: string;
|
|
requestedCwd?: string;
|
|
lockedCwd?: string;
|
|
allowCustomCwd: boolean;
|
|
}): string {
|
|
const lockedCwd = params.lockedCwd?.trim();
|
|
if (lockedCwd) {
|
|
return expandUserPath(lockedCwd);
|
|
}
|
|
|
|
const requestedCwd = params.requestedCwd?.trim();
|
|
if (!requestedCwd || !params.allowCustomCwd) {
|
|
return params.parentCwd;
|
|
}
|
|
|
|
return resolvePathFromBase(params.parentCwd, requestedCwd);
|
|
}
|
|
|
|
async function resolveMcpCwd(params: {
|
|
dependencies: CreateAgentCommandDependencies;
|
|
cwd: string;
|
|
initialPrompt: string;
|
|
worktree: CreateAgentFromMcpInput["worktree"];
|
|
}): Promise<{
|
|
resolvedCwd: string;
|
|
setupContinuation?: AgentWorktreeSetupContinuation;
|
|
createdWorkspaceId?: string;
|
|
}> {
|
|
const { dependencies, worktree } = params;
|
|
if (!worktree) {
|
|
return { resolvedCwd: params.cwd };
|
|
}
|
|
const shouldCreateWorktree = Boolean(
|
|
worktree.worktreeName || worktree.refName || worktree.action || worktree.githubPrNumber,
|
|
);
|
|
if (!shouldCreateWorktree) {
|
|
return { resolvedCwd: params.cwd };
|
|
}
|
|
if (
|
|
worktree.worktreeName &&
|
|
!worktree.baseBranch &&
|
|
!worktree.refName &&
|
|
!worktree.action &&
|
|
worktree.githubPrNumber === undefined
|
|
) {
|
|
throw new Error("baseBranch is required when creating a worktree");
|
|
}
|
|
const baseBranch = worktree.baseBranch;
|
|
const createdWorktree = await createMcpWorktree({
|
|
input: {
|
|
cwd: params.cwd,
|
|
worktreeSlug: worktree.worktreeName,
|
|
branchName: worktree.branchName,
|
|
refName: worktree.refName,
|
|
action: worktree.action,
|
|
githubPrNumber: worktree.githubPrNumber,
|
|
firstAgentContext: { prompt: params.initialPrompt },
|
|
runSetup: false,
|
|
paseoHome: dependencies.paseoHome,
|
|
worktreesRoot: dependencies.worktreesRoot,
|
|
},
|
|
createPaseoWorktree: dependencies.createPaseoWorktree,
|
|
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
|
setupContinuation: {
|
|
kind: "agent",
|
|
terminalManager: dependencies.terminalManager ?? null,
|
|
appendTimelineItem: ({ agentId, item }) =>
|
|
appendTimelineItemIfAgentKnown({
|
|
agentManager: dependencies.agentManager,
|
|
agentId,
|
|
item,
|
|
}),
|
|
emitLiveTimelineItem: ({ agentId, item }) =>
|
|
emitLiveTimelineItemIfAgentKnown({
|
|
agentManager: dependencies.agentManager,
|
|
agentId,
|
|
item,
|
|
}),
|
|
logger: dependencies.logger,
|
|
},
|
|
});
|
|
return {
|
|
resolvedCwd: createdWorktree.worktree.worktreePath,
|
|
setupContinuation: createdWorktree.setupContinuation,
|
|
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
|
};
|
|
}
|
|
|
|
interface CreateMcpWorktreeOptions {
|
|
input: CreatePaseoWorktreeInput;
|
|
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
|
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
|
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
|
}
|
|
|
|
async function createMcpWorktree(
|
|
options: CreateMcpWorktreeOptions,
|
|
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
|
try {
|
|
if (!options.createPaseoWorktree) {
|
|
throw new Error("Paseo worktree service is not configured");
|
|
}
|
|
return await options.createPaseoWorktree(options.input, {
|
|
...(options.resolveDefaultBranch
|
|
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
|
: {}),
|
|
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
|
|
});
|
|
} catch (error) {
|
|
throw toWorktreeRequestError(error);
|
|
}
|
|
}
|
|
|
|
function mergeLabels(params: {
|
|
callerAgentId: string | undefined;
|
|
detached: boolean;
|
|
childAgentDefaultLabels: Record<string, string> | undefined;
|
|
labels: Record<string, string> | undefined;
|
|
}): Record<string, string> | undefined {
|
|
const mergedLabels = {
|
|
...(!params.detached && params.callerAgentId
|
|
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
|
|
: {}),
|
|
...params.childAgentDefaultLabels,
|
|
...params.labels,
|
|
};
|
|
if (params.detached) {
|
|
delete mergedLabels[PARENT_AGENT_ID_LABEL];
|
|
}
|
|
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
|
|
}
|