diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index e3dbf5a..8a044d2 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -26,6 +26,8 @@ pub struct AgentQueryOptions { pub model: Option, pub claude_config_dir: Option, pub additional_directories: Option>, + /// When set, agent runs in a git worktree for isolation (passed as --worktree CLI flag) + pub worktree_name: Option, /// Provider-specific configuration blob (passed through to sidecar as-is) #[serde(default)] pub provider_config: serde_json::Value, @@ -216,6 +218,7 @@ impl SidecarManager { "model": options.model, "claudeConfigDir": options.claude_config_dir, "additionalDirectories": options.additional_directories, + "worktreeName": options.worktree_name, "providerConfig": options.provider_config, }); diff --git a/v2/sidecar/claude-runner.ts b/v2/sidecar/claude-runner.ts index a2ca35c..d85a18f 100644 --- a/v2/sidecar/claude-runner.ts +++ b/v2/sidecar/claude-runner.ts @@ -48,6 +48,7 @@ interface QueryMessage { model?: string; claudeConfigDir?: string; additionalDirectories?: string[]; + worktreeName?: string; } interface StopMessage { @@ -72,7 +73,7 @@ async function handleMessage(msg: Record) { } async function handleQuery(msg: QueryMessage) { - const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg; + const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName } = msg; if (sessions.has(sessionId)) { send({ type: 'error', sessionId, message: 'Session already running' }); @@ -126,6 +127,7 @@ async function handleQuery(msg: QueryMessage) { : { type: 'preset' as const, preset: 'claude_code' as const }, model: model ?? undefined, additionalDirectories: additionalDirectories ?? undefined, + extraArgs: worktreeName ? { worktree: worktreeName } : undefined, }, }); diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts index 6125614..5fe96f9 100644 --- a/v2/src/lib/adapters/agent-bridge.ts +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -20,6 +20,8 @@ export interface AgentQueryOptions { model?: string; claude_config_dir?: string; additional_directories?: string[]; + /** When set, agent runs in a git worktree for isolation */ + worktree_name?: string; provider_config?: Record; remote_machine_id?: string; } diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index 030564d..74be7df 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -205,6 +205,7 @@ import { isSidecarAlive, setSidecarAlive, waitForPendingPersistence, + detectWorktreeFromCwd, } from './agent-dispatcher'; // Stop any previous dispatcher between tests so `unlistenMsg` is null and start works @@ -622,4 +623,71 @@ describe('agent-dispatcher', () => { await expect(waitForPendingPersistence()).resolves.toBeUndefined(); }); }); + + describe('detectWorktreeFromCwd', () => { + it('detects Claude Code worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session'); + expect(result).toBe('/.claude/worktrees/my-session'); + }); + + it('detects Codex worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1'); + expect(result).toBe('/.codex/worktrees/task-1'); + }); + + it('detects Cursor worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x'); + expect(result).toBe('/.cursor/worktrees/feature-x'); + }); + + it('returns null for non-worktree CWD', () => { + expect(detectWorktreeFromCwd('/home/user/project')).toBeNull(); + expect(detectWorktreeFromCwd('/tmp/work')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(detectWorktreeFromCwd('')).toBeNull(); + }); + }); + + describe('init event CWD worktree detection', () => { + beforeEach(async () => { + await startAgentDispatcher(); + }); + + it('calls setSessionWorktree when init CWD contains worktree path', async () => { + const { setSessionWorktree } = await import('./stores/conflicts.svelte'); + + // Override the mock adapter to return init with worktree CWD + const { adaptMessage } = await import('./adapters/message-adapters'); + (adaptMessage as ReturnType).mockReturnValueOnce([{ + id: 'msg-wt', + type: 'init', + content: { sessionId: 'sdk-wt', model: 'claude-sonnet-4-20250514', cwd: '/home/user/repo/.claude/worktrees/my-session', tools: [] }, + timestamp: Date.now(), + }]); + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-wt', + event: { type: 'system', subtype: 'init' }, + }); + + expect(setSessionWorktree).toHaveBeenCalledWith('sess-wt', '/.claude/worktrees/my-session'); + }); + + it('does not call setSessionWorktree for non-worktree CWD', async () => { + const { setSessionWorktree } = await import('./stores/conflicts.svelte'); + (setSessionWorktree as ReturnType).mockClear(); + + capturedCallbacks.msg!({ + type: 'agent_event', + sessionId: 'sess-normal', + event: { type: 'system', subtype: 'init' }, + }); + + // The default mock returns cwd: '/tmp' which is not a worktree + expect(setSessionWorktree).not.toHaveBeenCalled(); + }); + }); }); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 2908b97..88eaee3 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -182,6 +182,14 @@ function handleAgentEvent(sessionId: string, event: Record): vo const init = msg.content as InitContent; setAgentSdkSessionId(sessionId, init.sessionId); setAgentModel(sessionId, init.model); + // CWD-based worktree detection: if init CWD contains a worktree path pattern, + // register it for conflict suppression (agents in different worktrees don't conflict) + if (init.cwd) { + const wtPath = detectWorktreeFromCwd(init.cwd); + if (wtPath) { + setSessionWorktree(sessionId, wtPath); + } + } break; } @@ -454,6 +462,22 @@ function triggerAutoAnchor( notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); } +// Worktree path patterns for various providers +const WORKTREE_CWD_PATTERNS = [ + /\/\.claude\/worktrees\/([^/]+)/, // Claude Code: /.claude/worktrees// + /\/\.codex\/worktrees\/([^/]+)/, // Codex + /\/\.cursor\/worktrees\/([^/]+)/, // Cursor +]; + +/** Extract worktree path from CWD if it matches a known worktree pattern */ +export function detectWorktreeFromCwd(cwd: string): string | null { + for (const pattern of WORKTREE_CWD_PATTERNS) { + const match = cwd.match(pattern); + if (match) return match[0]; // Return the full worktree path segment + } + return null; +} + export function stopAgentDispatcher(): void { if (unlistenMsg) { unlistenMsg(); diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index d949c55..76b4d28 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -53,10 +53,11 @@ profile?: string; provider?: ProviderId; capabilities?: ProviderCapabilities; + useWorktrees?: boolean; onExit?: () => void; } - let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props(); + let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, onExit }: Props = $props(); let session = $derived(getAgentSession(sessionId)); let inputPrompt = $state(initialPrompt); @@ -184,6 +185,7 @@ setting_sources: ['user', 'project'], claude_config_dir: profile?.config_dir, system_prompt: systemPrompt, + worktree_name: useWorktrees ? sessionId : undefined, }); inputPrompt = ''; if (promptRef) { diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte index 12958d0..c9436ed 100644 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -130,6 +130,7 @@ profile={project.profile || undefined} provider={providerId} capabilities={providerMeta?.capabilities} + useWorktrees={project.useWorktrees ?? false} onExit={handleNewSession} /> {/if} diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte index 3737b49..9067136 100644 --- a/v2/src/lib/components/Workspace/SettingsTab.svelte +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -793,6 +793,21 @@ +
+ + + Worktree Isolation + + +
+