claude-mpm 3.9.0__py3-none-any.whl → 3.9.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/BASE_AGENT_TEMPLATE.md +59 -135
- claude_mpm/agents/MEMORY.md +5 -2
- claude_mpm/agents/WORKFLOW.md +54 -4
- claude_mpm/agents/agents_metadata.py +25 -1
- claude_mpm/agents/schema/agent_schema.json +1 -1
- claude_mpm/agents/templates/backup/research_agent_2025011_234551.json +88 -0
- claude_mpm/agents/templates/project_organizer.json +178 -0
- claude_mpm/agents/templates/research.json +33 -30
- claude_mpm/config/paths.py +56 -6
- claude_mpm/core/claude_runner.py +31 -10
- claude_mpm/hooks/claude_hooks/hook_handler.py +167 -105
- claude_mpm/hooks/claude_hooks/hook_handler_fixed.py +454 -0
- claude_mpm/utils/paths.py +112 -8
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/METADATA +1 -1
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/RECORD +20 -17
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/WHEEL +0 -0
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/entry_points.txt +0 -0
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-3.9.0.dist-info → claude_mpm-3.9.4.dist-info}/top_level.txt +0 -0
| @@ -0,0 +1,454 @@ | |
| 1 | 
            +
            #!/usr/bin/env python3
         | 
| 2 | 
            +
            """Optimized Claude Code hook handler with fixed memory management.
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            MEMORY LEAK FIXES:
         | 
| 5 | 
            +
            1. Use singleton pattern for ClaudeHookHandler to prevent multiple instances
         | 
| 6 | 
            +
            2. Proper cleanup of Socket.IO connections with connection pooling
         | 
| 7 | 
            +
            3. Bounded dictionaries with automatic cleanup of old entries
         | 
| 8 | 
            +
            4. Improved git branch cache with proper expiration
         | 
| 9 | 
            +
            5. Better resource management and connection reuse
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            WHY these fixes:
         | 
| 12 | 
            +
            - Singleton pattern ensures only one handler instance exists
         | 
| 13 | 
            +
            - Connection pooling prevents creating new connections for each event
         | 
| 14 | 
            +
            - Bounded dictionaries prevent unbounded memory growth
         | 
| 15 | 
            +
            - Regular cleanup prevents accumulation of stale data
         | 
| 16 | 
            +
            """
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            import json
         | 
| 19 | 
            +
            import sys
         | 
| 20 | 
            +
            import os
         | 
| 21 | 
            +
            import subprocess
         | 
| 22 | 
            +
            from datetime import datetime, timedelta
         | 
| 23 | 
            +
            import time
         | 
| 24 | 
            +
            import asyncio
         | 
| 25 | 
            +
            from pathlib import Path
         | 
| 26 | 
            +
            from collections import deque
         | 
| 27 | 
            +
            import weakref
         | 
| 28 | 
            +
            import gc
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            # Import constants for configuration
         | 
| 31 | 
            +
            try:
         | 
| 32 | 
            +
                from claude_mpm.core.constants import (
         | 
| 33 | 
            +
                    NetworkConfig,
         | 
| 34 | 
            +
                    TimeoutConfig,
         | 
| 35 | 
            +
                    RetryConfig
         | 
| 36 | 
            +
                )
         | 
| 37 | 
            +
            except ImportError:
         | 
| 38 | 
            +
                # Fallback values if constants module not available
         | 
| 39 | 
            +
                class NetworkConfig:
         | 
| 40 | 
            +
                    SOCKETIO_PORT_RANGE = (8080, 8099)
         | 
| 41 | 
            +
                    RECONNECTION_DELAY = 0.5
         | 
| 42 | 
            +
                    SOCKET_WAIT_TIMEOUT = 1.0
         | 
| 43 | 
            +
                class TimeoutConfig:
         | 
| 44 | 
            +
                    QUICK_TIMEOUT = 2.0
         | 
| 45 | 
            +
                    QUEUE_GET_TIMEOUT = 1.0
         | 
| 46 | 
            +
                class RetryConfig:
         | 
| 47 | 
            +
                    MAX_RETRIES = 3
         | 
| 48 | 
            +
                    INITIAL_RETRY_DELAY = 0.1
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            # Debug mode is enabled by default for better visibility into hook processing
         | 
| 51 | 
            +
            DEBUG = os.environ.get('CLAUDE_MPM_HOOK_DEBUG', 'true').lower() != 'false'
         | 
| 52 | 
            +
             | 
| 53 | 
            +
            # Socket.IO import
         | 
| 54 | 
            +
            try:
         | 
| 55 | 
            +
                import socketio
         | 
| 56 | 
            +
                SOCKETIO_AVAILABLE = True
         | 
| 57 | 
            +
            except ImportError:
         | 
| 58 | 
            +
                SOCKETIO_AVAILABLE = False
         | 
| 59 | 
            +
                socketio = None
         | 
| 60 | 
            +
             | 
| 61 | 
            +
            # Memory hooks and response tracking imports (simplified)
         | 
| 62 | 
            +
            MEMORY_HOOKS_AVAILABLE = False
         | 
| 63 | 
            +
            RESPONSE_TRACKING_AVAILABLE = False
         | 
| 64 | 
            +
             | 
| 65 | 
            +
            # Maximum size for tracking dictionaries to prevent unbounded growth
         | 
| 66 | 
            +
            MAX_DELEGATION_TRACKING = 100
         | 
| 67 | 
            +
            MAX_PROMPT_TRACKING = 50
         | 
| 68 | 
            +
            MAX_CACHE_AGE_SECONDS = 300  # 5 minutes
         | 
| 69 | 
            +
            CLEANUP_INTERVAL_EVENTS = 100  # Clean up every 100 events
         | 
| 70 | 
            +
             | 
| 71 | 
            +
             | 
| 72 | 
            +
            class SocketIOConnectionPool:
         | 
| 73 | 
            +
                """Connection pool for Socket.IO clients to prevent connection leaks.
         | 
| 74 | 
            +
                
         | 
| 75 | 
            +
                WHY: Reuses connections instead of creating new ones for each event,
         | 
| 76 | 
            +
                preventing the accumulation of zombie connections over time.
         | 
| 77 | 
            +
                """
         | 
| 78 | 
            +
                
         | 
| 79 | 
            +
                def __init__(self, max_connections=3):
         | 
| 80 | 
            +
                    self.max_connections = max_connections
         | 
| 81 | 
            +
                    self.connections = []
         | 
| 82 | 
            +
                    self.current_index = 0
         | 
| 83 | 
            +
                    self.last_cleanup = time.time()
         | 
| 84 | 
            +
                    
         | 
| 85 | 
            +
                def get_connection(self, port):
         | 
| 86 | 
            +
                    """Get or create a connection to the specified port."""
         | 
| 87 | 
            +
                    # Clean up dead connections periodically
         | 
| 88 | 
            +
                    if time.time() - self.last_cleanup > 60:  # Every minute
         | 
| 89 | 
            +
                        self._cleanup_dead_connections()
         | 
| 90 | 
            +
                        self.last_cleanup = time.time()
         | 
| 91 | 
            +
                    
         | 
| 92 | 
            +
                    # Look for existing connection to this port
         | 
| 93 | 
            +
                    for conn in self.connections:
         | 
| 94 | 
            +
                        if conn.get('port') == port and conn.get('client'):
         | 
| 95 | 
            +
                            client = conn['client']
         | 
| 96 | 
            +
                            if self._is_connection_alive(client):
         | 
| 97 | 
            +
                                return client
         | 
| 98 | 
            +
                            else:
         | 
| 99 | 
            +
                                # Remove dead connection
         | 
| 100 | 
            +
                                self.connections.remove(conn)
         | 
| 101 | 
            +
                    
         | 
| 102 | 
            +
                    # Create new connection if under limit
         | 
| 103 | 
            +
                    if len(self.connections) < self.max_connections:
         | 
| 104 | 
            +
                        client = self._create_connection(port)
         | 
| 105 | 
            +
                        if client:
         | 
| 106 | 
            +
                            self.connections.append({
         | 
| 107 | 
            +
                                'port': port,
         | 
| 108 | 
            +
                                'client': client,
         | 
| 109 | 
            +
                                'created': time.time()
         | 
| 110 | 
            +
                            })
         | 
| 111 | 
            +
                            return client
         | 
| 112 | 
            +
                    
         | 
| 113 | 
            +
                    # Reuse oldest connection if at limit
         | 
| 114 | 
            +
                    if self.connections:
         | 
| 115 | 
            +
                        oldest = min(self.connections, key=lambda x: x['created'])
         | 
| 116 | 
            +
                        self._close_connection(oldest['client'])
         | 
| 117 | 
            +
                        oldest['client'] = self._create_connection(port)
         | 
| 118 | 
            +
                        oldest['port'] = port
         | 
| 119 | 
            +
                        oldest['created'] = time.time()
         | 
| 120 | 
            +
                        return oldest['client']
         | 
| 121 | 
            +
                    
         | 
| 122 | 
            +
                    return None
         | 
| 123 | 
            +
                
         | 
| 124 | 
            +
                def _create_connection(self, port):
         | 
| 125 | 
            +
                    """Create a new Socket.IO connection."""
         | 
| 126 | 
            +
                    if not SOCKETIO_AVAILABLE:
         | 
| 127 | 
            +
                        return None
         | 
| 128 | 
            +
                        
         | 
| 129 | 
            +
                    try:
         | 
| 130 | 
            +
                        client = socketio.Client(
         | 
| 131 | 
            +
                            reconnection=False,  # Disable auto-reconnect to prevent zombies
         | 
| 132 | 
            +
                            logger=False,
         | 
| 133 | 
            +
                            engineio_logger=False
         | 
| 134 | 
            +
                        )
         | 
| 135 | 
            +
                        client.connect(f'http://localhost:{port}', 
         | 
| 136 | 
            +
                                      wait=True, 
         | 
| 137 | 
            +
                                      wait_timeout=NetworkConfig.SOCKET_WAIT_TIMEOUT)
         | 
| 138 | 
            +
                        if client.connected:
         | 
| 139 | 
            +
                            return client
         | 
| 140 | 
            +
                    except Exception:
         | 
| 141 | 
            +
                        pass
         | 
| 142 | 
            +
                    return None
         | 
| 143 | 
            +
                
         | 
| 144 | 
            +
                def _is_connection_alive(self, client):
         | 
| 145 | 
            +
                    """Check if a connection is still alive."""
         | 
| 146 | 
            +
                    try:
         | 
| 147 | 
            +
                        return client and client.connected
         | 
| 148 | 
            +
                    except:
         | 
| 149 | 
            +
                        return False
         | 
| 150 | 
            +
                
         | 
| 151 | 
            +
                def _close_connection(self, client):
         | 
| 152 | 
            +
                    """Safely close a connection."""
         | 
| 153 | 
            +
                    try:
         | 
| 154 | 
            +
                        if client:
         | 
| 155 | 
            +
                            client.disconnect()
         | 
| 156 | 
            +
                    except:
         | 
| 157 | 
            +
                        pass
         | 
| 158 | 
            +
                
         | 
| 159 | 
            +
                def _cleanup_dead_connections(self):
         | 
| 160 | 
            +
                    """Remove dead connections from the pool."""
         | 
| 161 | 
            +
                    self.connections = [
         | 
| 162 | 
            +
                        conn for conn in self.connections 
         | 
| 163 | 
            +
                        if self._is_connection_alive(conn.get('client'))
         | 
| 164 | 
            +
                    ]
         | 
| 165 | 
            +
                
         | 
| 166 | 
            +
                def close_all(self):
         | 
| 167 | 
            +
                    """Close all connections in the pool."""
         | 
| 168 | 
            +
                    for conn in self.connections:
         | 
| 169 | 
            +
                        self._close_connection(conn.get('client'))
         | 
| 170 | 
            +
                    self.connections.clear()
         | 
| 171 | 
            +
             | 
| 172 | 
            +
             | 
| 173 | 
            +
            class BoundedDict(dict):
         | 
| 174 | 
            +
                """Dictionary with maximum size that removes oldest entries.
         | 
| 175 | 
            +
                
         | 
| 176 | 
            +
                WHY: Prevents unbounded memory growth by automatically removing
         | 
| 177 | 
            +
                old entries when the size limit is reached.
         | 
| 178 | 
            +
                """
         | 
| 179 | 
            +
                
         | 
| 180 | 
            +
                def __init__(self, max_size=100):
         | 
| 181 | 
            +
                    super().__init__()
         | 
| 182 | 
            +
                    self.max_size = max_size
         | 
| 183 | 
            +
                    self.access_times = {}
         | 
| 184 | 
            +
                    
         | 
| 185 | 
            +
                def __setitem__(self, key, value):
         | 
| 186 | 
            +
                    # Remove oldest entries if at capacity
         | 
| 187 | 
            +
                    if len(self) >= self.max_size and key not in self:
         | 
| 188 | 
            +
                        # Find and remove the oldest entry
         | 
| 189 | 
            +
                        if self.access_times:
         | 
| 190 | 
            +
                            oldest_key = min(self.access_times, key=self.access_times.get)
         | 
| 191 | 
            +
                            del self[oldest_key]
         | 
| 192 | 
            +
                            del self.access_times[oldest_key]
         | 
| 193 | 
            +
                    
         | 
| 194 | 
            +
                    super().__setitem__(key, value)
         | 
| 195 | 
            +
                    self.access_times[key] = time.time()
         | 
| 196 | 
            +
                
         | 
| 197 | 
            +
                def __delitem__(self, key):
         | 
| 198 | 
            +
                    super().__delitem__(key)
         | 
| 199 | 
            +
                    self.access_times.pop(key, None)
         | 
| 200 | 
            +
                
         | 
| 201 | 
            +
                def cleanup_old_entries(self, max_age_seconds=300):
         | 
| 202 | 
            +
                    """Remove entries older than specified age."""
         | 
| 203 | 
            +
                    current_time = time.time()
         | 
| 204 | 
            +
                    keys_to_remove = [
         | 
| 205 | 
            +
                        key for key, access_time in self.access_times.items()
         | 
| 206 | 
            +
                        if current_time - access_time > max_age_seconds
         | 
| 207 | 
            +
                    ]
         | 
| 208 | 
            +
                    for key in keys_to_remove:
         | 
| 209 | 
            +
                        del self[key]
         | 
| 210 | 
            +
             | 
| 211 | 
            +
             | 
| 212 | 
            +
            class ClaudeHookHandler:
         | 
| 213 | 
            +
                """Optimized hook handler with proper memory management.
         | 
| 214 | 
            +
                
         | 
| 215 | 
            +
                FIXES:
         | 
| 216 | 
            +
                - Uses connection pooling for Socket.IO clients
         | 
| 217 | 
            +
                - Bounded dictionaries prevent unbounded growth
         | 
| 218 | 
            +
                - Regular cleanup of old entries
         | 
| 219 | 
            +
                - Proper cache expiration
         | 
| 220 | 
            +
                """
         | 
| 221 | 
            +
                
         | 
| 222 | 
            +
                # Class-level singleton instance
         | 
| 223 | 
            +
                _instance = None
         | 
| 224 | 
            +
                _instance_lock = None
         | 
| 225 | 
            +
                
         | 
| 226 | 
            +
                def __new__(cls):
         | 
| 227 | 
            +
                    """Implement singleton pattern to prevent multiple instances."""
         | 
| 228 | 
            +
                    if cls._instance is None:
         | 
| 229 | 
            +
                        cls._instance = super().__new__(cls)
         | 
| 230 | 
            +
                        cls._instance._initialized = False
         | 
| 231 | 
            +
                    return cls._instance
         | 
| 232 | 
            +
                
         | 
| 233 | 
            +
                def __init__(self):
         | 
| 234 | 
            +
                    # Only initialize once
         | 
| 235 | 
            +
                    if self._initialized:
         | 
| 236 | 
            +
                        return
         | 
| 237 | 
            +
                    self._initialized = True
         | 
| 238 | 
            +
                    
         | 
| 239 | 
            +
                    # Socket.IO connection pool
         | 
| 240 | 
            +
                    self.connection_pool = SocketIOConnectionPool(max_connections=3)
         | 
| 241 | 
            +
                    
         | 
| 242 | 
            +
                    # Use bounded dictionaries to prevent unbounded memory growth
         | 
| 243 | 
            +
                    self.active_delegations = BoundedDict(MAX_DELEGATION_TRACKING)
         | 
| 244 | 
            +
                    self.delegation_requests = BoundedDict(MAX_DELEGATION_TRACKING)
         | 
| 245 | 
            +
                    self.pending_prompts = BoundedDict(MAX_PROMPT_TRACKING)
         | 
| 246 | 
            +
                    
         | 
| 247 | 
            +
                    # Limited delegation history
         | 
| 248 | 
            +
                    self.delegation_history = deque(maxlen=100)
         | 
| 249 | 
            +
                    
         | 
| 250 | 
            +
                    # Git branch cache with expiration
         | 
| 251 | 
            +
                    self._git_branch_cache = {}
         | 
| 252 | 
            +
                    self._git_branch_cache_time = {}
         | 
| 253 | 
            +
                    
         | 
| 254 | 
            +
                    # Track events processed for periodic cleanup
         | 
| 255 | 
            +
                    self.events_processed = 0
         | 
| 256 | 
            +
                    
         | 
| 257 | 
            +
                    # Initialize other components (simplified for brevity)
         | 
| 258 | 
            +
                    self.memory_hooks_initialized = False
         | 
| 259 | 
            +
                    self.pre_delegation_hook = None
         | 
| 260 | 
            +
                    self.post_delegation_hook = None
         | 
| 261 | 
            +
                    self.response_tracker = None
         | 
| 262 | 
            +
                    self.response_tracking_enabled = False
         | 
| 263 | 
            +
                    self.track_all_interactions = False
         | 
| 264 | 
            +
                    
         | 
| 265 | 
            +
                    if DEBUG:
         | 
| 266 | 
            +
                        print(f"✅ ClaudeHookHandler singleton initialized (pid: {os.getpid()})", file=sys.stderr)
         | 
| 267 | 
            +
                
         | 
| 268 | 
            +
                def _periodic_cleanup(self):
         | 
| 269 | 
            +
                    """Perform periodic cleanup of old data."""
         | 
| 270 | 
            +
                    self.events_processed += 1
         | 
| 271 | 
            +
                    
         | 
| 272 | 
            +
                    if self.events_processed % CLEANUP_INTERVAL_EVENTS == 0:
         | 
| 273 | 
            +
                        # Clean up old entries in bounded dictionaries
         | 
| 274 | 
            +
                        self.active_delegations.cleanup_old_entries(MAX_CACHE_AGE_SECONDS)
         | 
| 275 | 
            +
                        self.delegation_requests.cleanup_old_entries(MAX_CACHE_AGE_SECONDS)
         | 
| 276 | 
            +
                        self.pending_prompts.cleanup_old_entries(MAX_CACHE_AGE_SECONDS)
         | 
| 277 | 
            +
                        
         | 
| 278 | 
            +
                        # Clean up git branch cache
         | 
| 279 | 
            +
                        current_time = time.time()
         | 
| 280 | 
            +
                        expired_keys = [
         | 
| 281 | 
            +
                            key for key, cache_time in self._git_branch_cache_time.items()
         | 
| 282 | 
            +
                            if current_time - cache_time > MAX_CACHE_AGE_SECONDS
         | 
| 283 | 
            +
                        ]
         | 
| 284 | 
            +
                        for key in expired_keys:
         | 
| 285 | 
            +
                            self._git_branch_cache.pop(key, None)
         | 
| 286 | 
            +
                            self._git_branch_cache_time.pop(key, None)
         | 
| 287 | 
            +
                        
         | 
| 288 | 
            +
                        # Force garbage collection periodically
         | 
| 289 | 
            +
                        if self.events_processed % (CLEANUP_INTERVAL_EVENTS * 10) == 0:
         | 
| 290 | 
            +
                            gc.collect()
         | 
| 291 | 
            +
                            if DEBUG:
         | 
| 292 | 
            +
                                print(f"🧹 Performed cleanup after {self.events_processed} events", file=sys.stderr)
         | 
| 293 | 
            +
                
         | 
| 294 | 
            +
                def _track_delegation(self, session_id: str, agent_type: str, request_data: dict = None):
         | 
| 295 | 
            +
                    """Track a new agent delegation with automatic cleanup."""
         | 
| 296 | 
            +
                    if session_id and agent_type and agent_type != 'unknown':
         | 
| 297 | 
            +
                        self.active_delegations[session_id] = agent_type
         | 
| 298 | 
            +
                        key = f"{session_id}:{datetime.now().timestamp()}"
         | 
| 299 | 
            +
                        self.delegation_history.append((key, agent_type))
         | 
| 300 | 
            +
                        
         | 
| 301 | 
            +
                        if request_data:
         | 
| 302 | 
            +
                            self.delegation_requests[session_id] = {
         | 
| 303 | 
            +
                                'agent_type': agent_type,
         | 
| 304 | 
            +
                                'request': request_data,
         | 
| 305 | 
            +
                                'timestamp': datetime.now().isoformat()
         | 
| 306 | 
            +
                            }
         | 
| 307 | 
            +
                
         | 
| 308 | 
            +
                def _get_delegation_agent_type(self, session_id: str) -> str:
         | 
| 309 | 
            +
                    """Get the agent type for a session's active delegation."""
         | 
| 310 | 
            +
                    if session_id and session_id in self.active_delegations:
         | 
| 311 | 
            +
                        return self.active_delegations[session_id]
         | 
| 312 | 
            +
                    
         | 
| 313 | 
            +
                    # Check recent history
         | 
| 314 | 
            +
                    if session_id:
         | 
| 315 | 
            +
                        for key, agent_type in reversed(self.delegation_history):
         | 
| 316 | 
            +
                            if key.startswith(session_id):
         | 
| 317 | 
            +
                                return agent_type
         | 
| 318 | 
            +
                    
         | 
| 319 | 
            +
                    return 'unknown'
         | 
| 320 | 
            +
                
         | 
| 321 | 
            +
                def _get_git_branch(self, working_dir: str = None) -> str:
         | 
| 322 | 
            +
                    """Get git branch with proper caching and expiration."""
         | 
| 323 | 
            +
                    if not working_dir:
         | 
| 324 | 
            +
                        working_dir = os.getcwd()
         | 
| 325 | 
            +
                    
         | 
| 326 | 
            +
                    cache_key = working_dir
         | 
| 327 | 
            +
                    current_time = time.time()
         | 
| 328 | 
            +
                    
         | 
| 329 | 
            +
                    # Check cache with expiration
         | 
| 330 | 
            +
                    if (cache_key in self._git_branch_cache and 
         | 
| 331 | 
            +
                        cache_key in self._git_branch_cache_time and
         | 
| 332 | 
            +
                        current_time - self._git_branch_cache_time[cache_key] < 30):
         | 
| 333 | 
            +
                        return self._git_branch_cache[cache_key]
         | 
| 334 | 
            +
                    
         | 
| 335 | 
            +
                    # Get git branch
         | 
| 336 | 
            +
                    try:
         | 
| 337 | 
            +
                        original_cwd = os.getcwd()
         | 
| 338 | 
            +
                        os.chdir(working_dir)
         | 
| 339 | 
            +
                        
         | 
| 340 | 
            +
                        result = subprocess.run(
         | 
| 341 | 
            +
                            ['git', 'branch', '--show-current'],
         | 
| 342 | 
            +
                            capture_output=True,
         | 
| 343 | 
            +
                            text=True,
         | 
| 344 | 
            +
                            timeout=TimeoutConfig.QUICK_TIMEOUT
         | 
| 345 | 
            +
                        )
         | 
| 346 | 
            +
                        
         | 
| 347 | 
            +
                        os.chdir(original_cwd)
         | 
| 348 | 
            +
                        
         | 
| 349 | 
            +
                        if result.returncode == 0 and result.stdout.strip():
         | 
| 350 | 
            +
                            branch = result.stdout.strip()
         | 
| 351 | 
            +
                            self._git_branch_cache[cache_key] = branch
         | 
| 352 | 
            +
                            self._git_branch_cache_time[cache_key] = current_time
         | 
| 353 | 
            +
                            return branch
         | 
| 354 | 
            +
                    except:
         | 
| 355 | 
            +
                        pass
         | 
| 356 | 
            +
                    
         | 
| 357 | 
            +
                    self._git_branch_cache[cache_key] = 'Unknown'
         | 
| 358 | 
            +
                    self._git_branch_cache_time[cache_key] = current_time
         | 
| 359 | 
            +
                    return 'Unknown'
         | 
| 360 | 
            +
                
         | 
| 361 | 
            +
                def _emit_socketio_event(self, namespace: str, event: str, data: dict):
         | 
| 362 | 
            +
                    """Emit Socket.IO event using connection pool."""
         | 
| 363 | 
            +
                    port = int(os.environ.get('CLAUDE_MPM_SOCKETIO_PORT', '8765'))
         | 
| 364 | 
            +
                    client = self.connection_pool.get_connection(port)
         | 
| 365 | 
            +
                    
         | 
| 366 | 
            +
                    if not client:
         | 
| 367 | 
            +
                        return
         | 
| 368 | 
            +
                    
         | 
| 369 | 
            +
                    try:
         | 
| 370 | 
            +
                        claude_event_data = {
         | 
| 371 | 
            +
                            'type': f'hook.{event}',
         | 
| 372 | 
            +
                            'timestamp': datetime.now().isoformat(),
         | 
| 373 | 
            +
                            'data': data
         | 
| 374 | 
            +
                        }
         | 
| 375 | 
            +
                        client.emit('claude_event', claude_event_data)
         | 
| 376 | 
            +
                    except Exception as e:
         | 
| 377 | 
            +
                        if DEBUG:
         | 
| 378 | 
            +
                            print(f"❌ Socket.IO emit failed: {e}", file=sys.stderr)
         | 
| 379 | 
            +
                
         | 
| 380 | 
            +
                def handle(self):
         | 
| 381 | 
            +
                    """Process hook event with minimal overhead."""
         | 
| 382 | 
            +
                    try:
         | 
| 383 | 
            +
                        # Perform periodic cleanup
         | 
| 384 | 
            +
                        self._periodic_cleanup()
         | 
| 385 | 
            +
                        
         | 
| 386 | 
            +
                        # Read and parse event
         | 
| 387 | 
            +
                        event = self._read_hook_event()
         | 
| 388 | 
            +
                        if not event:
         | 
| 389 | 
            +
                            self._continue_execution()
         | 
| 390 | 
            +
                            return
         | 
| 391 | 
            +
                        
         | 
| 392 | 
            +
                        # Route event to appropriate handler
         | 
| 393 | 
            +
                        self._route_event(event)
         | 
| 394 | 
            +
                        
         | 
| 395 | 
            +
                        # Always continue execution
         | 
| 396 | 
            +
                        self._continue_execution()
         | 
| 397 | 
            +
                        
         | 
| 398 | 
            +
                    except:
         | 
| 399 | 
            +
                        # Fail fast and silent
         | 
| 400 | 
            +
                        self._continue_execution()
         | 
| 401 | 
            +
                
         | 
| 402 | 
            +
                def _read_hook_event(self) -> dict:
         | 
| 403 | 
            +
                    """Read and parse hook event from stdin."""
         | 
| 404 | 
            +
                    try:
         | 
| 405 | 
            +
                        event_data = sys.stdin.read()
         | 
| 406 | 
            +
                        return json.loads(event_data)
         | 
| 407 | 
            +
                    except:
         | 
| 408 | 
            +
                        return None
         | 
| 409 | 
            +
                
         | 
| 410 | 
            +
                def _route_event(self, event: dict) -> None:
         | 
| 411 | 
            +
                    """Route event to appropriate handler based on type."""
         | 
| 412 | 
            +
                    hook_type = event.get('hook_event_name', 'unknown')
         | 
| 413 | 
            +
                    
         | 
| 414 | 
            +
                    # Simplified routing (implement actual handlers as needed)
         | 
| 415 | 
            +
                    if DEBUG:
         | 
| 416 | 
            +
                        print(f"📥 Processing {hook_type} event", file=sys.stderr)
         | 
| 417 | 
            +
                
         | 
| 418 | 
            +
                def _continue_execution(self) -> None:
         | 
| 419 | 
            +
                    """Send continue action to Claude."""
         | 
| 420 | 
            +
                    print(json.dumps({"action": "continue"}))
         | 
| 421 | 
            +
                
         | 
| 422 | 
            +
                def __del__(self):
         | 
| 423 | 
            +
                    """Cleanup when handler is destroyed."""
         | 
| 424 | 
            +
                    if hasattr(self, 'connection_pool'):
         | 
| 425 | 
            +
                        self.connection_pool.close_all()
         | 
| 426 | 
            +
             | 
| 427 | 
            +
             | 
| 428 | 
            +
            # Global singleton instance
         | 
| 429 | 
            +
            _handler_instance = None
         | 
| 430 | 
            +
             | 
| 431 | 
            +
             | 
| 432 | 
            +
            def get_handler():
         | 
| 433 | 
            +
                """Get the singleton handler instance."""
         | 
| 434 | 
            +
                global _handler_instance
         | 
| 435 | 
            +
                if _handler_instance is None:
         | 
| 436 | 
            +
                    _handler_instance = ClaudeHookHandler()
         | 
| 437 | 
            +
                return _handler_instance
         | 
| 438 | 
            +
             | 
| 439 | 
            +
             | 
| 440 | 
            +
            def main():
         | 
| 441 | 
            +
                """Entry point with proper singleton usage."""
         | 
| 442 | 
            +
                try:
         | 
| 443 | 
            +
                    handler = get_handler()
         | 
| 444 | 
            +
                    handler.handle()
         | 
| 445 | 
            +
                except Exception as e:
         | 
| 446 | 
            +
                    # Always output continue action to not block Claude
         | 
| 447 | 
            +
                    print(json.dumps({"action": "continue"}))
         | 
| 448 | 
            +
                    if DEBUG:
         | 
| 449 | 
            +
                        print(f"Hook handler error: {e}", file=sys.stderr)
         | 
| 450 | 
            +
                    sys.exit(0)
         | 
| 451 | 
            +
             | 
| 452 | 
            +
             | 
| 453 | 
            +
            if __name__ == "__main__":
         | 
| 454 | 
            +
                main()
         | 
    
        claude_mpm/utils/paths.py
    CHANGED
    
    | @@ -5,6 +5,7 @@ to avoid duplication across the codebase. | |
| 5 5 | 
             
            """
         | 
| 6 6 |  | 
| 7 7 | 
             
            import os
         | 
| 8 | 
            +
            import sys
         | 
| 8 9 | 
             
            from pathlib import Path
         | 
| 9 10 | 
             
            from typing import Optional, Union, List
         | 
| 10 11 | 
             
            from functools import lru_cache
         | 
| @@ -45,11 +46,17 @@ class PathResolver: | |
| 45 46 | 
             
                    try:
         | 
| 46 47 | 
             
                        import claude_mpm
         | 
| 47 48 | 
             
                        module_path = Path(claude_mpm.__file__).parent
         | 
| 48 | 
            -
                         | 
| 49 | 
            +
                        
         | 
| 50 | 
            +
                        # Check if we're in a development environment (has src/ directory)
         | 
| 49 51 | 
             
                        if module_path.parent.name == 'src':
         | 
| 52 | 
            +
                            # Development structure: project_root/src/claude_mpm/
         | 
| 50 53 | 
             
                            return module_path.parent.parent
         | 
| 51 | 
            -
                         | 
| 54 | 
            +
                        
         | 
| 55 | 
            +
                        # Check if we're in an installed environment (site-packages)
         | 
| 56 | 
            +
                        # In this case, the module itself IS the framework root for resources
         | 
| 57 | 
            +
                        # No src/ directory exists in installed packages
         | 
| 52 58 | 
             
                        return module_path
         | 
| 59 | 
            +
                        
         | 
| 53 60 | 
             
                    except ImportError:
         | 
| 54 61 | 
             
                        pass
         | 
| 55 62 |  | 
| @@ -78,22 +85,50 @@ class PathResolver: | |
| 78 85 | 
             
                    Raises:
         | 
| 79 86 | 
             
                        FileNotFoundError: If agents directory doesn't exist
         | 
| 80 87 | 
             
                    """
         | 
| 88 | 
            +
                    # Try using importlib.resources first (Python 3.9+)
         | 
| 89 | 
            +
                    try:
         | 
| 90 | 
            +
                        if sys.version_info >= (3, 9):
         | 
| 91 | 
            +
                            from importlib.resources import files
         | 
| 92 | 
            +
                            agents_path = files('claude_mpm').joinpath('agents')
         | 
| 93 | 
            +
                            if agents_path.exists():
         | 
| 94 | 
            +
                                return Path(str(agents_path))
         | 
| 95 | 
            +
                        else:
         | 
| 96 | 
            +
                            # For Python 3.7-3.8, use the backport approach
         | 
| 97 | 
            +
                            from importlib import resources
         | 
| 98 | 
            +
                            with resources.path('claude_mpm', 'agents') as agents_path:
         | 
| 99 | 
            +
                                if agents_path.exists():
         | 
| 100 | 
            +
                                    return agents_path
         | 
| 101 | 
            +
                    except (ImportError, ModuleNotFoundError, TypeError):
         | 
| 102 | 
            +
                        # Fall back to manual detection if importlib.resources fails
         | 
| 103 | 
            +
                        pass
         | 
| 104 | 
            +
                    
         | 
| 105 | 
            +
                    # Fallback to manual detection
         | 
| 81 106 | 
             
                    framework_root = cls.get_framework_root()
         | 
| 82 107 |  | 
| 83 | 
            -
                    #  | 
| 84 | 
            -
                     | 
| 108 | 
            +
                    # First check if we're in development structure (framework_root is project root)
         | 
| 109 | 
            +
                    # This happens when framework_root has a src/ directory
         | 
| 110 | 
            +
                    if (framework_root / 'src').exists():
         | 
| 111 | 
            +
                        agents_dir = framework_root / 'src' / 'claude_mpm' / 'agents'
         | 
| 112 | 
            +
                        if agents_dir.exists():
         | 
| 113 | 
            +
                            return agents_dir
         | 
| 114 | 
            +
                    
         | 
| 115 | 
            +
                    # Otherwise we're in installed structure (framework_root is claude_mpm package)
         | 
| 116 | 
            +
                    # In pip/pipx installs, framework_root already points to claude_mpm package
         | 
| 117 | 
            +
                    agents_dir = framework_root / 'agents'
         | 
| 85 118 | 
             
                    if agents_dir.exists():
         | 
| 86 119 | 
             
                        return agents_dir
         | 
| 87 120 |  | 
| 88 | 
            -
                    #  | 
| 121 | 
            +
                    # Last fallback for edge cases
         | 
| 89 122 | 
             
                    agents_dir = framework_root / 'claude_mpm' / 'agents'
         | 
| 90 123 | 
             
                    if agents_dir.exists():
         | 
| 91 124 | 
             
                        return agents_dir
         | 
| 92 125 |  | 
| 93 126 | 
             
                    raise FileNotFoundError(
         | 
| 94 127 | 
             
                        f"Agents directory not found. Searched in:\n"
         | 
| 95 | 
            -
                        f"  -  | 
| 96 | 
            -
                        f"  - {framework_root / 'claude_mpm' / 'agents'}"
         | 
| 128 | 
            +
                        f"  - importlib.resources lookup\n"
         | 
| 129 | 
            +
                        f"  - {framework_root / 'src' / 'claude_mpm' / 'agents'} (development)\n"
         | 
| 130 | 
            +
                        f"  - {framework_root / 'agents'} (installed)\n"
         | 
| 131 | 
            +
                        f"  - {framework_root / 'claude_mpm' / 'agents'} (fallback)"
         | 
| 97 132 | 
             
                    )
         | 
| 98 133 |  | 
| 99 134 | 
             
                @classmethod
         | 
| @@ -260,6 +295,70 @@ class PathResolver: | |
| 260 295 | 
             
                    logger.debug(f"Found {len(matches)} files matching '{pattern}' in {root}")
         | 
| 261 296 | 
             
                    return matches
         | 
| 262 297 |  | 
| 298 | 
            +
                @classmethod
         | 
| 299 | 
            +
                def get_package_resource_path(cls, resource_path: str) -> Path:
         | 
| 300 | 
            +
                    """Get the path to a resource within the claude_mpm package.
         | 
| 301 | 
            +
                    
         | 
| 302 | 
            +
                    This method handles both development and installed environments correctly.
         | 
| 303 | 
            +
                    
         | 
| 304 | 
            +
                    Args:
         | 
| 305 | 
            +
                        resource_path: Relative path within the claude_mpm package (e.g., 'agents/templates')
         | 
| 306 | 
            +
                        
         | 
| 307 | 
            +
                    Returns:
         | 
| 308 | 
            +
                        Path: Full path to the resource
         | 
| 309 | 
            +
                        
         | 
| 310 | 
            +
                    Raises:
         | 
| 311 | 
            +
                        FileNotFoundError: If the resource doesn't exist
         | 
| 312 | 
            +
                    """
         | 
| 313 | 
            +
                    # Try using importlib.resources first (most reliable for installed packages)
         | 
| 314 | 
            +
                    try:
         | 
| 315 | 
            +
                        if sys.version_info >= (3, 9):
         | 
| 316 | 
            +
                            from importlib.resources import files
         | 
| 317 | 
            +
                            resource = files('claude_mpm').joinpath(resource_path)
         | 
| 318 | 
            +
                            if resource.exists():
         | 
| 319 | 
            +
                                return Path(str(resource))
         | 
| 320 | 
            +
                        else:
         | 
| 321 | 
            +
                            # For Python 3.7-3.8
         | 
| 322 | 
            +
                            from importlib import resources
         | 
| 323 | 
            +
                            parts = resource_path.split('/')
         | 
| 324 | 
            +
                            if len(parts) == 1:
         | 
| 325 | 
            +
                                with resources.path('claude_mpm', parts[0]) as p:
         | 
| 326 | 
            +
                                    if p.exists():
         | 
| 327 | 
            +
                                        return p
         | 
| 328 | 
            +
                            else:
         | 
| 329 | 
            +
                                # For nested paths, navigate step by step
         | 
| 330 | 
            +
                                package = 'claude_mpm'
         | 
| 331 | 
            +
                                for part in parts[:-1]:
         | 
| 332 | 
            +
                                    package = f"{package}.{part}"
         | 
| 333 | 
            +
                                with resources.path(package, parts[-1]) as p:
         | 
| 334 | 
            +
                                    if p.exists():
         | 
| 335 | 
            +
                                        return p
         | 
| 336 | 
            +
                    except (ImportError, ModuleNotFoundError, TypeError, AttributeError):
         | 
| 337 | 
            +
                        # Fall back to file system detection
         | 
| 338 | 
            +
                        pass
         | 
| 339 | 
            +
                    
         | 
| 340 | 
            +
                    # Fallback: Use file system detection
         | 
| 341 | 
            +
                    import claude_mpm
         | 
| 342 | 
            +
                    module_path = Path(claude_mpm.__file__).parent
         | 
| 343 | 
            +
                    resource = module_path / resource_path
         | 
| 344 | 
            +
                    
         | 
| 345 | 
            +
                    if resource.exists():
         | 
| 346 | 
            +
                        return resource
         | 
| 347 | 
            +
                    
         | 
| 348 | 
            +
                    # Try with framework root
         | 
| 349 | 
            +
                    framework_root = cls.get_framework_root()
         | 
| 350 | 
            +
                    if (framework_root / 'src').exists():
         | 
| 351 | 
            +
                        # Development environment
         | 
| 352 | 
            +
                        resource = framework_root / 'src' / 'claude_mpm' / resource_path
         | 
| 353 | 
            +
                    else:
         | 
| 354 | 
            +
                        # Installed environment
         | 
| 355 | 
            +
                        resource = framework_root / resource_path
         | 
| 356 | 
            +
                    
         | 
| 357 | 
            +
                    if resource.exists():
         | 
| 358 | 
            +
                        return resource
         | 
| 359 | 
            +
                    
         | 
| 360 | 
            +
                    raise FileNotFoundError(f"Resource not found: {resource_path}")
         | 
| 361 | 
            +
                
         | 
| 263 362 | 
             
                @classmethod
         | 
| 264 363 | 
             
                def clear_cache(cls):
         | 
| 265 364 | 
             
                    """Clear all cached path lookups.
         | 
| @@ -288,4 +387,9 @@ def get_project_root() -> Path: | |
| 288 387 |  | 
| 289 388 | 
             
            def find_file_upwards(filename: str, start_path: Optional[Path] = None) -> Optional[Path]:
         | 
| 290 389 | 
             
                """Search for a file by traversing up the directory tree."""
         | 
| 291 | 
            -
                return PathResolver.find_file_upwards(filename, start_path)
         | 
| 390 | 
            +
                return PathResolver.find_file_upwards(filename, start_path)
         | 
| 391 | 
            +
             | 
| 392 | 
            +
             | 
| 393 | 
            +
            def get_package_resource_path(resource_path: str) -> Path:
         | 
| 394 | 
            +
                """Get the path to a resource within the claude_mpm package."""
         | 
| 395 | 
            +
                return PathResolver.get_package_resource_path(resource_path)
         |