diff --git a/docs/data-model.md b/docs/data-model.md index 2287935bd..324502625 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`. app: { baseUrl: string }, + worktrees?: { + root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees + }, providers: { openai: { apiKey: string }, local: { modelsDir: string } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 7f3ba8a39..53e25a5a9 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -19,6 +19,7 @@ import type { AgentStorage } from "./agent-storage.js"; interface CreateAgentLifecycleDispatchDependencies { paseoHome: string; + worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; github: GitHubService; @@ -106,6 +107,7 @@ export class CreateAgentLifecycleDispatch { firstAgentContext, runSetup: false, paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, } as const; switch (target.mode) { @@ -191,6 +193,7 @@ export class CreateAgentLifecycleDispatch { }): Promise { const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, { paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, }); if (!ownership.allowed) { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); @@ -199,6 +202,7 @@ export class CreateAgentLifecycleDispatch { await archivePaseoWorktree( { paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, github: this.dependencies.github, workspaceGitService: this.dependencies.workspaceGitService, agentManager: this.dependencies.agentManager, @@ -215,6 +219,7 @@ export class CreateAgentLifecycleDispatch { targetPath: options.worktreePath, repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, worktreesRoot: ownership.worktreeRoot, + worktreesBaseRoot: this.dependencies.worktreesRoot, requestId: randomUUID(), }, ); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 30937e413..600c09562 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -46,6 +46,7 @@ interface CreateAgentCommandDependencies { agentStorage: AgentStorage; logger: Logger; paseoHome?: string; + worktreesRoot?: string; workspaceGitService?: Pick< WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" @@ -419,6 +420,7 @@ async function resolveMcpCwd(params: { ...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}), runSetup: false, paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.worktreesRoot, }, createPaseoWorktree: dependencies.createPaseoWorktree, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 0604d62d9..79ca55489 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -99,6 +99,7 @@ export interface AgentMcpServerOptions { clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; paseoHome?: string; + worktreesRoot?: string; /** * ID of the agent that is connecting to this MCP server. * Used for cwd/mode inheritance when agents spawn child agents. @@ -918,6 +919,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentStorage, logger: childLogger, paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, workspaceGitService: options.workspaceGitService, terminalManager, providerSnapshotManager, @@ -2152,6 +2154,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const commandResult = await createPaseoWorktreeCommand( { paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, createPaseoWorktreeWorkflow: options.createPaseoWorktree, }, createMcpWorktreeCommandInput(repoRoot, target), @@ -2414,6 +2417,7 @@ function archiveWorktreeDependencies( } return { paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, github: options.github, workspaceGitService: options.workspaceGitService, agentManager: context.agentManager, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 6332ce9e1..40fd706e8 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -15,6 +15,7 @@ import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; export interface AutoArchiveArchiveOptions { paseoHome: string; + worktreesRoot?: string; daemonConfigStore: DaemonConfigStore; workspaceGitService: WorkspaceGitServiceImpl; github: GitHubService; @@ -81,7 +82,10 @@ export async function archiveIfSafe(input: { return; } - const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome }); + const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + }); if (!ownership.allowed) { return; } @@ -90,6 +94,7 @@ export async function archiveIfSafe(input: { await deps.archivePaseoWorktree( { paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, github: options.github, workspaceGitService: options.workspaceGitService, agentManager: options.agentManager, @@ -115,6 +120,7 @@ export async function archiveIfSafe(input: { targetPath: cwd, repoRoot: ownership.repoRoot ?? null, worktreesRoot: ownership.worktreeRoot, + worktreesBaseRoot: options.worktreesRoot, requestId: "auto-archive-on-merge", }, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 68cd1174b..6c5aabf57 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -231,6 +231,7 @@ export type DaemonLifecycleIntent = export interface PaseoDaemonConfig { listen: string; paseoHome: string; + worktreesRoot?: string; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; hostnames?: HostnamesConfig; @@ -518,6 +519,7 @@ export async function createPaseoDaemon( const workspaceGitService = new WorkspaceGitServiceImpl({ logger, paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, deps: { github, }, @@ -657,6 +659,7 @@ export async function createPaseoDaemon( setupAutoArchiveOnMerge({ paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, daemonConfigStore, workspaceGitService, github, @@ -694,6 +697,7 @@ export async function createPaseoDaemon( return createPaseoWorktreeWorkflow( { paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, createPaseoWorktree: async (workflowInput, workflowOptions) => { return createRegisteredPaseoWorktree(workflowInput, { github, @@ -740,6 +744,7 @@ export async function createPaseoDaemon( ); }, paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, callerAgentId, enableVoiceTools: false, resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null, @@ -965,6 +970,7 @@ export async function createPaseoDaemon( providerSnapshotManager, { listen: formatListenTarget(boundListenTarget ?? listenTarget), + worktreesRoot: config.worktreesRoot, relay: { enabled: relayEnabled, endpoint: relayEndpoint, diff --git a/packages/server/src/server/config-relay.test.ts b/packages/server/src/server/config-relay.test.ts index 70e8a9c83..c61f931d6 100644 --- a/packages/server/src/server/config-relay.test.ts +++ b/packages/server/src/server/config-relay.test.ts @@ -83,3 +83,29 @@ describe("daemon relay config", () => { expect(config.relayPublicUseTls).toBe(true); }); }); + +describe("daemon worktree root config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("resolves relative worktrees.root against PASEO_HOME", async () => { + const home = await createPaseoHome({ + version: 1, + worktrees: { root: "custom-worktrees" }, + }); + + expect(loadConfig(home, { env: {} }).worktreesRoot).toBe(path.join(home, "custom-worktrees")); + }); + + test("keeps absolute worktrees.root absolute", async () => { + const home = await createPaseoHome({ + version: 1, + worktrees: { root: path.join(os.tmpdir(), "paseo-custom-worktrees") }, + }); + + expect(loadConfig(home, { env: {} }).worktreesRoot).toBe( + path.join(os.tmpdir(), "paseo-custom-worktrees"), + ); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 216652cae..5e634a60d 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { resolvePaseoNodeEnv } from "./paseo-env.js"; import { z } from "zod"; +import { expandTilde } from "../utils/path.js"; import type { PaseoDaemonConfig } from "./bootstrap.js"; import { @@ -256,6 +257,21 @@ function resolveAuthConfig( : undefined; } +function resolveWorktreesRoot( + paseoHome: string, + persisted: ReturnType, +): string | undefined { + const configuredRoot = persisted.worktrees?.root?.trim(); + if (!configuredRoot) { + return undefined; + } + + const expandedRoot = expandTilde(configuredRoot); + return path.isAbsolute(expandedRoot) + ? path.resolve(expandedRoot) + : path.resolve(paseoHome, expandedRoot); +} + function resolveAppendSystemPrompt(persisted: ReturnType): string { return persisted.daemon?.appendSystemPrompt ?? ""; } @@ -321,6 +337,7 @@ export function loadConfig( return { listen, paseoHome, + worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), hostnames, mcpEnabled, diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index 7f271e0e0..e31094962 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -14,6 +14,7 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; export interface ArchivePaseoWorktreeDependencies { paseoHome?: string; + worktreesRoot?: string; github: GitHubService; workspaceGitService: Pick; agentManager: Pick; @@ -41,12 +42,14 @@ export async function archivePaseoWorktree( targetPath: string; repoRoot: string | null; worktreesRoot?: string; + worktreesBaseRoot?: string; requestId: string; }, ): Promise { let targetPath = options.targetPath; const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { paseoHome: dependencies.paseoHome, + worktreesRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, }); if (resolvedWorktree) { targetPath = resolvedWorktree.worktreePath; @@ -115,6 +118,7 @@ export async function archivePaseoWorktree( worktreePath: targetPath, worktreesRoot: options.worktreesRoot, paseoHome: dependencies.paseoHome, + worktreesBaseRoot: options.worktreesBaseRoot ?? dependencies.worktreesRoot, }); } catch (error) { if (error instanceof WorktreeTeardownError) { diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 0361ab1da..45e5e638a 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -63,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => { }); }); +describe("PersistedConfigSchema worktrees config", () => { + test("accepts optional worktree root", () => { + const parsed = PersistedConfigSchema.parse({ + worktrees: { + root: "/mnt/fast/paseo-worktrees", + }, + }); + + expect(parsed.worktrees?.root).toBe("/mnt/fast/paseo-worktrees"); + }); +}); + describe("PersistedConfigSchema daemon append system prompt", () => { test("accepts optional append system prompt", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 21c14390f..5754582ed 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -63,6 +63,12 @@ const ProvidersSchema = z }) .strict(); +const WorktreesConfigSchema = z + .object({ + root: z.string().min(1).optional(), + }) + .strict(); + const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, { message: "Expected a bcrypt hash", }); @@ -248,6 +254,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + worktrees: WorktreesConfigSchema.optional(), agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 174cb5c41..8e100d1d0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -569,6 +569,7 @@ export interface SessionOptions { downloadTokenStore: DownloadTokenStore; pushTokenStore: PushTokenStore; paseoHome: string; + worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; projectRegistry: ProjectRegistry; @@ -742,6 +743,7 @@ export class Session { private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly sessionLogger: pino.Logger; private readonly paseoHome: string; + private readonly worktreesRoot: string | undefined; // State machine private abortController: AbortController; @@ -859,6 +861,7 @@ export class Session { downloadTokenStore, pushTokenStore, paseoHome, + worktreesRoot, agentManager, agentStorage, projectRegistry, @@ -900,6 +903,7 @@ export class Session { this.downloadTokenStore = downloadTokenStore; this.pushTokenStore = pushTokenStore; this.paseoHome = paseoHome; + this.worktreesRoot = worktreesRoot; this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -930,6 +934,7 @@ export class Session { }); this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({ paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, github: this.github, @@ -3111,6 +3116,7 @@ export class Session { agentStorage: this.agentStorage, logger: this.sessionLogger, paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, workspaceGitService: this.workspaceGitService, providerSnapshotManager: this.providerSnapshotManager, daemonConfig: this.readStructuredGenerationDaemonConfig(), @@ -3491,6 +3497,7 @@ export class Session { return buildWorktreeAgentSessionConfig( { paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, sessionLogger: this.sessionLogger, workspaceGitService: this.workspaceGitService, createPaseoWorktree: (input, serviceOptions) => @@ -5230,7 +5237,7 @@ export class Session { baseRef, mode: msg.strategy === "squash" ? "squash" : "merge", }, - { paseoHome: this.paseoHome }, + { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, ); await Promise.all([ this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }), @@ -5711,6 +5718,7 @@ export class Session { return handleWorktreeArchiveRequest( { paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, github: this.github, workspaceGitService: this.workspaceGitService, agentManager: this.agentManager, @@ -7298,6 +7306,7 @@ export class Session { return handleCreateWorktreeRequest( { paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result), emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, @@ -7317,6 +7326,7 @@ export class Session { return createWorktreeWorkflow( { paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, createPaseoWorktree: (workflowInput, serviceOptions) => this.createPaseoWorktree(workflowInput, serviceOptions), warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ea45becb0..18d78ed67 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -350,6 +350,7 @@ export class VoiceAssistantWebSocketServer { private readonly workspaceGitService: WorkspaceGitService; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; + private readonly worktreesRoot: string | undefined; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private readonly pushNotificationSender: PushNotificationSender; @@ -419,6 +420,7 @@ export class VoiceAssistantWebSocketServer { providerSnapshotManager?: ProviderSnapshotManager, daemonRuntimeConfig?: { listen: string | null; + worktreesRoot?: string; relay: { enabled: boolean; endpoint: string; @@ -453,6 +455,7 @@ export class VoiceAssistantWebSocketServer { this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; + this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; this.assignOptionalServices({ @@ -858,6 +861,7 @@ export class VoiceAssistantWebSocketServer { downloadTokenStore: this.downloadTokenStore, pushTokenStore: this.pushTokenStore, paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 1d7b5e374..f29d7fdb3 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -259,6 +259,7 @@ interface WorkspaceGitServiceDependencies { interface WorkspaceGitServiceOptions { logger: pino.Logger; paseoHome: string; + worktreesRoot?: string; deps?: Partial; } @@ -347,6 +348,7 @@ function resolveWorkspaceGitServiceDeps( export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; + private readonly worktreesRoot: string | undefined; private readonly deps: WorkspaceGitServiceDependencies; private readonly snapshotUpdatedListeners = new Set(); private readonly workspaceTargets = new Map(); @@ -385,6 +387,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; + this.worktreesRoot = options.worktreesRoot; this.deps = resolveWorkspaceGitServiceDeps(options.deps); } @@ -438,6 +441,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { try { const status = await this.deps.getCheckoutStatus(normalizedCwd, { paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, logger: this.logger, }); if (!status.isGit) { @@ -484,7 +488,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedOptions = this.normalizeCheckoutDiffOptions(options); const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { paseoHome: this.paseoHome }), + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), ); } @@ -581,6 +588,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.deps.listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, }), ); } @@ -1566,7 +1574,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = target.cwd; const previousGitHubPollKey = this.getGitHubPollKey(target); - const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; + const baseContext: CheckoutContext = { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.logger, + }; const facts = await this.loadCheckoutFacts(target, { ...baseContext, allowRecent: !request.force, diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 3891f4403..1aa5f4a30 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -24,6 +24,7 @@ export interface CreateWorktreeCoreInput { githubPrNumber?: number; firstAgentContext?: FirstAgentContext; paseoHome?: string; + worktreesRoot?: string; runSetup?: boolean; } @@ -98,6 +99,7 @@ export async function createWorktreeCore( slug: normalizedSlug, repoRoot, paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, }); if (existingWorktree) { return { worktree: existingWorktree, intent, repoRoot, created: false }; @@ -110,6 +112,7 @@ export async function createWorktreeCore( source: intent, runSetup: input.runSetup ?? true, paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, }), intent, repoRoot, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3b6a74d37..d248e4414 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -75,6 +75,7 @@ type AgentWorktreeSetupTimelineWriter = (input: { interface BuildAgentSessionConfigDependencies { paseoHome?: string; + worktreesRoot?: string; sessionLogger: Logger; workspaceGitService?: WorkspaceGitService; createPaseoWorktree: ( @@ -95,6 +96,7 @@ interface BuildAgentSessionConfigDependencies { interface CreatePaseoWorktreeInBackgroundDependencies { paseoHome?: string; + worktreesRoot?: string; emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise; cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void; emit: EmitSessionMessage; @@ -158,6 +160,7 @@ interface HandleWorkspaceSetupStatusRequestDependencies { interface HandleCreatePaseoWorktreeRequestDependencies { paseoHome?: string; + worktreesRoot?: string; describeWorkspaceRecord: ( result: CreatePaseoWorktreeResult, ) => Promise; @@ -224,6 +227,7 @@ export async function buildAgentSessionConfig( firstAgentContext, runSetup: false, paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.worktreesRoot, }, { resolveDefaultBranch: normalized.baseBranch @@ -491,6 +495,7 @@ export async function handleCreatePaseoWorktreeRequest( const commandResult = await createPaseoWorktreeCommand( { paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.worktreesRoot, createPaseoWorktreeWorkflow: dependencies.createPaseoWorktreeWorkflow, }, { @@ -572,6 +577,7 @@ export async function createPaseoWorktreeWorkflow( ...input, runSetup: false, paseoHome: input.paseoHome ?? dependencies.paseoHome, + worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot, }, options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index ec4e3eb52..c0370692e 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -39,6 +39,7 @@ export interface CreatePaseoWorktreeCommandDependencies< Result extends CreatePaseoWorktreeResult = CreatePaseoWorktreeResult, > { paseoHome?: string; + worktreesRoot?: string; createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow; } @@ -47,6 +48,7 @@ export type CreatePaseoWorktreeCommandInput = Omit< "paseoHome" | "runSetup" > & { paseoHome?: string; + worktreesRoot?: string; }; export type CreatePaseoWorktreeCommandResult = @@ -73,6 +75,7 @@ export async function createPaseoWorktreeCommand { - const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome); + const worktreesRoot = await getPaseoWorktreesRoot( + repoRoot, + dependencies.paseoHome, + dependencies.worktreesRoot, + ); return join(worktreesRoot, worktreeSlug); } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index d506a90f9..fcccea51f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -10,6 +10,7 @@ import { mkdirSync, } from "fs"; import { join } from "path"; +import { win32 } from "node:path"; import { tmpdir } from "os"; import { __resetCheckoutShortstatCacheForTests, @@ -2355,6 +2356,20 @@ const x = 1; expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true); }); + it("matches worktrees under a custom PASEO_HOME", () => { + const customPaseoHome = process.platform === "win32" ? "C:\\paseo" : "/var/lib/paseo"; + const worktreePath = + process.platform === "win32" + ? win32.join(customPaseoHome, "worktrees", "project", "feature") + : `${customPaseoHome}/worktrees/project/feature`; + + expect( + isPaseoWorktreePath(worktreePath, { + paseoHome: customPaseoHome, + }), + ).toBe(true); + }); + it("rejects paths without .paseo/worktrees segment", () => { expect(isPaseoWorktreePath("/home/user/repo")).toBe(false); expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 2bad277c3..b3d8bca9b 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -19,7 +19,7 @@ import { } from "../services/github-service.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.js"; -import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; +import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { readPaseoWorktreeMetadata } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", @@ -779,6 +779,7 @@ export interface MergeFromBaseOptions { export interface CheckoutContext { paseoHome?: string; + worktreesRoot?: string; logger?: Pick; facts?: CheckoutSnapshotFacts | null; } @@ -882,6 +883,7 @@ export async function getMainRepoRoot(cwd: string): Promise { async function getMainRepoRootFromCommonDir( cwd: string, commonDir: string | null, + context?: CheckoutContext, ): Promise { if (!commonDir) { throw new Error("Not in a git repository"); @@ -897,7 +899,14 @@ async function getMainRepoRootFromCommonDir( envOverlay: READ_ONLY_GIT_ENV, }); const worktrees = parseWorktreeList(worktreeOut); - const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path)); + const nonBareNonPaseo = worktrees.filter( + (wt) => + !wt.isBare && + !isPaseoWorktreePath(wt.path, { + paseoHome: context?.paseoHome, + worktreesRoot: context?.worktreesRoot, + }), + ); const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized)); const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main"); return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized; @@ -909,8 +918,14 @@ export interface GitWorktreeEntry { isBare?: boolean; } -/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */ -export function isPaseoWorktreePath(p: string): boolean { +/** Check whether a path is under Paseo's worktree root. */ +export function isPaseoWorktreePath( + p: string, + options?: { paseoHome?: string; worktreesRoot?: string }, +): boolean { + if (options?.worktreesRoot || options?.paseoHome) { + return isDescendantPath(p, resolvePaseoWorktreesBaseRoot(options)); + } return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p); } @@ -1008,7 +1023,10 @@ async function getPaseoWorktreeForCwd( return { isPaseoOwnedWorktree: false }; } - const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome }); + const ownership = await isPaseoOwnedWorktreeCwd(cwd, { + paseoHome: context?.paseoHome, + worktreesRoot: context?.worktreesRoot, + }); if (!ownership.allowed) { return { isPaseoOwnedWorktree: false }; } @@ -1548,9 +1566,11 @@ export async function getCheckoutSnapshotFacts( ? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot) : null; const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd)); - const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch( - () => null, - ); + const mainRepoRoot = await getMainRepoRootFromCommonDir( + cwd, + inspected.gitCommonDir, + context, + ).catch(() => null); let comparisonBaseRef: string | null = null; if ( resolvedBaseRef && diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 3f369e374..d27cdd792 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -49,6 +49,7 @@ interface LegacyCreateWorktreeTestOptions { worktreeSlug: string; runSetup?: boolean; paseoHome?: string; + worktreesRoot?: string; } function createLegacyWorktreeForTest( @@ -68,6 +69,7 @@ function createLegacyWorktreeForTest( }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, }); } @@ -118,6 +120,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ version: 1, baseRefName: "main" }); }); + it("creates and owns worktrees under a configured root", async () => { + const worktreesRoot = join(tempDir, "custom-worktrees"); + const projectHash = await deriveWorktreeProjectHash(repoDir); + const result = await createLegacyWorktreeForTest({ + branchName: "custom-root", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "custom-root", + paseoHome, + worktreesRoot, + }); + + expect(result.worktreePath).toBe(join(worktreesRoot, projectHash, "custom-root")); + await expect( + isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome, worktreesRoot }), + ).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) }); + await expect( + isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }), + ).resolves.toMatchObject({ allowed: false }); + + const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot }); + expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath); + + await deletePaseoWorktree({ + cwd: repoDir, + worktreePath: result.worktreePath, + paseoHome, + worktreesBaseRoot: worktreesRoot, + }); + expect(existsSync(result.worktreePath)).toBe(false); + }); + it.skip("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => { // Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS). const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-")); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index eb2b49c87..1e23676f2 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -2,7 +2,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; -import { join, basename, dirname, resolve, sep } from "path"; +import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; @@ -31,6 +31,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; +import { expandTilde } from "./path.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -150,6 +151,11 @@ export interface PaseoWorktreeOwnership { worktreePath?: string; } +export interface WorktreeRootOptions { + paseoHome?: string; + worktreesRoot?: string; +} + export type WorktreeSource = | { kind: "branch-off"; baseBranch: string; branchName: string } | { kind: "checkout-branch"; branchName: string } @@ -168,12 +174,14 @@ export interface CreateWorktreeOptions { source: WorktreeSource; runSetup: boolean; paseoHome?: string; + worktreesRoot?: string; } interface ResolveExistingWorktreeForSlugOptions { slug: string; repoRoot: string; paseoHome?: string; + worktreesRoot?: string; } export class BranchAlreadyCheckedOutError extends Error { @@ -766,19 +774,38 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise { } } -export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { - const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); +export function resolvePaseoWorktreesBaseRoot(options?: WorktreeRootOptions): string { + if (options?.worktreesRoot) { + const expandedRoot = expandTilde(options.worktreesRoot); + if (isAbsolute(expandedRoot)) { + return resolve(expandedRoot); + } + const home = options.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome(); + return resolve(home, expandedRoot); + } + + const home = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome(); + return join(home, "worktrees"); +} + +export async function getPaseoWorktreesRoot( + cwd: string, + paseoHome?: string, + worktreesRoot?: string, +): Promise { + const baseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot }); const projectHash = await deriveWorktreeProjectHash(cwd); - return join(home, "worktrees", projectHash); + return join(baseRoot, projectHash); } export async function computeWorktreePath( cwd: string, slug: string, paseoHome?: string, + worktreesRoot?: string, ): Promise { - const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); - return join(worktreesRoot, slug); + const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); + return join(projectWorktreesRoot, slug); } function normalizePathForOwnership(input: string): string { @@ -798,7 +825,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string { export async function isPaseoOwnedWorktreeCwd( cwd: string, - options?: { paseoHome?: string }, + options?: WorktreeRootOptions, ): Promise { const resolvedCwd = normalizePathForOwnership(cwd); @@ -813,10 +840,10 @@ export async function isPaseoOwnedWorktreeCwd( // ignore } - const paseoHome = options?.paseoHome ? resolve(options.paseoHome) : resolvePaseoHome(); - const paseoWorktreesPrefix = normalizePathForOwnership(join(paseoHome, "worktrees")) + sep; + const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); + const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep; - // Ownership is defined by the path living under $PASEO_HOME/worktrees//[/...]. + // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. @@ -838,7 +865,7 @@ export async function isPaseoOwnedWorktreeCwd( }; } - const worktreesRoot = join(paseoHome, "worktrees", parts[0]); + const worktreesRoot = join(worktreesBaseRoot, parts[0]); return { allowed: true, ...(repoRoot !== undefined ? { repoRoot } : {}), @@ -901,17 +928,19 @@ function resolveWorktreeCreatedAtIso(worktreePath: string): string { export async function listPaseoWorktrees({ cwd, paseoHome, + worktreesRoot, }: { cwd: string; paseoHome?: string; + worktreesRoot?: string; }): Promise { - const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); + const projectWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, envOverlay: READ_ONLY_GIT_ENV, }); - const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep; + const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep; return parseWorktreeList(stdout) .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) .filter((entry) => entry.path.startsWith(rootPrefix)) @@ -924,10 +953,12 @@ export async function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, + worktreesRoot, }: ResolveExistingWorktreeForSlugOptions): Promise { const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome, + worktreesRoot, }); const slugSuffix = `${sep}${slug}`; const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix)); @@ -952,7 +983,7 @@ export async function resolveExistingWorktreeForSlug({ export async function resolvePaseoWorktreeRootForCwd( cwd: string, - options?: { paseoHome?: string }, + options?: WorktreeRootOptions, ): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> { let gitCommonDir: string; try { @@ -961,7 +992,11 @@ export async function resolvePaseoWorktreeRootForCwd( return null; } - const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); + const worktreesRoot = await getPaseoWorktreesRoot( + cwd, + options?.paseoHome, + options?.worktreesRoot, + ); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; let worktreeRoot: string | null = null; @@ -987,6 +1022,7 @@ export async function resolvePaseoWorktreeRootForCwd( const knownWorktrees = await listPaseoWorktrees({ cwd, paseoHome: options?.paseoHome, + worktreesRoot: options?.worktreesRoot, }); const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot); if (!match) { @@ -1006,12 +1042,14 @@ export async function deletePaseoWorktree({ worktreeSlug, worktreesRoot, paseoHome, + worktreesBaseRoot, }: { cwd: string | null; worktreePath?: string; worktreeSlug?: string; worktreesRoot?: string; paseoHome?: string; + worktreesBaseRoot?: string; }): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); @@ -1024,7 +1062,7 @@ export async function deletePaseoWorktree({ if (worktreesRoot) { resolvedWorktreesRoot = worktreesRoot; } else if (cwd) { - resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); + resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesBaseRoot); } else { throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree"); } @@ -1033,8 +1071,12 @@ export async function deletePaseoWorktree({ const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); const resolvedRequested = normalizePathForOwnership(requestedPath); const resolvedWorktree = - (await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ?? - resolvedRequested; + ( + await resolvePaseoWorktreeRootForCwd(requestedPath, { + paseoHome, + worktreesRoot: worktreesBaseRoot, + }) + )?.worktreePath ?? resolvedRequested; if (!resolvedWorktree.startsWith(resolvedRoot)) { throw new Error("Refusing to delete non-Paseo worktree"); @@ -1121,9 +1163,10 @@ export const createWorktree = async ({ worktreeSlug, runSetup, paseoHome, + worktreesRoot, }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug); + let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); mkdirSync(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 6757654b4..895bb5e81 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -115,6 +115,16 @@ }, "additionalProperties": false }, + "worktrees": { + "type": "object", + "properties": { + "root": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, "providers": { "type": "object", "properties": { diff --git a/public-docs/configuration.md b/public-docs/configuration.md index ae68c9702..9fedebca7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -54,6 +54,20 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). +## Worktrees + +New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`: + +```json +{ + "worktrees": { + "root": "/mnt/fast/paseo-worktrees" + } +} +``` + +Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain where they are; changing this setting only changes where Paseo creates and discovers Paseo-managed worktrees going forward. + ## Voice Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 753767419..8fb0a65fc 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -11,7 +11,7 @@ Each agent runs in its own git worktree, a separate directory on a separate bran ## Layout and workflow -Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source checkout path. Each worktree gets a random slug; the branch name is chosen when you first launch an agent. +Worktrees live under `$PASEO_HOME/worktrees/` by default, grouped by a hash of the source checkout path. You can change the base directory with `worktrees.root` in `config.json`. Each worktree gets a random slug; the branch name is chosen when you first launch an agent. ``` ~/.paseo/worktrees/ @@ -20,6 +20,16 @@ Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source c └── bold-owl/ ``` +With a custom root, Paseo keeps the same hashed layout under that directory: + +```json +{ + "worktrees": { + "root": "/mnt/fast/paseo-worktrees" + } +} +``` + 1. Create a worktree, Paseo runs your setup hooks 2. Launch an agent, a branch is created or assigned 3. Review the diff against the base branch diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 84902b957..b0c800693 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -124,15 +124,15 @@ The desktop app's first-run hook (`installCli`) symlinks this to `~/.local/bin/p Daemon-client architecture: the daemon owns agent lifecycle, state, and the WebSocket API. Tools, CLI, mobile, and desktop apps are all clients. -| | Default | -| -------------- | ------------------------------------------ | -| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) | -| Home | `~/.paseo` (override `PASEO_HOME`) | -| Daemon log | `$PASEO_HOME/daemon.log` | -| Agent state | `$PASEO_HOME/agents/.json` | -| Worktrees | `$PASEO_HOME/worktrees/` | -| PID file | `$PASEO_HOME/paseo.pid` | -| Health | `GET http://127.0.0.1:6767/api/health` | +| | Default | +| -------------- | --------------------------------------------------------------- | +| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) | +| Home | `~/.paseo` (override `PASEO_HOME`) | +| Daemon log | `$PASEO_HOME/daemon.log` | +| Agent state | `$PASEO_HOME/agents/.json` | +| Worktrees | `$PASEO_HOME/worktrees/` (or `worktrees.root` in `config.json`) | +| PID file | `$PASEO_HOME/paseo.pid` | +| Health | `GET http://127.0.0.1:6767/api/health` | Debug order: