diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index c7aea45f4..d36d7c2ed 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -60,6 +60,7 @@ export function CreateAgentModal({ const [workingDir, setWorkingDir] = useState(""); const [selectedMode, setSelectedMode] = useState("plan"); + const [worktreeName, setWorktreeName] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [pendingRequestId, setPendingRequestId] = useState(null); @@ -92,6 +93,94 @@ export function CreateAgentModal({ [] ); + // Slugify helper function + const slugifyWorktreeName = useCallback((input: string): string => { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + }, []); + + // Validate worktree name + const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => { + if (!name) return { valid: true }; // Optional field + + if (name.length > 100) { + return { valid: false, error: "Worktree name too long (max 100 characters)" }; + } + + const validPattern = /^[a-z0-9-]+$/; + if (!validPattern.test(name)) { + return { + valid: false, + error: "Must contain only lowercase letters, numbers, and hyphens", + }; + } + + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; + } + + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; + } + + return { valid: true }; + }, []); + + const handleCreate = useCallback(async () => { + if (!workingDir.trim()) { + setErrorMessage("Working directory is required"); + return; + } + + if (isLoading) { + return; + } + + const path = workingDir.trim(); + const worktree = worktreeName.trim(); + + // Validate worktree name if provided + if (worktree) { + const validation = validateWorktreeName(worktree); + if (!validation.valid) { + setErrorMessage(`Invalid worktree name: ${validation.error}`); + return; + } + } + + // Save to recent paths + try { + await addRecentPath(path); + } catch (error) { + console.error("[CreateAgentModal] Failed to save recent path:", error); + // Continue anyway - don't block agent creation + } + + // Generate request ID + const requestId = generateMessageId(); + + setIsLoading(true); + setPendingRequestId(requestId); + setErrorMessage(""); + + // Create the agent + try { + createAgent({ + cwd: path, + initialMode: selectedMode, + worktreeName: worktree || undefined, + requestId, + }); + } catch (error) { + console.error("[CreateAgentModal] Failed to create agent:", error); + setErrorMessage("Failed to create agent. Please try again."); + setIsLoading(false); + setPendingRequestId(null); + } + }, [workingDir, worktreeName, selectedMode, isLoading, validateWorktreeName, addRecentPath, createAgent]); + const renderFooter = useCallback( (props: BottomSheetFooterProps) => ( @@ -118,7 +207,7 @@ export function CreateAgentModal({ ), - [insets.bottom, workingDir, animatedFooterStyle, isLoading] + [insets.bottom, workingDir, animatedFooterStyle, isLoading, handleCreate] ); useEffect(() => { @@ -156,48 +245,6 @@ export function CreateAgentModal({ }; }, [pendingRequestId, ws]); - async function handleCreate() { - if (!workingDir.trim()) { - setErrorMessage("Working directory is required"); - return; - } - - if (isLoading) { - return; - } - - const path = workingDir.trim(); - - // Save to recent paths - try { - await addRecentPath(path); - } catch (error) { - console.error("[CreateAgentModal] Failed to save recent path:", error); - // Continue anyway - don't block agent creation - } - - // Generate request ID - const requestId = generateMessageId(); - - setIsLoading(true); - setPendingRequestId(requestId); - setErrorMessage(""); - - // Create the agent - try { - createAgent({ - cwd: path, - initialMode: selectedMode, - requestId, - }); - } catch (error) { - console.error("[CreateAgentModal] Failed to create agent:", error); - setErrorMessage("Failed to create agent. Please try again."); - setIsLoading(false); - setPendingRequestId(null); - } - } - function handleClose() { bottomSheetRef.current?.dismiss(); } @@ -207,6 +254,7 @@ export function CreateAgentModal({ // Reset all state setWorkingDir(""); + setWorktreeName(""); setSelectedMode("plan"); setErrorMessage(""); setIsLoading(false); @@ -293,6 +341,29 @@ export function CreateAgentModal({ )} + {/* Worktree Name Input (Optional) */} + + Worktree Name (Optional) + { + // Auto-slugify as user types + const slugified = slugifyWorktreeName(text); + setWorktreeName(slugified); + setErrorMessage(""); + }} + autoCapitalize="none" + autoCorrect={false} + editable={!isLoading} + /> + + Create a git worktree for isolated development. Must be lowercase, alphanumeric, and hyphens only. + + + {/* Mode Selector */} Mode diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index a58cae9b5..7a39b7fb5 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -110,13 +110,6 @@ class ACPClient implements Client { } async sessionUpdate(params: SessionNotification): Promise { - if (params.update.sessionUpdate === "agent_message_chunk") { - console.log( - `[Agent ${this.agentId}] Message chunk update:`, - JSON.stringify(params, null, 2) - ); - } - // Check if this update contains a Claude session ID const claudeSessionId = params._meta?.claudeSessionId as string | undefined; if (claudeSessionId) { @@ -1231,9 +1224,6 @@ export class AgentManager { // Store the update in history agent.updates.push(agentUpdate); - // Log update for debugging - console.log(`[Agent ${agentId}] Session update:`, updateType); - // Notify all subscribers for (const subscriber of agent.subscribers) { try { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bcba0fdf8..3fa5b61b4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -33,6 +33,7 @@ import { generateAgentTitle, isTitleGeneratorInitialized, } from "../services/agent-title-generator.js"; +import { expandTilde } from "./terminal-mcp/tmux.js"; const execAsync = promisify(exec); @@ -166,10 +167,6 @@ export class Session { agentId, (update: AgentUpdate) => { const notification = update.notification; - console.log( - `[Session ${this.clientId}] Agent update notification type:`, - notification.type - ); // Handle permission requests if (notification.type === "permission") { @@ -499,7 +496,8 @@ export class Session { await this.handleCreateAgentRequest( msg.cwd, msg.initialMode, - msg.requestId + msg.requestId, + msg.worktreeName ); break; @@ -695,7 +693,7 @@ export class Session { private async handleSendAgentAudio( msg: Extract ): Promise { - const { agentId, audio, format, isLast } = msg; + const { agentId, audio, format, isLast, requestId } = msg; // Decode base64 const audioBuffer = Buffer.from(audio, "base64"); @@ -730,6 +728,17 @@ export class Session { `[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}` ); + // Emit transcription result to client with requestId + this.emit({ + type: "transcription_result", + payload: { + text: result.text, + language: result.language, + duration: result.duration, + requestId, + }, + }); + // Send transcribed text to agent await this.agentManager.sendPrompt(agentId, transcriptText); console.log( @@ -814,17 +823,44 @@ export class Session { private async handleCreateAgentRequest( cwd: string, initialMode?: string, - requestId?: string + requestId?: string, + worktreeName?: string ): Promise { console.log( `[Session ${this.clientId}] Creating agent in ${cwd} with mode ${ initialMode || "default" - }` + }${worktreeName ? ` with worktree ${worktreeName}` : ""}` ); try { + let effectiveCwd = cwd; + + // Handle worktree creation if requested + if (worktreeName) { + const { createWorktree } = await import("../utils/worktree.js"); + console.log( + `[Session ${this.clientId}] Creating worktree with branch: ${worktreeName}` + ); + + // Expand tilde in path before passing to createWorktree + const expandedCwd = expandTilde(cwd); + console.log( + `[Session ${this.clientId}] Expanded cwd from ${cwd} to ${expandedCwd}` + ); + + const worktreeConfig = await createWorktree({ + branchName: worktreeName, + cwd: expandedCwd, + }); + + effectiveCwd = worktreeConfig.worktreePath; + console.log( + `[Session ${this.clientId}] Worktree created at: ${effectiveCwd}` + ); + } + const agentId = await this.agentManager.createAgent({ - cwd, + cwd: effectiveCwd, initialMode, }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9bbcad5c8..84676e2d6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -160,6 +160,16 @@ export class VoiceAssistantWebSocketServer { // Extract and forward session message const sessionMessage = extractSessionMessage(message); if (sessionMessage) { + // Debug: Log create_agent_request details + if (sessionMessage.type === "create_agent_request") { + console.log("[WS] create_agent_request details:", { + cwd: sessionMessage.cwd, + initialMode: sessionMessage.initialMode, + worktreeName: sessionMessage.worktreeName, + requestId: sessionMessage.requestId, + }); + } + const session = this.sessions.get(ws); if (session) { await session.handleMessage(sessionMessage); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts new file mode 100644 index 000000000..051089893 --- /dev/null +++ b/packages/server/src/utils/worktree.ts @@ -0,0 +1,263 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { existsSync, readFileSync } from "fs"; +import { join, basename, dirname } from "path"; + +const execAsync = promisify(exec); + +interface RepoInfo { + type: "bare" | "normal"; + path: string; + name: string; +} + +interface WorktreeConfig { + branchName: string; + worktreePath: string; + repoType: "bare" | "normal"; + repoPath: string; +} + +/** + * Check if current directory is a bare repository + */ +function isBareRepo(dir: string): boolean { + const headPath = join(dir, "HEAD"); + const refsPath = join(dir, "refs"); + const gitPath = join(dir, ".git"); + + return existsSync(headPath) && existsSync(refsPath) && !existsSync(gitPath); +} + +/** + * Detect repository information (type, path, name) + */ +export async function detectRepoInfo(cwd: string): Promise { + // Check if we're in a bare repository + if (isBareRepo(cwd)) { + return { + type: "bare", + path: cwd, + name: basename(cwd), + }; + } + + // Check if we're in a worktree directory (has .git file pointing to gitdir) + const gitFilePath = join(cwd, ".git"); + if (existsSync(gitFilePath) && !existsSync(join(cwd, ".git", "HEAD"))) { + const gitContent = readFileSync(gitFilePath, "utf8"); + const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/); + + if (gitdirMatch && gitdirMatch[1]) { + const gitdir = gitdirMatch[1].trim(); + + // Check if gitdir contains .git/worktrees + if (gitdir.includes("/.git/worktrees/")) { + // Extract the repo path (everything before /.git/worktrees/) + const repoRoot = gitdir.split("/.git/worktrees/")[0]; + if (repoRoot) { + return { + type: "normal", + path: repoRoot, + name: basename(repoRoot), + }; + } + } else { + // Bare repo structure - worktrees are siblings + const worktreesDir = dirname(gitdir); + const bareRepoDir = dirname(worktreesDir); + return { + type: "bare", + path: bareRepoDir, + name: basename(bareRepoDir), + }; + } + } + } + + // Check if we're in a normal git repository + const gitDirPath = join(cwd, ".git"); + if (existsSync(gitDirPath)) { + try { + const { stdout } = await execAsync("git rev-parse --show-toplevel", { + cwd, + }); + const repoRoot = stdout.trim(); + return { + type: "normal", + path: repoRoot, + name: basename(repoRoot), + }; + } catch (error) { + throw new Error("Failed to determine git repository root"); + } + } + + throw new Error("Not in a git repository"); +} + +/** + * Validate that a string is a valid git branch name slug + * Must be lowercase, alphanumeric, hyphens only + */ +export function validateBranchSlug(slug: string): { + valid: boolean; + error?: string; +} { + if (!slug || slug.length === 0) { + return { valid: false, error: "Branch name cannot be empty" }; + } + + if (slug.length > 100) { + return { valid: false, error: "Branch name too long (max 100 characters)" }; + } + + // Check for valid characters: lowercase letters, numbers, hyphens + const validPattern = /^[a-z0-9-]+$/; + if (!validPattern.test(slug)) { + return { + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, and hyphens", + }; + } + + // Cannot start or end with hyphen + if (slug.startsWith("-") || slug.endsWith("-")) { + return { + valid: false, + error: "Branch name cannot start or end with a hyphen", + }; + } + + // Cannot have consecutive hyphens + if (slug.includes("--")) { + return { valid: false, error: "Branch name cannot have consecutive hyphens" }; + } + + return { valid: true }; +} + +/** + * Convert string to kebab-case for branch names + */ +export function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Check if a worktree already exists for a branch + */ +async function findExistingWorktree( + branchName: string, + repoPath: string +): Promise { + try { + const { stdout } = await execAsync("git worktree list --porcelain", { + cwd: repoPath, + }); + const lines = stdout.split("\n"); + + let currentWorktree: string | null = null; + + for (const line of lines) { + if (line.startsWith("worktree ")) { + currentWorktree = line.substring("worktree ".length); + } else if ( + line === `branch refs/heads/${branchName}` && + currentWorktree + ) { + return currentWorktree; + } + } + + return null; + } catch (error) { + return null; + } +} + +/** + * Create a git worktree with proper naming conventions + */ +export async function createWorktree(params: { + branchName: string; + cwd: string; +}): Promise { + const { branchName, cwd } = params; + + // Validate branch name + const validation = validateBranchSlug(branchName); + if (!validation.valid) { + throw new Error(`Invalid branch name: ${validation.error}`); + } + + // Detect repository info + const repoInfo = await detectRepoInfo(cwd); + + // Determine worktree directory based on repo type + let worktreePath: string; + if (repoInfo.type === "bare") { + worktreePath = join(repoInfo.path, branchName); + } else { + const parentDir = dirname(repoInfo.path); + const worktreeName = `${repoInfo.name}-${branchName.replace(/\//g, "-")}`; + worktreePath = join(parentDir, worktreeName); + } + + // Check if branch already exists + try { + await execAsync( + `git show-ref --verify --quiet refs/heads/${branchName}`, + { cwd: repoInfo.path } + ); + + // Branch exists, check for existing worktree + const existingWorktree = await findExistingWorktree( + branchName, + repoInfo.path + ); + + if (existingWorktree) { + if (existsSync(existingWorktree)) { + throw new Error( + `Worktree already exists at: ${existingWorktree}. Use 'git worktree remove ${existingWorktree}' to remove it first.` + ); + } else { + // Prune stale worktree reference + await execAsync("git worktree prune", { cwd: repoInfo.path }); + } + } + + // Create worktree using existing branch + await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, { + cwd: repoInfo.path, + }); + } catch (error) { + // Branch doesn't exist, create new branch and worktree + await execAsync( + `git worktree add "${worktreePath}" -b "${branchName}"`, + { cwd: repoInfo.path } + ); + } + + // Copy .env file if it exists + const envSource = + repoInfo.type === "bare" + ? join(repoInfo.path, "main", ".env") + : join(repoInfo.path, ".env"); + + if (existsSync(envSource)) { + await execAsync(`cp "${envSource}" "${worktreePath}/.env"`); + } + + return { + branchName, + worktreePath, + repoType: repoInfo.type, + repoPath: repoInfo.path, + }; +}