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.
@@ -21,6 +21,7 @@ import time
21
21
  import asyncio
22
22
  from pathlib import Path
23
23
  from collections import deque
24
+ import threading
24
25
 
25
26
  # Import constants for configuration
26
27
  try:
@@ -87,6 +88,102 @@ except ImportError:
87
88
  # No fallback needed - we only use Socket.IO now
88
89
 
89
90
 
91
+
92
+ class SocketIOConnectionPool:
93
+ """Connection pool for Socket.IO clients to prevent connection leaks."""
94
+
95
+ def __init__(self, max_connections=3):
96
+ self.max_connections = max_connections
97
+ self.connections = []
98
+ self.last_cleanup = time.time()
99
+
100
+ def get_connection(self, port):
101
+ """Get or create a connection to the specified port."""
102
+ if time.time() - self.last_cleanup > 60:
103
+ self._cleanup_dead_connections()
104
+ self.last_cleanup = time.time()
105
+
106
+ for conn in self.connections:
107
+ if conn.get('port') == port and conn.get('client'):
108
+ client = conn['client']
109
+ if self._is_connection_alive(client):
110
+ return client
111
+ else:
112
+ self.connections.remove(conn)
113
+
114
+ if len(self.connections) < self.max_connections:
115
+ client = self._create_connection(port)
116
+ if client:
117
+ self.connections.append({
118
+ 'port': port,
119
+ 'client': client,
120
+ 'created': time.time()
121
+ })
122
+ return client
123
+
124
+ if self.connections:
125
+ oldest = min(self.connections, key=lambda x: x['created'])
126
+ self._close_connection(oldest['client'])
127
+ oldest['client'] = self._create_connection(port)
128
+ oldest['port'] = port
129
+ oldest['created'] = time.time()
130
+ return oldest['client']
131
+
132
+ return None
133
+
134
+ def _create_connection(self, port):
135
+ """Create a new Socket.IO connection."""
136
+ if not SOCKETIO_AVAILABLE:
137
+ return None
138
+ try:
139
+ client = socketio.Client(
140
+ reconnection=False, # Disable auto-reconnect
141
+ logger=False,
142
+ engineio_logger=False
143
+ )
144
+ client.connect(f'http://localhost:{port}',
145
+ wait=True,
146
+ wait_timeout=NetworkConfig.SOCKET_WAIT_TIMEOUT)
147
+ if client.connected:
148
+ return client
149
+ except Exception:
150
+ pass
151
+ return None
152
+
153
+ def _is_connection_alive(self, client):
154
+ """Check if a connection is still alive."""
155
+ try:
156
+ return client and client.connected
157
+ except:
158
+ return False
159
+
160
+ def _close_connection(self, client):
161
+ """Safely close a connection."""
162
+ try:
163
+ if client:
164
+ client.disconnect()
165
+ except:
166
+ pass
167
+
168
+ def _cleanup_dead_connections(self):
169
+ """Remove dead connections from the pool."""
170
+ self.connections = [
171
+ conn for conn in self.connections
172
+ if self._is_connection_alive(conn.get('client'))
173
+ ]
174
+
175
+ def close_all(self):
176
+ """Close all connections in the pool."""
177
+ for conn in self.connections:
178
+ self._close_connection(conn.get('client'))
179
+ self.connections.clear()
180
+
181
+
182
+ # Global singleton handler instance
183
+ _global_handler = None
184
+ _handler_lock = threading.Lock()
185
+
186
+
90
187
  class ClaudeHookHandler:
91
188
  """Optimized hook handler with direct Socket.IO client.
92
189
 
@@ -99,8 +196,17 @@ class ClaudeHookHandler:
99
196
 
100
197
  def __init__(self):
101
198
  # Socket.IO client (persistent if possible)
102
- self.sio_client = None
103
- self.sio_connected = False
199
+ self.connection_pool = SocketIOConnectionPool(max_connections=3)
200
+ # Track events for periodic cleanup
201
+ self.events_processed = 0
202
+ self.last_cleanup = time.time()
203
+
204
+ # Maximum sizes for tracking
205
+ self.MAX_DELEGATION_TRACKING = 200
206
+ self.MAX_PROMPT_TRACKING = 100
207
+ self.MAX_CACHE_AGE_SECONDS = 300
208
+ self.CLEANUP_INTERVAL_EVENTS = 100
209
+
104
210
 
105
211
  # Agent delegation tracking
106
212
  # Store recent Task delegations: session_id -> agent_type
@@ -179,6 +285,36 @@ class ClaudeHookHandler:
179
285
  if key in self.delegation_requests:
180
286
  del self.delegation_requests[key]
181
287
 
288
+
289
+ def _cleanup_old_entries(self):
290
+ """Clean up old entries to prevent memory growth."""
291
+ cutoff_time = datetime.now().timestamp() - self.MAX_CACHE_AGE_SECONDS
292
+
293
+ # Clean up delegation tracking dictionaries
294
+ for storage in [self.active_delegations, self.delegation_requests]:
295
+ if len(storage) > self.MAX_DELEGATION_TRACKING:
296
+ # Keep only the most recent entries
297
+ sorted_keys = sorted(storage.keys())
298
+ excess = len(storage) - self.MAX_DELEGATION_TRACKING
299
+ for key in sorted_keys[:excess]:
300
+ del storage[key]
301
+
302
+ # Clean up pending prompts
303
+ if len(self.pending_prompts) > self.MAX_PROMPT_TRACKING:
304
+ sorted_keys = sorted(self.pending_prompts.keys())
305
+ excess = len(self.pending_prompts) - self.MAX_PROMPT_TRACKING
306
+ for key in sorted_keys[:excess]:
307
+ del self.pending_prompts[key]
308
+
309
+ # Clean up git branch cache
310
+ expired_keys = [
311
+ key for key, cache_time in self._git_branch_cache_time.items()
312
+ if datetime.now().timestamp() - cache_time > self.MAX_CACHE_AGE_SECONDS
313
+ ]
314
+ for key in expired_keys:
315
+ self._git_branch_cache.pop(key, None)
316
+ self._git_branch_cache_time.pop(key, None)
317
+
182
318
  def _get_delegation_agent_type(self, session_id: str) -> str:
183
319
  """Get the agent type for a session's active delegation."""
184
320
  # First try exact session match
@@ -455,97 +591,6 @@ class ClaudeHookHandler:
455
591
  self._git_branch_cache_time[cache_key] = current_time
456
592
  return 'Unknown'
457
593
 
458
- def _get_socketio_client(self):
459
- """Get or create Socket.IO client with improved reliability.
460
-
461
- WHY improved approach:
462
- - Implements retry logic with exponential backoff
463
- - Properly tests connection before returning
464
- - Ensures connection persists across events
465
- - Better error handling and recovery
466
- """
467
- if not SOCKETIO_AVAILABLE:
468
- return None
469
-
470
- # Check if we have a connected client
471
- if self.sio_client and self.sio_connected:
472
- try:
473
- # Test if still connected
474
- if self.sio_client.connected:
475
- return self.sio_client
476
- else:
477
- # Connection lost, clear it
478
- if DEBUG:
479
- print("Hook handler: Socket.IO connection lost, reconnecting...", file=sys.stderr)
480
- self.sio_connected = False
481
- except:
482
- self.sio_connected = False
483
-
484
- # Need to create or reconnect client
485
- port = int(os.environ.get('CLAUDE_MPM_SOCKETIO_PORT', '8765'))
486
- max_retries = RetryConfig.MAX_RETRIES
487
- retry_delay = RetryConfig.INITIAL_RETRY_DELAY
488
-
489
- for attempt in range(max_retries):
490
- try:
491
- # Clean up old client if exists
492
- if self.sio_client and not self.sio_connected:
493
- try:
494
- self.sio_client.disconnect()
495
- except:
496
- pass
497
- self.sio_client = None
498
-
499
- # Create new client
500
- self.sio_client = socketio.Client(
501
- reconnection=True, # Enable auto-reconnection
502
- reconnection_attempts=3,
503
- reconnection_delay=NetworkConfig.RECONNECTION_DELAY,
504
- reconnection_delay_max=2,
505
- logger=False,
506
- engineio_logger=False
507
- )
508
-
509
- # Try to connect with proper wait
510
- self.sio_client.connect(
511
- f'http://localhost:{port}',
512
- wait=True,
513
- wait_timeout=NetworkConfig.SOCKET_WAIT_TIMEOUT
514
- )
515
-
516
- # Verify connection
517
- if self.sio_client.connected:
518
- self.sio_connected = True
519
- if DEBUG:
520
- print(f"Hook handler: Successfully connected to Socket.IO server on port {port} (attempt {attempt + 1})", file=sys.stderr)
521
- return self.sio_client
522
-
523
- except Exception as e:
524
- if DEBUG and attempt == max_retries - 1:
525
- print(f"Hook handler: Failed to connect to Socket.IO after {max_retries} attempts: {e}", file=sys.stderr)
526
- elif DEBUG:
527
- print(f"Hook handler: Connection attempt {attempt + 1} failed, retrying...", file=sys.stderr)
528
-
529
- # Exponential backoff with async delay
530
- if attempt < max_retries - 1:
531
- # Use asyncio.sleep if in async context, otherwise fall back to time.sleep
532
- try:
533
- loop = asyncio.get_event_loop()
534
- if loop.is_running():
535
- # We're in an async context, use async sleep
536
- asyncio.create_task(asyncio.sleep(retry_delay))
537
- else:
538
- # Sync context, use regular sleep
539
- time.sleep(retry_delay)
540
- except:
541
- # Fallback to sync sleep if asyncio not available
542
- time.sleep(retry_delay)
543
- retry_delay *= 2 # Double the delay for next attempt
544
-
545
- # All attempts failed
546
- self.sio_client = None
547
- self.sio_connected = False
548
- return None
549
594
 
550
595
  def handle(self):
551
596
  """Process hook event with minimal overhead and zero blocking delays.
@@ -565,6 +610,13 @@ class ClaudeHookHandler:
565
610
  self._continue_execution()
566
611
  return
567
612
 
613
+ # Increment event counter and perform periodic cleanup
614
+ self.events_processed += 1
615
+ if self.events_processed % self.CLEANUP_INTERVAL_EVENTS == 0:
616
+ self._cleanup_old_entries()
617
+ if DEBUG:
618
+ print(f"🧹 Performed cleanup after {self.events_processed} events", file=sys.stderr)
619
+
568
620
  # Route event to appropriate handler
569
621
  self._route_event(event)
570
622
 
@@ -647,7 +699,8 @@ class ClaudeHookHandler:
647
699
  # The daemon should be running when manager is active
648
700
 
649
701
  # Get Socket.IO client
650
- client = self._get_socketio_client()
702
+ port = int(os.environ.get('CLAUDE_MPM_SOCKETIO_PORT', '8765'))
703
+ client = self.connection_pool.get_connection(port)
651
704
  if not client:
652
705
  if DEBUG:
653
706
  print(f"Hook handler: No Socket.IO client available for event: hook.{event}", file=sys.stderr)
@@ -680,22 +733,18 @@ class ClaudeHookHandler:
680
733
  print(f"✅ Successfully emitted Socket.IO event: hook.{event}", file=sys.stderr)
681
734
  else:
682
735
  print(f"⚠️ Event emitted but connection status uncertain: hook.{event}", file=sys.stderr)
683
- self.sio_connected = False # Force reconnection next time
684
736
 
685
737
  except Exception as e:
686
738
  if DEBUG:
687
739
  print(f"❌ Socket.IO emit failed for hook.{event}: {e}", file=sys.stderr)
688
- # Mark as disconnected so next call will reconnect
689
- self.sio_connected = False
690
740
 
691
741
  # Try to reconnect immediately for critical events
692
742
  if event in ['subagent_stop', 'pre_tool']:
693
743
  if DEBUG:
694
744
  print(f"Hook handler: Attempting immediate reconnection for critical event: hook.{event}", file=sys.stderr)
695
- # Clear the client to force reconnection
696
- self.sio_client = None
697
745
  # Try to get a new client and emit again
698
- retry_client = self._get_socketio_client()
746
+ retry_port = int(os.environ.get('CLAUDE_MPM_SOCKETIO_PORT', '8765'))
747
+ retry_client = self.connection_pool.get_connection(retry_port)
699
748
  if retry_client:
700
749
  try:
701
750
  retry_client.emit('claude_event', claude_event_data)
@@ -1667,18 +1716,31 @@ class ClaudeHookHandler:
1667
1716
  # Don't fail the delegation result - memory is optional
1668
1717
 
1669
1718
  def __del__(self):
1670
- """Cleanup Socket.IO client on handler destruction."""
1671
- if self.sio_client and self.sio_connected:
1719
+ """Cleanup Socket.IO connections on handler destruction."""
1720
+ if hasattr(self, 'connection_pool') and self.connection_pool:
1672
1721
  try:
1673
- self.sio_client.disconnect()
1722
+ self.connection_pool.close_all()
1674
1723
  except:
1675
1724
  pass
1676
1725
 
1677
1726
 
1678
1727
  def main():
1679
- """Entry point with comprehensive error handling."""
1728
+ """Entry point with singleton pattern to prevent multiple instances."""
1729
+ global _global_handler
1730
+
1680
1731
  try:
1681
- handler = ClaudeHookHandler()
1732
+ # Use singleton pattern to prevent creating multiple instances
1733
+ with _handler_lock:
1734
+ if _global_handler is None:
1735
+ _global_handler = ClaudeHookHandler()
1736
+ if DEBUG:
1737
+ print(f"✅ Created new ClaudeHookHandler singleton (pid: {os.getpid()})", file=sys.stderr)
1738
+ else:
1739
+ if DEBUG:
1740
+ print(f"♻️ Reusing existing ClaudeHookHandler singleton (pid: {os.getpid()})", file=sys.stderr)
1741
+
1742
+ handler = _global_handler
1743
+
1682
1744
  handler.handle()
1683
1745
  except Exception as e:
1684
1746
  # Always output continue action to not block Claude