refactor: unify worktree creation workflow (#636)

Route app, MCP, and agent worktree creation through one workflow boundary. Move branch auto-naming into workspace creation and keep agent metadata title-only.
This commit is contained in:
Mohamed Boudra
2026-04-30 21:37:17 +08:00
committed by GitHub
parent 93c6c3e5f8
commit c2ad4a02ef
15 changed files with 1328 additions and 569 deletions
+2 -1
View File
@@ -243,7 +243,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
export interface CreatePaseoWorktreeInput extends Pick< export interface CreatePaseoWorktreeInput extends Pick<
CreatePaseoWorktreeRequest, CreatePaseoWorktreeRequest,
"cwd" | "worktreeSlug" | "attachments" | "refName" | "action" | "githubPrNumber" "cwd" | "worktreeSlug" | "nameContext" | "attachments" | "refName" | "action" | "githubPrNumber"
> {} > {}
type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -2757,6 +2757,7 @@ export class DaemonClient {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
cwd: input.cwd, cwd: input.cwd,
worktreeSlug: input.worktreeSlug, worktreeSlug: input.worktreeSlug,
...(input.nameContext !== undefined ? { nameContext: input.nameContext } : {}),
...(input.attachments && input.attachments.length > 0 ...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments } ? { attachments: input.attachments }
: {}), : {}),
@@ -1,4 +1,3 @@
import { basename } from "path";
import { z } from "zod"; import { z } from "zod";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -9,15 +8,10 @@ import {
StructuredAgentResponseError, StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback, generateStructuredAgentResponseWithFallback,
} from "./agent-response-loop.js"; } from "./agent-response-loop.js";
import { validateBranchSlug } from "../../utils/worktree.js";
import { renameCurrentBranch } from "../../utils/checkout-git.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
export interface AgentMetadataGeneratorDeps { export interface AgentMetadataGeneratorDeps {
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
renameCurrentBranch?: typeof renameCurrentBranch;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">;
} }
export interface AgentMetadataGenerationOptions { export interface AgentMetadataGenerationOptions {
@@ -34,7 +28,6 @@ export interface AgentMetadataGenerationOptions {
interface AgentMetadataNeeds { interface AgentMetadataNeeds {
prompt: string | null; prompt: string | null;
needsTitle: boolean; needsTitle: boolean;
needsBranch: boolean;
} }
function hasExplicitTitle(title?: string | null): boolean { function hasExplicitTitle(title?: string | null): boolean {
@@ -49,33 +42,6 @@ function normalizeAutoTitle(title: string): string | null {
return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null; return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null;
} }
async function canRenameBranch(
cwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot"> | undefined,
): Promise<boolean> {
if (!workspaceGitService) {
return false;
}
let snapshot: WorkspaceGitRuntimeSnapshot;
try {
snapshot = await workspaceGitService.getSnapshot(cwd);
} catch {
return false;
}
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree) {
return false;
}
if (!snapshot.git.currentBranch || !snapshot.git.repoRoot) {
return false;
}
const worktreeDirName = basename(snapshot.git.repoRoot);
return snapshot.git.currentBranch === worktreeDirName;
}
export async function determineAgentMetadataNeeds( export async function determineAgentMetadataNeeds(
options: Pick< options: Pick<
AgentMetadataGenerationOptions, AgentMetadataGenerationOptions,
@@ -84,23 +50,21 @@ export async function determineAgentMetadataNeeds(
): Promise<AgentMetadataNeeds> { ): Promise<AgentMetadataNeeds> {
const prompt = options.initialPrompt?.trim(); const prompt = options.initialPrompt?.trim();
if (!prompt) { if (!prompt) {
return { prompt: null, needsTitle: false, needsBranch: false }; return { prompt: null, needsTitle: false };
} }
const needsTitle = !hasExplicitTitle(options.explicitTitle); const needsTitle = !hasExplicitTitle(options.explicitTitle);
const needsBranch = await canRenameBranch(options.cwd, options.deps?.workspaceGitService);
return { return {
prompt, prompt,
needsTitle, needsTitle,
needsBranch,
}; };
} }
function buildMetadataSchema( function buildMetadataSchema(
needs: AgentMetadataNeeds, needs: AgentMetadataNeeds,
): z.ZodObject<Record<string, z.ZodTypeAny>> | null { ): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
if (!needs.needsTitle && !needs.needsBranch) { if (!needs.needsTitle) {
return null; return null;
} }
@@ -108,33 +72,16 @@ function buildMetadataSchema(
if (needs.needsTitle) { if (needs.needsTitle) {
shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS); shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS);
} }
if (needs.needsBranch) {
shape.branch = z.string().min(1).max(100);
}
return z.object(shape); return z.object(shape);
} }
function buildPrompt(needs: AgentMetadataNeeds): string { function buildPrompt(needs: AgentMetadataNeeds): string {
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
Boolean,
) as string[];
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."]; const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
if (needs.needsTitle) { if (needs.needsTitle) {
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`); instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
} }
if (needs.needsBranch) { instructions.push("Return JSON only with a single field 'title'.");
instructions.push(
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens.",
);
}
if (fields.length === 1) {
instructions.push(`Return JSON only with a single field '${fields[0]}'.`);
} else {
instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`);
}
instructions.push("", "User prompt:", needs.prompt ?? ""); instructions.push("", "User prompt:", needs.prompt ?? "");
return instructions.join("\n"); return instructions.join("\n");
@@ -156,9 +103,8 @@ export async function generateAndApplyAgentMetadata(
const generator = const generator =
options.deps?.generateStructuredAgentResponseWithFallback ?? options.deps?.generateStructuredAgentResponseWithFallback ??
generateStructuredAgentResponseWithFallback; generateStructuredAgentResponseWithFallback;
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
let result: { title?: string; branch?: string }; let result: { title?: string };
try { try {
result = await generator({ result = await generator({
@@ -198,78 +144,6 @@ export async function generateAndApplyAgentMetadata(
await options.agentManager.setTitle(options.agentId, normalizedTitle); await options.agentManager.setTitle(options.agentId, normalizedTitle);
} }
} }
if (needs.needsBranch && typeof result.branch === "string") {
await applyGeneratedBranchRename({
options,
branch: result.branch,
renameCurrentBranchImpl,
});
}
}
async function applyGeneratedBranchRename(params: {
options: AgentMetadataGenerationOptions;
branch: string;
renameCurrentBranchImpl: typeof renameCurrentBranch;
}): Promise<void> {
const { options, branch, renameCurrentBranchImpl } = params;
const normalizedBranch = branch.trim();
const validation = validateBranchSlug(normalizedBranch);
if (!validation.valid) {
options.logger.warn(
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
"Generated branch name is invalid",
);
return;
}
const workspaceGitService = options.deps?.workspaceGitService;
if (!workspaceGitService) {
return;
}
let snapshot: WorkspaceGitRuntimeSnapshot;
try {
snapshot = await workspaceGitService.getSnapshot(options.cwd);
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Failed to re-check branch eligibility",
);
return;
}
if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) {
return;
}
const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null;
if (snapshot.git.currentBranch !== worktreeDirName) {
return;
}
try {
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
try {
await workspaceGitService.getSnapshot(options.cwd, {
force: true,
reason: "rename-branch",
});
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, cwd: options.cwd },
"Failed to force-refresh workspace git snapshot after branch rename",
);
}
options.agentManager.notifyAgentState(options.agentId);
await options.agentManager.flush();
} catch (error) {
options.logger.warn(
{ err: error, agentId: options.agentId, branch: normalizedBranch },
"Failed to rename branch",
);
}
} }
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void { export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
@@ -7,34 +7,9 @@ import {
type AgentMetadataGeneratorDeps, type AgentMetadataGeneratorDeps,
} from "./agent-metadata-generator.js"; } from "./agent-metadata-generator.js";
import type { AgentManager } from "./agent-manager.js"; import type { AgentManager } from "./agent-manager.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
const logger = createTestLogger(); const logger = createTestLogger();
const ELIGIBLE_WORKTREE_SNAPSHOT: WorkspaceGitRuntimeSnapshot = {
cwd: "/tmp/repo/metadata-worktree",
git: {
isGit: true,
repoRoot: "/tmp/repo/metadata-worktree",
mainRepoRoot: "/tmp/repo",
currentBranch: "metadata-worktree",
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
baseRef: "main",
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
};
function createDeps( function createDeps(
generateStructuredAgentResponseWithFallback: NonNullable< generateStructuredAgentResponseWithFallback: NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
@@ -89,134 +64,30 @@ describe("agent metadata generator auto-title", () => {
expect(setTitle).not.toHaveBeenCalled(); expect(setTitle).not.toHaveBeenCalled();
}); });
it("notifies agent state after successfully renaming a generated branch", async () => { it("generates titles independently from workspace branch naming", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined); const setTitle = vi.fn().mockResolvedValue(undefined);
const notifyAgentState = vi.fn(); const manager = { setTitle } as unknown as AgentManager;
const flush = vi.fn().mockResolvedValue(undefined); const generateStructured = vi
const manager = { .fn()
setTitle, .mockResolvedValue({ title: "Generated title" }) as NonNullable<
notifyAgentState, AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
flush, >;
} as unknown as AgentManager;
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
await generateAndApplyAgentMetadata({ await generateAndApplyAgentMetadata({
agentManager: manager, agentManager: manager,
agentId: "agent-branch", agentId: "agent-suppressed-branch",
cwd: "/tmp/repo/metadata-worktree", cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.", initialPrompt: "Implement this feature",
explicitTitle: "Keep explicit title", explicitTitle: null,
paseoHome: "/tmp/paseo-home",
logger, logger,
deps: { deps: {
generateStructuredAgentResponseWithFallback: generateStructured, generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService,
}, },
}); });
expect(renameCurrentBranch).toHaveBeenCalledWith( expect(generateStructured).toHaveBeenCalledWith(
"/tmp/repo/metadata-worktree", expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }),
"feature/metadata-worktree",
);
expect(notifyAgentState).toHaveBeenCalledWith("agent-branch");
expect(setTitle).not.toHaveBeenCalled();
});
it("forces a workspace git snapshot refresh after renaming a generated branch", async () => {
const manager = {
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentManager;
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-branch-refresh",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.",
explicitTitle: "Keep explicit title",
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService: workspaceGitService as unknown as Pick<
import("../workspace-git-service.js").WorkspaceGitService,
"getSnapshot"
>,
},
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
force: true,
reason: "rename-branch",
});
});
it("uses the workspace git service snapshot for branch rename eligibility checks", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const notifyAgentState = vi.fn();
const manager = {
setTitle,
notifyAgentState,
flush: vi.fn().mockResolvedValue(undefined),
} as unknown as AgentManager;
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(ELIGIBLE_WORKTREE_SNAPSHOT),
};
const renameCurrentBranch = vi.fn().mockResolvedValue({
previousBranch: "metadata-worktree",
currentBranch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["renameCurrentBranch"]>;
const generateStructured = vi.fn().mockResolvedValue({
branch: "feature/metadata-worktree",
}) as NonNullable<AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-service-branch",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Rename this worktree branch.",
explicitTitle: "Keep explicit title",
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
renameCurrentBranch,
workspaceGitService: workspaceGitService as unknown as Pick<
import("../workspace-git-service.js").WorkspaceGitService,
"getSnapshot"
>,
},
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(3);
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo/metadata-worktree", {
force: true,
reason: "rename-branch",
});
expect(renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo/metadata-worktree",
"feature/metadata-worktree",
); );
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
}); });
}); });
@@ -18,8 +18,9 @@ import type { AgentProvider } from "./agent-sdk-types.js";
import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js";
import { import {
createPaseoWorktree as createPaseoWorktreeService, createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn, type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js"; } from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import { createWorktreeCoreDeps } from "../worktree-core.js"; import { createWorktreeCoreDeps } from "../worktree-core.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import type { GitHubService } from "../../services/github-service.js"; import type { GitHubService } from "../../services/github-service.js";
@@ -292,7 +293,9 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: string; paseoHome: string;
broadcasts: string[]; broadcasts: string[];
createdWorkspaceIds?: string[]; createdWorkspaceIds?: string[];
}): CreatePaseoWorktreeFn { setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>(); const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>(); const workspaces = new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub(); const github = createGitHubServiceStub();
@@ -303,6 +306,7 @@ function createPaseoWorktreeForMcpTest(options: {
}); });
return async (input, serviceOptions) => { return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const coreDeps = createWorktreeCoreDeps(github); const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktreeService(input, { const result = await createPaseoWorktreeService(input, {
...coreDeps, ...coreDeps,
@@ -326,6 +330,17 @@ function createPaseoWorktreeForMcpTest(options: {
}); });
options.broadcasts.push(result.workspace.workspaceId); options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId); options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") {
return {
...result,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
options.startedAgentSetupIds?.push(agentId);
},
},
};
}
return result; return result;
}; };
} }
@@ -478,13 +493,26 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true); expect(parsed.success).toBe(true);
}); });
it("accepts optional name context in create_worktree input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
nameContext: "Fix workspace creation naming",
});
expect(parsed.success).toBe(true);
});
it("rejects create_worktree without a branch name or checkout intent", async () => { it("rejects create_worktree without a branch name or checkout intent", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_worktree"); const tool = registeredTool(server, "create_worktree");
await expect(tool.callback({})).rejects.toThrow( await expect(tool.callback({})).rejects.toThrow(
"create_worktree requires branchName, refName, or githubPrNumber", "create_worktree requires branchName, nameContext, refName, or githubPrNumber",
); );
}); });
@@ -608,6 +636,8 @@ describe("create_agent MCP tool", () => {
const paseoHome = join(tempDir, ".paseo"); const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = []; const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = []; const createdWorkspaceIds: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
const startedAgentSetupIds: string[] = [];
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
@@ -636,6 +666,8 @@ describe("create_agent MCP tool", () => {
paseoHome, paseoHome,
broadcasts, broadcasts,
createdWorkspaceIds, createdWorkspaceIds,
setupContinuations,
startedAgentSetupIds,
}), }),
logger, logger,
}); });
@@ -653,6 +685,8 @@ describe("create_agent MCP tool", () => {
expect(broadcasts).toHaveLength(1); expect(broadcasts).toHaveLength(1);
expect(createdWorkspaceIds).toHaveLength(1); expect(createdWorkspaceIds).toHaveLength(1);
expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); expect(broadcasts[0]).toBe(createdWorkspaceIds[0]);
expect(setupContinuations).toEqual(["agent"]);
expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
cwd: expect.stringContaining("agent-worktree"), cwd: expect.stringContaining("agent-worktree"),
@@ -665,12 +699,243 @@ describe("create_agent MCP tool", () => {
} }
}); });
it("auto-names a create_agent branch-off worktree from the initial prompt without metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-named-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Worktree agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: repoDir,
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
action: "branch-off",
baseBranch: "main",
background: true,
});
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("fix-workspace-creation-naming");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-checkout-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Checkout agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: repoDir,
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
action: "checkout",
refName: "existing-feature",
background: true,
});
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const startedAgentSetupIds: string[] = [];
const createPaseoWorktree = vi.fn(
async (
input: CreatePaseoWorktreeInput,
options?: Parameters<CreatePaseoWorktreeWorkflowFn>[1],
) => ({
worktree: {
branchName: "pr-123",
worktreePath: "/tmp/worktrees/pr-123",
},
intent: {
kind: "checkout-github-pr" as const,
githubPrNumber: input.githubPrNumber ?? 123,
headRef: "pr-123",
baseRefName: "main",
},
workspace: {
workspaceId: "/tmp/worktrees/pr-123",
projectId: "/tmp/repo",
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
displayName: "pr-123",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: "/tmp/repo",
created: true,
...(options?.setupContinuation?.kind === "agent"
? {
setupContinuation: {
kind: "agent" as const,
startAfterAgentCreate: ({ agentId }: { agentId: string }) => {
startedAgentSetupIds.push(agentId);
},
},
}
: {}),
}),
);
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
}),
};
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-pr-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "PR agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
createPaseoWorktree,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.callback({
cwd: "/tmp/repo",
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
githubPrNumber: 123,
background: true,
});
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
githubPrNumber: 123,
nameContext: "Rename this PR branch from prompt",
}),
expect.objectContaining({
setupContinuation: expect.objectContaining({ kind: "agent" }),
}),
);
expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }),
undefined,
undefined,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => { it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-")); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo"); const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo"); const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = []; const broadcasts: string[] = [];
const setupContinuations: Array<"workspace" | "agent" | undefined> = [];
try { try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" }); execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
@@ -689,7 +954,11 @@ describe("create_agent MCP tool", () => {
agentManager, agentManager,
agentStorage, agentStorage,
paseoHome, paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
setupContinuations,
}),
workspaceGitService: workspaceGitService as unknown as Pick< workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService, WorkspaceGitService,
"getSnapshot" | "listWorktrees" "getSnapshot" | "listWorktrees"
@@ -705,17 +974,8 @@ describe("create_agent MCP tool", () => {
expect(response.structuredContent.branchName).toBe("tool-worktree"); expect(response.structuredContent.branchName).toBe("tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree"); expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, { expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
force: true, expect(setupContinuations).toEqual([undefined]);
reason: "create-worktree",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
response.structuredContent.worktreePath,
{
force: true,
reason: "create-worktree",
},
);
expect(broadcasts).toHaveLength(1); expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree"); expect(broadcasts[0]).toContain("tool-worktree");
} finally { } finally {
@@ -723,6 +983,106 @@ describe("create_agent MCP tool", () => {
} }
}); });
it("auto-names a standalone branch-off worktree from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
nameContext: "Fix workspace creation naming",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("fix-workspace-creation-naming");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("fix-workspace-creation-naming");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not rename checkout-created worktrees from create_worktree name context", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-checkout-worktree-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
try {
execSync(`git init ${JSON.stringify(repoDir)}`, { stdio: "pipe" });
execSync("git config user.email test@example.com", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: repoDir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "README.md"), "hello\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m init", { cwd: repoDir, stdio: "pipe" });
execSync("git branch -M main", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout -b existing-feature", { cwd: repoDir, stdio: "pipe" });
await writeFile(join(repoDir, "feature.txt"), "feature\n");
execSync("git add feature.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m feature", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
logger,
});
const tool = registeredTool(server, "create_worktree");
const response = await tool.callback({
cwd: repoDir,
action: "checkout",
refName: "existing-feature",
nameContext: "Should Not Rename Checkout",
});
expect(response.structuredContent.branchName).toBe("existing-feature");
expect(
execSync("git branch --show-current", {
cwd: response.structuredContent.worktreePath as string,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("existing-feature");
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => { it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
const { agentManager, agentStorage } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-"));
+53 -84
View File
@@ -27,7 +27,7 @@ import {
appendTimelineItemIfAgentKnown, appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js"; } from "./timeline-append.js";
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js"; import { getPaseoWorktreesRoot } from "../../utils/worktree.js";
import { import {
archivePaseoWorktree, archivePaseoWorktree,
killTerminalsUnderPath, killTerminalsUnderPath,
@@ -39,7 +39,12 @@ import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js"; import { captureTerminalLines } from "../../terminal/terminal.js";
import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../worktree-session.js";
import type { ScheduleService } from "../schedule/service.js"; import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
@@ -61,11 +66,7 @@ import {
} from "./mcp-shared.js"; } from "./mcp-shared.js";
import type { GitHubService } from "../../services/github-service.js"; import type { GitHubService } from "../../services/github-service.js";
import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
CreatePaseoWorktreeFn,
CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js"; import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path"; import { join } from "node:path";
@@ -83,7 +84,7 @@ export interface AgentMcpServerOptions {
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"]; emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
createPaseoWorktree?: CreatePaseoWorktreeFn; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
paseoHome?: string; paseoHome?: string;
/** /**
* ID of the agent that is connecting to this MCP server. * ID of the agent that is connecting to this MCP server.
@@ -625,8 +626,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: boolean; notifyOnFinish: boolean;
resolvedCwd: string; resolvedCwd: string;
resolvedMode: string | undefined; resolvedMode: string | undefined;
worktreeConfig: WorktreeConfig | undefined; setupContinuation: AgentWorktreeSetupContinuation | undefined;
shouldBootstrapWorktree: boolean | undefined;
} }
const resolveCallerCreateAgentArgs = ( const resolveCallerCreateAgentArgs = (
@@ -661,8 +661,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: callerArgs.notifyOnFinish ?? false, notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
resolvedMode, resolvedMode,
worktreeConfig: undefined, setupContinuation: undefined,
shouldBootstrapWorktree: undefined,
}; };
}; };
@@ -673,11 +672,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
let resolvedCwd = expandUserPath(cwd); let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined; let setupContinuation: AgentWorktreeSetupContinuation | undefined;
let shouldBootstrapWorktree: boolean | undefined;
if (worktreeName) { const shouldCreateWorktree = Boolean(worktreeName || refName || action || githubPrNumber);
if (!baseBranch && !refName && !action && githubPrNumber === undefined) { if (shouldCreateWorktree) {
if (worktreeName && !baseBranch && !refName && !action && githubPrNumber === undefined) {
throw new Error("baseBranch is required when creating a worktree"); throw new Error("baseBranch is required when creating a worktree");
} }
const createdWorktree = await createMcpWorktree({ const createdWorktree = await createMcpWorktree({
@@ -687,17 +686,32 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
refName, refName,
action, action,
githubPrNumber, githubPrNumber,
nameContext: topLevelArgs.initialPrompt,
runSetup: false, runSetup: false,
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
}, },
createPaseoWorktree: options.createPaseoWorktree, createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
workspaceGitService: options.workspaceGitService, setupContinuation: {
logger: options.logger, kind: "agent",
terminalManager: terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId,
item,
}),
logger: childLogger,
},
}); });
resolvedCwd = createdWorktree.worktree.worktreePath; resolvedCwd = createdWorktree.worktree.worktreePath;
worktreeConfig = createdWorktree.worktree; setupContinuation = createdWorktree.setupContinuation;
shouldBootstrapWorktree = createdWorktree.created;
} }
return { return {
@@ -711,8 +725,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
resolvedMode: mode, resolvedMode: mode,
worktreeConfig, setupContinuation,
shouldBootstrapWorktree,
}; };
}; };
@@ -755,8 +768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
notifyOnFinish, notifyOnFinish,
resolvedCwd, resolvedCwd,
resolvedMode, resolvedMode,
worktreeConfig, setupContinuation,
shouldBootstrapWorktree,
} = resolved; } = resolved;
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
@@ -778,27 +790,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined, Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
); );
if (worktreeConfig) { setupContinuation?.startAfterAgentCreate({
void runAsyncWorktreeBootstrap({ agentId: snapshot.id,
agentId: snapshot.id, });
worktree: worktreeConfig,
shouldBootstrap: shouldBootstrapWorktree,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt.trim(); const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({ scheduleAgentMetadataGeneration({
@@ -809,11 +803,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
explicitTitle: snapshot.config.title, explicitTitle: snapshot.config.title,
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
logger: childLogger, logger: childLogger,
deps: options.workspaceGitService
? {
workspaceGitService: options.workspaceGitService,
}
: undefined,
}); });
try { try {
@@ -1779,6 +1768,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
.optional() .optional()
.describe("Optional repository cwd. Defaults to the caller agent cwd."), .describe("Optional repository cwd. Defaults to the caller agent cwd."),
branchName: z.string().optional(), branchName: z.string().optional(),
nameContext: z.string().optional(),
baseBranch: z.string().optional(), baseBranch: z.string().optional(),
refName: z.string().min(1).optional(), refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(), action: z.enum(["branch-off", "checkout"]).optional(),
@@ -1789,15 +1779,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
worktreePath: z.string(), worktreePath: z.string(),
}, },
}, },
async ({ cwd, branchName, baseBranch, refName, action, githubPrNumber }) => { async ({ cwd, branchName, nameContext, baseBranch, refName, action, githubPrNumber }) => {
if (!branchName && !refName && githubPrNumber === undefined) { if (!branchName && !nameContext && !refName && githubPrNumber === undefined) {
throw new Error("create_worktree requires branchName, refName, or githubPrNumber"); throw new Error(
"create_worktree requires branchName, nameContext, refName, or githubPrNumber",
);
} }
const repoRoot = resolveScopedCwd(cwd, { required: true }); const repoRoot = resolveScopedCwd(cwd, { required: true });
const createdWorktree = await createMcpWorktree({ const createdWorktree = await createMcpWorktree({
input: { input: {
cwd: repoRoot, cwd: repoRoot,
worktreeSlug: branchName, worktreeSlug: branchName,
nameContext,
refName, refName,
action, action,
githubPrNumber, githubPrNumber,
@@ -1806,8 +1799,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}, },
createPaseoWorktree: options.createPaseoWorktree, createPaseoWorktree: options.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
workspaceGitService: options.workspaceGitService,
logger: options.logger,
}); });
const { worktree } = createdWorktree; const { worktree } = createdWorktree;
@@ -2050,46 +2041,24 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
interface CreateMcpWorktreeOptions { interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput; input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeFn | undefined; createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>; resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot">; setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
logger: Logger;
} }
async function createMcpWorktree( async function createMcpWorktree(
options: CreateMcpWorktreeOptions, options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeResult> { ): Promise<CreatePaseoWorktreeWorkflowResult> {
try { try {
if (!options.createPaseoWorktree) { if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured"); throw new Error("Paseo worktree service is not configured");
} }
const result = await options.createPaseoWorktree(options.input, { const result = await options.createPaseoWorktree(options.input, {
resolveDefaultBranch: options.resolveDefaultBranch, ...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
}); });
if (options.workspaceGitService) {
const refreshResults = await Promise.allSettled([
options.workspaceGitService.getSnapshot(options.input.cwd, {
force: true,
reason: "create-worktree",
}),
options.workspaceGitService.getSnapshot(result.worktree.worktreePath, {
force: true,
reason: "create-worktree",
}),
]);
for (const [index, refreshResult] of refreshResults.entries()) {
if (refreshResult.status === "fulfilled") {
continue;
}
options.logger.warn(
{
err: refreshResult.reason,
cwd: index === 0 ? options.input.cwd : result.worktree.worktreePath,
},
"Failed to force-refresh workspace git snapshot after creating worktree",
);
}
}
return result; return result;
} catch (error) { } catch (error) {
throw toWorktreeRequestError(error); throw toWorktreeRequestError(error);
+49 -18
View File
@@ -88,7 +88,8 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js"; import { createGitHubService } from "../services/github-service.js";
import { createPaseoWorktree } from "./paseo-worktree-service.js"; import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { createWorktreeCoreDeps } from "./worktree-core.js"; import { createWorktreeCoreDeps } from "./worktree-core.js";
import { DownloadTokenStore } from "./file-download/token-store.js"; import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
@@ -562,24 +563,54 @@ export async function createPaseoDaemon(
clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive, clearWorkspaceArchiving: clearWorkspaceArchivingForMcpArchive,
emitSessionMessage: emitMcpArchiveSessionMessage, emitSessionMessage: emitMcpArchiveSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => { createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github); return createPaseoWorktreeWorkflow(
const result = await createPaseoWorktree(input, { {
...coreDeps, paseoHome: config.paseoHome,
...(serviceOptions?.resolveDefaultBranch createPaseoWorktree: async (workflowInput, workflowOptions) => {
? { const coreDeps = createWorktreeCoreDeps(github);
resolveDefaultBranch: serviceOptions.resolveDefaultBranch, return createRegisteredPaseoWorktree(workflowInput, {
} ...coreDeps,
: {}), ...(workflowOptions?.resolveDefaultBranch
projectRegistry, ? {
workspaceRegistry, resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
workspaceGitService, }
}); : {}),
await Promise.all( projectRegistry,
wsServer workspaceRegistry,
?.listActiveSessions() workspaceGitService,
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [], });
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
);
void emitOptions;
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitMcpArchiveSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
scriptRouteStore,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
onScriptsChanged: null,
},
input,
serviceOptions,
); );
return result;
}, },
paseoHome: config.paseoHome, paseoHome: config.paseoHome,
callerAgentId, callerAgentId,
@@ -1,15 +1,19 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js"; import {
attemptFirstAgentBranchAutoName,
createPaseoWorktree,
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js"; import { createWorktreeCoreDeps } from "./worktree-core.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = []; const cleanupPaths: string[] = [];
@@ -98,6 +102,150 @@ test("reuses an existing worktree and still upserts the workspace", async () =>
expect(events).toContain(`workspace:${second.workspace.workspaceId}`); expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
}); });
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps({
generateBranchName: (seed) => (seed ? "renamed-from-agent-context" : "unnamed-placeholder"),
});
const created = await createPaseoWorktree(
{
cwd: repoDir,
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(created.worktree.branchName).toBe("unnamed-placeholder");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName: "unnamed-placeholder",
},
});
const first = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Build the agent context name",
generateBranchName: deps.generateBranchName,
});
const branchAfterFirst = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(first).toEqual({
attempted: true,
renamed: true,
branchName: "renamed-from-agent-context",
});
expect(branchAfterFirst).toBe("renamed-from-agent-context");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "unnamed-placeholder",
},
});
const second = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Try another name",
generateBranchName: () => "second-agent-name",
});
const branchAfterSecond = execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(second).toEqual({ attempted: false, renamed: false, branchName: null });
expect(branchAfterSecond).toBe("renamed-from-agent-context");
});
test("does not mark checkout branch worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execSync("git checkout -b dev", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "dev branch\n");
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
execSync("git commit -m dev", { cwd: repoDir, stdio: "pipe" });
execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree(
{
cwd: repoDir,
action: "checkout",
refName: "dev",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "dev",
});
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Rename checkout branch",
generateBranchName: () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("dev");
});
test("does not mark GitHub PR checkout worktrees as eligible for first-agent rename", async () => {
const { repoDir, tempDir } = createGitHubPrRemoteRepo();
cleanupPaths.push(tempDir);
const created = await createPaseoWorktree(
{
cwd: repoDir,
action: "checkout",
githubPrNumber: 123,
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
nameContext: "Rename PR checkout",
generateBranchName: () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execSync("git branch --show-current", {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("pr-123");
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => { test("does not mutate registries or broadcast when core worktree creation fails", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-"));
cleanupPaths.push(tempDir); cleanupPaths.push(tempDir);
@@ -119,21 +267,6 @@ test("does not mutate registries or broadcast when core worktree creation fails"
expect(deps.workspaces.size).toBe(0); expect(deps.workspaces.size).toBe(0);
}); });
test("keeps direct core worktree creation calls behind the service boundary", () => {
// Keep this literal in the test file so the grep invariant sees createWorktreeCore( here.
const serverSrc = path.dirname(fileURLToPath(import.meta.url));
const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => {
if (path.basename(filePath) === "worktree-core.ts") {
return [];
}
const contents = readFileSync(filePath, "utf8");
const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g");
return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath));
});
expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]);
});
interface TestDeps extends CreatePaseoWorktreeDeps { interface TestDeps extends CreatePaseoWorktreeDeps {
projects: Map<string, PersistedProjectRecord>; projects: Map<string, PersistedProjectRecord>;
workspaces: Map<string, PersistedWorkspaceRecord>; workspaces: Map<string, PersistedWorkspaceRecord>;
@@ -143,6 +276,7 @@ function createDeps(options?: {
events?: string[]; events?: string[];
projects?: Map<string, PersistedProjectRecord>; projects?: Map<string, PersistedProjectRecord>;
workspaces?: Map<string, PersistedWorkspaceRecord>; workspaces?: Map<string, PersistedWorkspaceRecord>;
generateBranchName?: (seed: string | undefined) => string;
}): TestDeps { }): TestDeps {
const events = options?.events ?? []; const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>(); const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
@@ -150,6 +284,7 @@ function createDeps(options?: {
return { return {
...createWorktreeCoreDeps(createGitHubServiceStub()), ...createWorktreeCoreDeps(createGitHubServiceStub()),
...(options?.generateBranchName ? { generateBranchName: options.generateBranchName } : {}),
projects, projects,
workspaces, workspaces,
projectRegistry: { projectRegistry: {
@@ -310,14 +445,24 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
function listTypeScriptFiles(directory: string): string[] { function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const entries = readdirSync(directory); const { tempDir, repoDir } = createGitRepo();
return entries.flatMap((entry) => { execSync("git checkout -b pr-123", { cwd: repoDir, stdio: "pipe" });
const fullPath = path.join(directory, entry); writeFileSync(path.join(repoDir, "README.md"), "pr branch\n");
const stats = statSync(fullPath); execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
if (stats.isDirectory()) { execSync("git commit -m pr-branch", { cwd: repoDir, stdio: "pipe" });
return listTypeScriptFiles(fullPath); const prHead = execSync("git rev-parse HEAD", { cwd: repoDir, stdio: "pipe" }).toString().trim();
} execSync("git checkout main", { cwd: repoDir, stdio: "pipe" });
return fullPath.endsWith(".ts") ? [fullPath] : []; execSync("git branch -D pr-123", { cwd: repoDir, stdio: "pipe" });
const remoteDir = path.join(tempDir, "remote.git");
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe",
}); });
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${prHead}`, {
stdio: "pipe",
});
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
} }
@@ -1,3 +1,5 @@
import { basename } from "node:path";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { import {
type PersistedWorkspaceRecord, type PersistedWorkspaceRecord,
@@ -13,6 +15,13 @@ import {
type CreateWorktreeCoreInput, type CreateWorktreeCoreInput,
} from "./worktree-core.js"; } from "./worktree-core.js";
import type { WorktreeConfig } from "../utils/worktree.js"; import type { WorktreeConfig } from "../utils/worktree.js";
import { validateBranchSlug } from "../utils/worktree.js";
import { renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js";
import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {} export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {}
@@ -32,6 +41,12 @@ export type CreatePaseoWorktreeFn = (
}, },
) => Promise<CreatePaseoWorktreeResult>; ) => Promise<CreatePaseoWorktreeResult>;
export interface AttemptFirstAgentBranchAutoNameResult {
attempted: boolean;
renamed: boolean;
branchName: string | null;
}
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">; projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">; workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
@@ -43,17 +58,23 @@ export async function createPaseoWorktree(
deps: CreatePaseoWorktreeDeps, deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> { ): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps); const createdWorktree = await createWorktreeCore(input, deps);
maybeMarkFirstAgentBranchAutoNameEligible({ input, createdWorktree });
const worktree = await maybeAutoNameCreatedWorktree({
input,
createdWorktree,
deps,
});
const workspace = await upsertWorkspaceForWorktree({ const workspace = await upsertWorkspaceForWorktree({
inputCwd: input.cwd, inputCwd: input.cwd,
repoRoot: createdWorktree.repoRoot, repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree, worktree,
deps, deps,
}); });
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); deps.github.invalidate({ cwd: worktree.worktreePath });
return { return {
worktree: createdWorktree.worktree, worktree,
intent: createdWorktree.intent, intent: createdWorktree.intent,
workspace, workspace,
repoRoot: createdWorktree.repoRoot, repoRoot: createdWorktree.repoRoot,
@@ -61,6 +82,108 @@ export async function createPaseoWorktree(
}; };
} }
export async function attemptFirstAgentBranchAutoName(options: {
cwd: string;
nameContext?: string;
generateBranchName: (seed: string | undefined) => string;
renameCurrentBranch?: typeof renameCurrentBranch;
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
const nameContext = options.nameContext?.trim();
if (!nameContext) {
return { attempted: false, renamed: false, branchName: null };
}
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
try {
metadata = readPaseoWorktreeMetadata(options.cwd);
} catch {
return { attempted: false, renamed: false, branchName: null };
}
if (
!metadata ||
metadata.version !== 2 ||
metadata.firstAgentBranchAutoName?.status !== "pending"
) {
return { attempted: false, renamed: false, branchName: null };
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = options.generateBranchName(nameContext);
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === metadata.firstAgentBranchAutoName.placeholderBranchName) {
return { attempted: true, renamed: false, branchName: null };
}
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
return {
attempted: true,
renamed: true,
branchName: renamedBranch.currentBranch ?? branchName,
};
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {
const { input, createdWorktree } = options;
if (
!createdWorktree.created ||
input.worktreeSlug ||
createdWorktree.intent.kind !== "branch-off"
) {
return;
}
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
placeholderBranchName: createdWorktree.worktree.branchName,
});
}
async function maybeAutoNameCreatedWorktree(options: {
input: CreatePaseoWorktreeInput;
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
deps: Pick<CreatePaseoWorktreeDeps, "generateBranchName">;
}): Promise<WorktreeConfig> {
const { input, createdWorktree, deps } = options;
const nameContext = input.nameContext?.trim();
if (
!nameContext ||
input.worktreeSlug ||
!createdWorktree.created ||
createdWorktree.intent.kind !== "branch-off"
) {
return createdWorktree.worktree;
}
const generatedPlaceholderName = basename(createdWorktree.worktree.worktreePath);
if (
!generatedPlaceholderName ||
createdWorktree.worktree.branchName !== generatedPlaceholderName
) {
return createdWorktree.worktree;
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(createdWorktree.worktree.worktreePath);
const branchName = deps.generateBranchName(nameContext);
const validation = validateBranchSlug(branchName);
if (!validation.valid || branchName === createdWorktree.worktree.branchName) {
return createdWorktree.worktree;
}
const renamedBranch = await renameCurrentBranch(
createdWorktree.worktree.worktreePath,
branchName,
);
return {
...createdWorktree.worktree,
branchName: renamedBranch.currentBranch ?? branchName,
};
}
async function upsertWorkspaceForWorktree(options: { async function upsertWorkspaceForWorktree(options: {
inputCwd: string; inputCwd: string;
repoRoot: string; repoRoot: string;
+81 -44
View File
@@ -165,13 +165,11 @@ import {
} from "./file-explorer/service.js"; } from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js"; import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import { import {
readPaseoConfigForEdit, readPaseoConfigForEdit,
writePaseoConfigForEdit, writePaseoConfigForEdit,
type ProjectConfigRpcError, type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js"; } from "../utils/paseo-config-file.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
@@ -206,6 +204,7 @@ import {
type PullRequestTimelineItem, type PullRequestTimelineItem,
} from "../services/github-service.js"; } from "../services/github-service.js";
import { import {
attemptFirstAgentBranchAutoName,
createPaseoWorktree, createPaseoWorktree,
type CreatePaseoWorktreeInput, type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult, type CreatePaseoWorktreeResult,
@@ -214,7 +213,10 @@ import { createWorktreeCoreDeps } from "./worktree-core.js";
import { import {
assertSafeGitRef as assertWorktreeSafeGitRef, assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig, buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession, buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest,
@@ -274,6 +276,7 @@ type GitMutationRefreshReason =
| "create-pr" | "create-pr"
| "switch-branch" | "switch-branch"
| "create-branch" | "create-branch"
| "rename-branch"
| "stash-push" | "stash-push"
| "stash-pop" | "stash-pop"
| "create-worktree"; | "create-worktree";
@@ -3105,19 +3108,28 @@ export class Session {
...(provisionalTitle ? { title: provisionalTitle } : {}), ...(provisionalTitle ? { title: provisionalTitle } : {}),
}; };
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig( const agentNameContext = buildAgentWorktreeNameContext({
initialPrompt: trimmedPrompt,
attachments,
});
const { sessionConfig, setupContinuation } = await this.buildAgentSessionConfig(
resolvedConfig, resolvedConfig,
git, git,
worktreeName, worktreeName,
attachments, attachments,
agentNameContext,
); );
const resolvedWorkspace = msg.workspaceId let resolvedWorkspace = msg.workspaceId
? await this.workspaceRegistry.get(msg.workspaceId) ? await this.workspaceRegistry.get(msg.workspaceId)
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ?? : ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd))); (await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
if (!resolvedWorkspace) { if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`); throw new Error(`Workspace not found: ${msg.workspaceId}`);
} }
resolvedWorkspace = await this.maybeAutoNameWorkspaceBranchForFirstAgent({
workspace: resolvedWorkspace,
nameContext: agentNameContext,
});
const snapshot = await this.agentManager.createAgent( const snapshot = await this.agentManager.createAgent(
{ {
...sessionConfig, ...sessionConfig,
@@ -3155,27 +3167,9 @@ export class Session {
}); });
} }
if (worktreeBootstrap) { setupContinuation?.startAfterAgentCreate({
void runAsyncWorktreeBootstrap({ agentId: snapshot.id,
agentId: snapshot.id, });
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
this.sessionLogger.info( this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider }, { agentId: snapshot.id, provider: snapshot.provider },
@@ -3231,9 +3225,6 @@ export class Session {
explicitTitle: params.explicitTitle, explicitTitle: params.explicitTitle,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
logger: this.sessionLogger, logger: this.sessionLogger,
deps: {
workspaceGitService: this.workspaceGitService,
},
}); });
const started = await this.handleSendAgentMessage( const started = await this.handleSendAgentMessage(
@@ -3460,9 +3451,10 @@ export class Session {
gitOptions?: GitSetupOptions, gitOptions?: GitSetupOptions,
legacyWorktreeName?: string, legacyWorktreeName?: string,
attachments?: AgentAttachment[], attachments?: AgentAttachment[],
nameContext?: string,
): Promise<{ ): Promise<{
sessionConfig: AgentSessionConfig; sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean }; setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
}> { }> {
return buildWorktreeAgentSessionConfig( return buildWorktreeAgentSessionConfig(
{ {
@@ -3470,7 +3462,26 @@ export class Session {
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) => createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktree(input, serviceOptions), this.createPaseoWorktreeWorkflow(input, {
...serviceOptions,
setupContinuation: {
kind: "agent",
terminalManager: this.terminalManager,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
logger: this.sessionLogger,
},
}),
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params), createBranchFromBase: (params) => this.createBranchFromBase(params),
github: this.github, github: this.github,
@@ -3479,9 +3490,35 @@ export class Session {
gitOptions, gitOptions,
legacyWorktreeName, legacyWorktreeName,
attachments, attachments,
nameContext,
); );
} }
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
nameContext?: string;
}): Promise<PersistedWorkspaceRecord> {
const coreDeps = createWorktreeCoreDeps(this.github);
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
nameContext: input.nameContext,
generateBranchName: coreDeps.generateBranchName,
});
if (!result.renamed || !result.branchName) {
return input.workspace;
}
const updatedWorkspace: PersistedWorkspaceRecord = {
...input.workspace,
displayName: result.branchName,
updatedAt: new Date().toISOString(),
};
await this.workspaceRegistry.upsert(updatedWorkspace);
await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
return updatedWorkspace;
}
private emitProviderDisabledResponse( private emitProviderDisabledResponse(
kind: "models" | "modes", kind: "models" | "modes",
provider: AgentProvider, provider: AgentProvider,
@@ -7447,27 +7484,26 @@ export class Session {
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result), describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message), emit: (message) => this.emit(message),
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
sessionLogger: this.sessionLogger, sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options), createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input),
}, },
request, request,
); );
} }
private async runWorktreeSetupInBackground(options: { private async createPaseoWorktreeWorkflow(
requestCwd: string; input: CreatePaseoWorktreeInput,
repoRoot: string; options?: {
workspaceId: string; resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
worktree: { branchName: string; worktreePath: string }; setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
shouldBootstrap: boolean; },
slug: string; ): Promise<CreatePaseoWorktreeWorkflowResult> {
worktreePath: string; return createWorktreeWorkflow(
}): Promise<void> {
return runWorktreeSetupInBackgroundSession(
{ {
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
emitWorkspaceUpdateForCwd: (cwd, emitOptions) => emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions), this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
@@ -7485,6 +7521,7 @@ export class Session {
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory); this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
}, },
}, },
input,
options, options,
); );
} }
@@ -2,7 +2,6 @@ import { execSync } from "node:child_process";
import { import {
existsSync, existsSync,
mkdtempSync, mkdtempSync,
readdirSync,
readFileSync, readFileSync,
realpathSync, realpathSync,
rmSync, rmSync,
@@ -10,7 +9,6 @@ import {
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test, afterEach, vi } from "vitest"; import { describe, expect, test, afterEach, vi } from "vitest";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
@@ -60,42 +58,6 @@ function createCoreDeps(options?: {
}; };
} }
function findDirectCreateWorktreeCallSites(serverSrc: string): string[] {
const matches: string[] = [];
function walk(directory: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const relativePath = path
.relative(serverSrc, absolutePath)
.split(path.sep)
.join(path.posix.sep);
if (relativePath === "utils/worktree.ts" || relativePath.endsWith(".test.ts")) {
continue;
}
// Keep this literal in the test file so the invariant proves tests are allowed to inspect createWorktree(.
if (/createWorktree\(/.test(readFileSync(absolutePath, "utf8"))) {
matches.push(relativePath);
}
}
}
walk(serverSrc);
return matches.sort();
}
function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } { function createGitRepo(): { tempDir: string; repoDir: string; paseoHome: string } {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-"))); const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-core-test-")));
const repoDir = path.join(tempDir, "repo"); const repoDir = path.join(tempDir, "repo");
@@ -651,11 +613,6 @@ describe.skipIf(process.platform === "win32")("createWorktreeCore", () => {
}); });
expect(result.worktree.branchName).toBe("feature/from-service"); expect(result.worktree.branchName).toBe("feature/from-service");
}); });
test("keeps direct createWorktree calls isolated to the core layer", () => {
const serverSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
expect(findDirectCreateWorktreeCallSites(serverSrc)).toEqual(["server/worktree-core.ts"]);
});
}); });
describe("resolveWorktreeRepoRoot", () => { describe("resolveWorktreeRepoRoot", () => {
@@ -17,6 +17,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput { export interface CreateWorktreeCoreInput extends ResolveWorktreeCreationIntentInput {
cwd: string; cwd: string;
nameContext?: string;
paseoHome?: string; paseoHome?: string;
runSetup?: boolean; runSetup?: boolean;
} }
@@ -16,6 +16,8 @@ import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messa
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
import { import {
buildAgentSessionConfig, buildAgentSessionConfig,
buildAgentWorktreeNameContext,
createPaseoWorktreeWorkflow,
handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest, handlePaseoWorktreeListRequest,
resolveGitCreateBaseBranch, resolveGitCreateBaseBranch,
@@ -79,6 +81,53 @@ function createLogger(): Logger {
return logger; return logger;
} }
function createWorkflowForRequestTest(options: {
paseoHome: string;
createPaseoWorktree?: CreatePaseoWorktreeFn;
warmWorkspaceGitData?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
onSetupStarted?: (input: {
requestCwd: string;
repoRoot: string;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}) => void;
}) {
return async (input: Parameters<CreatePaseoWorktreeFn>[0]) => {
const createPaseoWorktree =
options.createPaseoWorktree ?? createPaseoWorktreeForTest({ paseoHome: options.paseoHome });
return createPaseoWorktreeWorkflow(
{
paseoHome: options.paseoHome,
createPaseoWorktree,
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
emitWorkspaceUpdateForCwd: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
input,
{ setupContinuation: { kind: "workspace" } },
).then((result) => {
options.onSetupStarted?.({
requestCwd: input.cwd,
repoRoot: result.repoRoot,
workspaceId: result.workspace.workspaceId,
worktree: result.worktree,
shouldBootstrap: result.created,
});
return result;
});
};
}
function createGitHubServiceStub(): GitHubService { function createGitHubServiceStub(): GitHubService {
return { return {
listPullRequests: async () => [], listPullRequests: async () => [],
@@ -347,6 +396,81 @@ describe("resolveGitCreateBaseBranch", () => {
}); });
}); });
describe("create-agent worktree setup boundary", () => {
test("agent setup continuation starts setup for the created agent timeline", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const appendedItems: Array<{ name: string; status: string }> = [];
const liveItems: Array<{ name: string; status: string }> = [];
const workspaceSetupEvents: SessionOutboundMessage[] = [];
try {
const result = await createPaseoWorktreeWorkflow(
{
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
emitWorkspaceUpdateForCwd: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message),
sessionLogger: createLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
scriptRouteStore: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
{
cwd: repoDir,
worktreeSlug: "agent-setup-after-create",
runSetup: false,
paseoHome,
},
{
setupContinuation: {
kind: "agent",
terminalManager: createTerminalManagerStub().manager,
appendTimelineItem: async ({ agentId, item }) => {
expect(agentId).toBe("agent-after-create");
if (item.type !== "tool_call") {
throw new Error(`Expected tool call timeline item, got ${item.type}`);
}
appendedItems.push({ name: item.name, status: item.status });
return true;
},
emitLiveTimelineItem: async ({ agentId, item }) => {
expect(agentId).toBe("agent-after-create");
if (item.type !== "tool_call") {
throw new Error(`Expected tool call timeline item, got ${item.type}`);
}
liveItems.push({ name: item.name, status: item.status });
return true;
},
logger: createLogger(),
},
},
);
expect(result.setupContinuation?.kind).toBe("agent");
expect(workspaceSetupEvents).toEqual([]);
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" });
await vi.waitFor(() => {
expect(appendedItems).toContainEqual({
name: "paseo_worktree_setup",
status: "completed",
});
});
expect(liveItems).toEqual([]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> { function createAgentStorageStub(): Pick<AgentStorage, "list" | "remove"> {
return { return {
list: async (): Promise<StoredAgentRecord[]> => [], list: async (): Promise<StoredAgentRecord[]> => [],
@@ -1048,10 +1172,8 @@ describe("handleCreatePaseoWorktreeRequest", () => {
describeWorkspaceRecord: async (result) => describeWorkspaceRecord: async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
sessionLogger: logger, sessionLogger: logger,
runWorktreeSetupInBackground: async () => {}, createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
}, },
{ {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
@@ -1139,8 +1261,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
], ],
); );
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr"); expect(result.sessionConfig.cwd).toContain("agent-review-pr-123");
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
const branch = execSync("git branch --show-current", { const branch = execSync("git branch --show-current", {
@@ -1184,8 +1305,93 @@ describe("handleCreatePaseoWorktreeRequest", () => {
}, },
); );
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature-x"); expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x");
expect(path.basename(result.worktreeBootstrap?.worktree.worktreePath ?? "")).toBe("feature-x"); });
test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => {
const createPaseoWorktree = vi.fn(async () => ({
worktree: {
branchName: "fix-attached-pr-context",
worktreePath: "/tmp/worktrees/fix-attached-pr-context",
},
intent: {
kind: "branch-off" as const,
baseBranch: "main",
newBranchName: "fix-attached-pr-context",
},
workspace: {
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
projectId: "/tmp/repo",
cwd: "/tmp/worktrees/fix-attached-pr-context",
kind: "worktree" as const,
displayName: "fix-attached-pr-context",
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
archivedAt: null,
},
repoRoot: "/tmp/repo",
created: true,
}));
const nameContext = buildAgentWorktreeNameContext({
initialPrompt: "Create a worktree name from this prompt",
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix worktree naming",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-naming",
},
],
});
const result = await buildAgentSessionConfig(
{
sessionLogger: createLogger(),
workspaceGitService: {
resolveDefaultBranch: vi.fn(async () => "main"),
} as unknown as WorkspaceGitService,
createPaseoWorktree,
checkoutExistingBranch: async () => {
throw new Error("should not checkout existing branch");
},
createBranchFromBase: async () => {
throw new Error("should not create a branch outside the worktree service");
},
},
{
provider: "codex",
cwd: "/tmp/repo",
},
{
createWorktree: true,
action: "branch-off",
},
undefined,
[
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix worktree naming",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-naming",
},
],
nameContext,
);
expect(createPaseoWorktree).toHaveBeenCalledWith(
expect.objectContaining({
nameContext:
"Create a worktree name from this prompt\n\nGitHub PR #123: Fix worktree naming\nhttps://github.com/getpaseo/paseo/pull/123\nBase: main\nHead: fix/worktree-naming",
}),
expect.anything(),
);
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context");
}); });
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
@@ -1284,7 +1490,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome, paseoHome,
sessionLogger: createLogger(), sessionLogger: createLogger(),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }), createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
}),
describeWorkspaceRecord: vi.fn(async (result) => ({ describeWorkspaceRecord: vi.fn(async (result) => ({
id: result.workspace.workspaceId, id: result.workspace.workspaceId,
projectId: result.workspace.projectId, projectId: result.workspace.projectId,
@@ -1308,8 +1517,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
}, },
githubRuntime: null, githubRuntime: null,
})), })),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
}, },
{ {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
@@ -1346,17 +1553,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome, paseoHome,
sessionLogger: createLogger(), sessionLogger: createLogger(),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
createPaseoWorktree: async (input) => { createPaseoWorktreeWorkflow: createWorkflowForRequestTest({
const result = await createPaseoWorktreeForTest({ paseoHome })(input); paseoHome,
expect(existsSync(result.worktree.worktreePath)).toBe(true); createPaseoWorktree: async (input) => {
registeredWorktreePath = result.worktree.worktreePath; const result = await createPaseoWorktreeForTest({ paseoHome })(input);
return result; expect(existsSync(result.worktree.worktreePath)).toBe(true);
}, registeredWorktreePath = result.worktree.worktreePath;
return result;
},
warmWorkspaceGitData,
onSetupStarted: backgroundWork,
}),
describeWorkspaceRecord: vi.fn(async (result) => describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
), ),
warmWorkspaceGitData,
runWorktreeSetupInBackground: backgroundWork,
}, },
{ {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
@@ -1388,6 +1598,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
}); });
expect(registeredWorktreePath).toBeTruthy(); expect(registeredWorktreePath).toBeTruthy();
expect(existsSync(registeredWorktreePath!)).toBe(true); expect(existsSync(registeredWorktreePath!)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(warmWorkspaceGitData).toHaveBeenCalledWith( expect(warmWorkspaceGitData).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspaceId: response?.payload.workspace?.id, workspaceId: response?.payload.workspace?.id,
@@ -1421,12 +1632,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome, paseoHome,
sessionLogger: createLogger(), sessionLogger: createLogger(),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (result) => describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
), ),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
}, },
{ {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
@@ -1462,12 +1671,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
paseoHome, paseoHome,
sessionLogger: createLogger(), sessionLogger: createLogger(),
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), createPaseoWorktreeWorkflow: createWorkflowForRequestTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (result) => describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }), createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
), ),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
}, },
{ {
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",
+147 -38
View File
@@ -13,6 +13,7 @@ import {
import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { import {
runAsyncWorktreeBootstrap,
applyWorktreeSetupProgressEvent, applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail, buildWorktreeSetupDetail,
createWorktreeSetupProgressAccumulator, createWorktreeSetupProgressAccumulator,
@@ -23,6 +24,7 @@ import type { ScriptRouteStore } from "./script-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { GitHubService } from "../services/github-service.js"; import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { renderPromptAttachmentAsText } from "./agent/prompt-attachments.js";
import { expandTilde } from "../utils/path.js"; import { expandTilde } from "../utils/path.js";
import { import {
getWorktreeSetupCommands, getWorktreeSetupCommands,
@@ -61,6 +63,15 @@ export interface NormalizedGitOptions {
} }
type EmitSessionMessage = (message: SessionOutboundMessage) => void; type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type AgentWorktreeSetupTimelineItem = Parameters<
typeof runAsyncWorktreeBootstrap
>[0]["appendTimelineItem"] extends (item: infer Item) => unknown
? Item
: never;
type AgentWorktreeSetupTimelineWriter = (input: {
agentId: string;
item: AgentWorktreeSetupTimelineItem;
}) => Promise<boolean>;
interface BuildAgentSessionConfigDependencies { interface BuildAgentSessionConfigDependencies {
paseoHome?: string; paseoHome?: string;
@@ -70,8 +81,9 @@ interface BuildAgentSessionConfigDependencies {
input: CreatePaseoWorktreeInput, input: CreatePaseoWorktreeInput,
options?: { options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>; resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}, },
) => Promise<CreatePaseoWorktreeResult>; ) => Promise<CreatePaseoWorktreeWorkflowResult>;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>; checkoutExistingBranch: (cwd: string, branch: string) => Promise<CheckoutExistingBranchResult>;
createBranchFromBase: (params: { createBranchFromBase: (params: {
cwd: string; cwd: string;
@@ -96,6 +108,45 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null; onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
} }
interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
createPaseoWorktree: (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
},
) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}
interface AgentWorktreeSetupContinuationInput {
kind: "agent";
terminalManager: TerminalManager | null;
appendTimelineItem: AgentWorktreeSetupTimelineWriter;
emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter;
logger: Logger;
}
export type CreatePaseoWorktreeSetupContinuationInput =
| { kind: "workspace" }
| AgentWorktreeSetupContinuationInput;
export interface AgentWorktreeSetupContinuation {
kind: "agent";
startAfterAgentCreate: (input: { agentId: string }) => void;
}
export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & {
setupContinuation?: AgentWorktreeSetupContinuation;
};
export type CreatePaseoWorktreeWorkflowFn = (
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
) => Promise<CreatePaseoWorktreeWorkflowResult>;
interface HandleWorkspaceSetupStatusRequestDependencies { interface HandleWorkspaceSetupStatusRequestDependencies {
emit: EmitSessionMessage; emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>; workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
@@ -107,18 +158,10 @@ interface HandleCreatePaseoWorktreeRequestDependencies {
result: CreatePaseoWorktreeResult, result: CreatePaseoWorktreeResult,
) => Promise<WorkspaceDescriptorPayload>; ) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage; emit: EmitSessionMessage;
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
sessionLogger: Logger; sessionLogger: Logger;
runWorktreeSetupInBackground: (options: { createPaseoWorktreeWorkflow: (
requestCwd: string; input: CreatePaseoWorktreeInput,
repoRoot: string; ) => Promise<CreatePaseoWorktreeWorkflowResult>;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
}) => Promise<void>;
} }
export async function buildAgentSessionConfig( export async function buildAgentSessionConfig(
@@ -127,13 +170,14 @@ export async function buildAgentSessionConfig(
gitOptions?: GitSetupOptions, gitOptions?: GitSetupOptions,
legacyWorktreeName?: string, legacyWorktreeName?: string,
attachments?: AgentAttachment[], attachments?: AgentAttachment[],
nameContext?: string,
): Promise<{ ): Promise<{
sessionConfig: AgentSessionConfig; sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean }; setupContinuation?: AgentWorktreeSetupContinuation;
}> { }> {
let cwd = expandTilde(config.cwd); let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined; let setupContinuation: AgentWorktreeSetupContinuation | undefined;
if (!normalized) { if (!normalized) {
return { return {
@@ -157,6 +201,7 @@ export async function buildAgentSessionConfig(
refName: normalized.refName, refName: normalized.refName,
action: normalized.action, action: normalized.action,
githubPrNumber: normalized.githubPrNumber, githubPrNumber: normalized.githubPrNumber,
nameContext,
attachments, attachments,
runSetup: false, runSetup: false,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
@@ -173,10 +218,7 @@ export async function buildAgentSessionConfig(
}, },
); );
cwd = createdWorktree.worktree.worktreePath; cwd = createdWorktree.worktree.worktreePath;
worktreeBootstrap = { setupContinuation = createdWorktree.setupContinuation;
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
};
} else if (normalized.createNewBranch) { } else if (normalized.createNewBranch) {
const baseBranch = const baseBranch =
normalized.baseBranch ?? normalized.baseBranch ??
@@ -201,10 +243,28 @@ export async function buildAgentSessionConfig(
...config, ...config,
cwd, cwd,
}, },
worktreeBootstrap, setupContinuation,
}; };
} }
export function buildAgentWorktreeNameContext(input: {
initialPrompt?: string | null;
attachments?: readonly AgentAttachment[];
}): string | undefined {
const parts: string[] = [];
const prompt = input.initialPrompt?.trim();
if (prompt) {
parts.push(prompt);
}
for (const attachment of input.attachments ?? []) {
const rendered = renderPromptAttachmentAsText(attachment).trim();
if (rendered) {
parts.push(rendered);
}
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
interface ValidateNormalizedGitOptionsInput { interface ValidateNormalizedGitOptionsInput {
baseBranch: string | undefined; baseBranch: string | undefined;
createNewBranch: boolean; createNewBranch: boolean;
@@ -450,9 +510,10 @@ export async function handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>, request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> { ): Promise<void> {
try { try {
const createdWorktree = await dependencies.createPaseoWorktree({ const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
cwd: request.cwd, cwd: request.cwd,
worktreeSlug: request.worktreeSlug, worktreeSlug: request.worktreeSlug,
nameContext: request.nameContext,
refName: request.refName, refName: request.refName,
action: request.action, action: request.action,
githubPrNumber: request.githubPrNumber, githubPrNumber: request.githubPrNumber,
@@ -460,8 +521,6 @@ export async function handleCreatePaseoWorktreeRequest(
runSetup: false, runSetup: false,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
}); });
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree); const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
dependencies.emit({ dependencies.emit({
@@ -480,22 +539,6 @@ export async function handleCreatePaseoWorktreeRequest(
workspace: descriptor, workspace: descriptor,
}, },
}); });
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm workspace git data after creating worktree",
);
});
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
});
} catch (error) { } catch (error) {
const wireError = toWorktreeWireError(error); const wireError = toWorktreeWireError(error);
dependencies.sessionLogger.error( dependencies.sessionLogger.error(
@@ -515,6 +558,72 @@ export async function handleCreatePaseoWorktreeRequest(
} }
} }
export async function createPaseoWorktreeWorkflow(
dependencies: CreatePaseoWorktreeWorkflowDependencies,
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
): Promise<CreatePaseoWorktreeWorkflowResult> {
const createdWorktree = await dependencies.createPaseoWorktree(
{
...input,
runSetup: false,
paseoHome: input.paseoHome ?? dependencies.paseoHome,
},
options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: undefined,
);
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
setTimeout(() => {
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm workspace git data after creating worktree",
);
});
if (setupContinuation.kind === "workspace") {
void runWorktreeSetupInBackground(dependencies, {
requestCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
slug,
worktreePath: createdWorktree.worktree.worktreePath,
});
}
}, 0);
if (setupContinuation.kind === "agent") {
return {
...createdWorktree,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
void runAsyncWorktreeBootstrap({
agentId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.created,
terminalManager: setupContinuation.terminalManager,
appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }),
emitLiveTimelineItem: (item) =>
setupContinuation.emitLiveTimelineItem({ agentId, item }),
logger: setupContinuation.logger,
});
},
},
};
}
return createdWorktree;
}
export async function handleWorkspaceSetupStatusRequest( export async function handleWorkspaceSetupStatusRequest(
dependencies: HandleWorkspaceSetupStatusRequestDependencies, dependencies: HandleWorkspaceSetupStatusRequestDependencies,
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>, request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
+1
View File
@@ -1385,6 +1385,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
type: z.literal("create_paseo_worktree_request"), type: z.literal("create_paseo_worktree_request"),
cwd: z.string(), cwd: z.string(),
worktreeSlug: z.string().optional(), worktreeSlug: z.string().optional(),
nameContext: z.string().optional(),
attachments: AgentAttachmentsSchema, attachments: AgentAttachmentsSchema,
refName: z.string().min(1).optional(), refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(), action: z.enum(["branch-off", "checkout"]).optional(),
@@ -10,6 +10,19 @@ const PaseoWorktreeMetadataV1Schema = z.object({
const PaseoWorktreeMetadataV2Schema = z.object({ const PaseoWorktreeMetadataV2Schema = z.object({
version: z.literal(2), version: z.literal(2),
baseRefName: z.string().min(1), baseRefName: z.string().min(1),
firstAgentBranchAutoName: z
.discriminatedUnion("status", [
z.object({
status: z.literal("pending"),
placeholderBranchName: z.string().min(1),
}),
z.object({
status: z.literal("attempted"),
placeholderBranchName: z.string().min(1),
attemptedAt: z.string().min(1),
}),
])
.optional(),
runtime: z runtime: z
.object({ .object({
worktreePort: z.number().int().positive(), worktreePort: z.number().int().positive(),
@@ -101,6 +114,9 @@ export function writePaseoWorktreeRuntimeMetadata(
const next: PaseoWorktreeMetadata = { const next: PaseoWorktreeMetadata = {
version: 2, version: 2,
baseRefName: current.baseRefName, baseRefName: current.baseRefName,
...(current.version === 2 && current.firstAgentBranchAutoName
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
: {}),
runtime: { runtime: {
worktreePort: options.worktreePort, worktreePort: options.worktreePort,
}, },
@@ -108,6 +124,54 @@ export function writePaseoWorktreeRuntimeMetadata(
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
} }
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
worktreeRoot: string,
options: { placeholderBranchName: string },
): void {
const placeholderBranchName = options.placeholderBranchName.trim();
if (!placeholderBranchName) {
throw new Error("Placeholder branch name is required");
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
}
writePaseoWorktreeMetadataFile(worktreeRoot, {
version: 2,
baseRefName: current.baseRefName,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
},
...(current.version === 2 && current.runtime ? { runtime: current.runtime } : {}),
});
}
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
worktreeRoot: string,
options: { attemptedAt?: string } = {},
): PaseoWorktreeMetadata | null {
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
return current;
}
const next: PaseoWorktreeMetadata = {
version: 2,
baseRefName: current.baseRefName,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
},
...(current.runtime ? { runtime: current.runtime } : {}),
};
writePaseoWorktreeMetadataFile(worktreeRoot, next);
return next;
}
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
if (!existsSync(metadataPath)) { if (!existsSync(metadataPath)) {
@@ -136,3 +200,12 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
} }
return null; return null;
} }
function writePaseoWorktreeMetadataFile(
worktreeRoot: string,
metadata: PaseoWorktreeMetadata,
): void {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
}