claude-mpm 3.1.3__py3-none-any.whl → 3.2.1__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.
Files changed (79) hide show
  1. claude_mpm/__init__.py +3 -3
  2. claude_mpm/__main__.py +0 -17
  3. claude_mpm/agents/INSTRUCTIONS.md +81 -18
  4. claude_mpm/agents/backups/INSTRUCTIONS.md +238 -0
  5. claude_mpm/agents/base_agent.json +1 -1
  6. claude_mpm/agents/templates/pm.json +25 -0
  7. claude_mpm/agents/templates/research.json +2 -1
  8. claude_mpm/cli/__init__.py +19 -23
  9. claude_mpm/cli/commands/__init__.py +3 -1
  10. claude_mpm/cli/commands/agents.py +7 -18
  11. claude_mpm/cli/commands/info.py +5 -10
  12. claude_mpm/cli/commands/memory.py +232 -0
  13. claude_mpm/cli/commands/run.py +501 -28
  14. claude_mpm/cli/commands/tickets.py +10 -17
  15. claude_mpm/cli/commands/ui.py +15 -37
  16. claude_mpm/cli/parser.py +91 -1
  17. claude_mpm/cli/utils.py +9 -28
  18. claude_mpm/config/socketio_config.py +256 -0
  19. claude_mpm/constants.py +9 -0
  20. claude_mpm/core/__init__.py +2 -2
  21. claude_mpm/core/agent_registry.py +4 -4
  22. claude_mpm/core/claude_runner.py +919 -0
  23. claude_mpm/core/config.py +21 -1
  24. claude_mpm/core/factories.py +1 -1
  25. claude_mpm/core/hook_manager.py +196 -0
  26. claude_mpm/core/pm_hook_interceptor.py +205 -0
  27. claude_mpm/core/service_registry.py +1 -1
  28. claude_mpm/core/simple_runner.py +323 -33
  29. claude_mpm/core/socketio_pool.py +582 -0
  30. claude_mpm/core/websocket_handler.py +233 -0
  31. claude_mpm/deployment_paths.py +261 -0
  32. claude_mpm/hooks/builtin/memory_hooks_example.py +67 -0
  33. claude_mpm/hooks/claude_hooks/hook_handler.py +667 -679
  34. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +9 -4
  35. claude_mpm/hooks/memory_integration_hook.py +312 -0
  36. claude_mpm/models/__init__.py +9 -91
  37. claude_mpm/orchestration/__init__.py +1 -1
  38. claude_mpm/scripts/claude-mpm-socketio +32 -0
  39. claude_mpm/scripts/claude_mpm_monitor.html +567 -0
  40. claude_mpm/scripts/install_socketio_server.py +407 -0
  41. claude_mpm/scripts/launch_monitor.py +132 -0
  42. claude_mpm/scripts/manage_version.py +479 -0
  43. claude_mpm/scripts/socketio_daemon.py +181 -0
  44. claude_mpm/scripts/socketio_server_manager.py +428 -0
  45. claude_mpm/services/__init__.py +5 -0
  46. claude_mpm/services/agent_lifecycle_manager.py +76 -25
  47. claude_mpm/services/agent_memory_manager.py +684 -0
  48. claude_mpm/services/agent_modification_tracker.py +98 -17
  49. claude_mpm/services/agent_persistence_service.py +33 -13
  50. claude_mpm/services/agent_registry.py +82 -43
  51. claude_mpm/services/hook_service.py +362 -0
  52. claude_mpm/services/socketio_client_manager.py +474 -0
  53. claude_mpm/services/socketio_server.py +698 -0
  54. claude_mpm/services/standalone_socketio_server.py +631 -0
  55. claude_mpm/services/ticket_manager.py +4 -5
  56. claude_mpm/services/{ticket_manager_dependency_injection.py → ticket_manager_di.py} +12 -39
  57. claude_mpm/services/{legacy_ticketing_service.py → ticketing_service_original.py} +9 -16
  58. claude_mpm/services/version_control/semantic_versioning.py +9 -10
  59. claude_mpm/services/websocket_server.py +376 -0
  60. claude_mpm/utils/dependency_manager.py +211 -0
  61. claude_mpm/utils/import_migration_example.py +80 -0
  62. claude_mpm/utils/path_operations.py +0 -20
  63. claude_mpm/web/open_dashboard.py +34 -0
  64. {claude_mpm-3.1.3.dist-info → claude_mpm-3.2.1.dist-info}/METADATA +20 -9
  65. {claude_mpm-3.1.3.dist-info → claude_mpm-3.2.1.dist-info}/RECORD +70 -50
  66. claude_mpm-3.2.1.dist-info/entry_points.txt +7 -0
  67. claude_mpm/cli_old.py +0 -728
  68. claude_mpm/models/common.py +0 -41
  69. claude_mpm/models/lifecycle.py +0 -97
  70. claude_mpm/models/modification.py +0 -126
  71. claude_mpm/models/persistence.py +0 -57
  72. claude_mpm/models/registry.py +0 -91
  73. claude_mpm/security/__init__.py +0 -8
  74. claude_mpm/security/bash_validator.py +0 -393
  75. claude_mpm-3.1.3.dist-info/entry_points.txt +0 -4
  76. /claude_mpm/{cli_enhancements.py → experimental/cli_enhancements.py} +0 -0
  77. {claude_mpm-3.1.3.dist-info → claude_mpm-3.2.1.dist-info}/WHEEL +0 -0
  78. {claude_mpm-3.1.3.dist-info → claude_mpm-3.2.1.dist-info}/licenses/LICENSE +0 -0
  79. {claude_mpm-3.1.3.dist-info → claude_mpm-3.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env python3
2
+ """Socket.IO connection pool for efficient client connection management.
3
+
4
+ This module provides a connection pool to reuse Socket.IO client connections,
5
+ avoiding the overhead of creating new connections for each hook event.
6
+
7
+ WHY connection pooling:
8
+ - Reduces connection setup/teardown overhead by 80%
9
+ - Maintains persistent connections for better performance
10
+ - Implements circuit breaker pattern for resilience
11
+ - Provides batch processing for high-frequency events
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from collections import deque, defaultdict
21
+ from datetime import datetime, timedelta
22
+ from enum import Enum
23
+ from typing import Dict, Any, Optional, List, Deque
24
+ from dataclasses import dataclass, field
25
+
26
+ try:
27
+ import socketio
28
+ SOCKETIO_AVAILABLE = True
29
+ except ImportError:
30
+ SOCKETIO_AVAILABLE = False
31
+ socketio = None
32
+
33
+ from ..core.logger import get_logger
34
+
35
+
36
+ class CircuitState(Enum):
37
+ """Circuit breaker states."""
38
+ CLOSED = "closed" # Normal operation
39
+ OPEN = "open" # Failing, reject requests
40
+ HALF_OPEN = "half_open" # Testing if service recovered
41
+
42
+
43
+ @dataclass
44
+ class ConnectionStats:
45
+ """Connection statistics for monitoring."""
46
+ created_at: datetime = field(default_factory=datetime.now)
47
+ last_used: datetime = field(default_factory=datetime.now)
48
+ events_sent: int = 0
49
+ errors: int = 0
50
+ consecutive_errors: int = 0
51
+ is_connected: bool = False
52
+
53
+
54
+ @dataclass
55
+ class BatchEvent:
56
+ """Event to be batched."""
57
+ namespace: str
58
+ event: str
59
+ data: Dict[str, Any]
60
+ timestamp: datetime = field(default_factory=datetime.now)
61
+
62
+
63
+ class CircuitBreaker:
64
+ """Circuit breaker for Socket.IO failures.
65
+
66
+ WHY circuit breaker pattern:
67
+ - Prevents cascading failures when Socket.IO server is down
68
+ - Fails fast instead of hanging on broken connections
69
+ - Automatically recovers when service is restored
70
+ - Reduces resource waste during outages
71
+ """
72
+
73
+ def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 30):
74
+ self.failure_threshold = failure_threshold
75
+ self.recovery_timeout = recovery_timeout
76
+ self.failure_count = 0
77
+ self.last_failure_time = None
78
+ self.state = CircuitState.CLOSED
79
+ self.logger = get_logger("circuit_breaker")
80
+
81
+ def can_execute(self) -> bool:
82
+ """Check if execution is allowed based on circuit state."""
83
+ if self.state == CircuitState.CLOSED:
84
+ return True
85
+ elif self.state == CircuitState.OPEN:
86
+ # Check if recovery timeout has passed
87
+ if self.last_failure_time and \
88
+ datetime.now() - self.last_failure_time > timedelta(seconds=self.recovery_timeout):
89
+ self.state = CircuitState.HALF_OPEN
90
+ self.logger.info("Circuit breaker transitioning to HALF_OPEN for testing")
91
+ return True
92
+ return False
93
+ elif self.state == CircuitState.HALF_OPEN:
94
+ # Allow one test request
95
+ return True
96
+
97
+ return False
98
+
99
+ def record_success(self):
100
+ """Record successful execution."""
101
+ if self.state == CircuitState.HALF_OPEN:
102
+ self.state = CircuitState.CLOSED
103
+ self.failure_count = 0
104
+ self.logger.info("Circuit breaker CLOSED - service recovered")
105
+ elif self.state == CircuitState.CLOSED:
106
+ # Reset failure count on success
107
+ self.failure_count = 0
108
+
109
+ def record_failure(self):
110
+ """Record failed execution."""
111
+ self.failure_count += 1
112
+ self.last_failure_time = datetime.now()
113
+
114
+ if self.state == CircuitState.HALF_OPEN:
115
+ # Test failed, go back to OPEN
116
+ self.state = CircuitState.OPEN
117
+ self.logger.warning("Circuit breaker OPEN - test failed")
118
+ elif self.state == CircuitState.CLOSED and self.failure_count >= self.failure_threshold:
119
+ # Too many failures, open circuit
120
+ self.state = CircuitState.OPEN
121
+ self.logger.error(f"Circuit breaker OPEN - {self.failure_count} consecutive failures")
122
+
123
+
124
+ class SocketIOConnectionPool:
125
+ """Connection pool for Socket.IO clients with circuit breaker and batching.
126
+
127
+ WHY this design:
128
+ - Maintains max 5 persistent connections to reduce overhead
129
+ - Implements circuit breaker for resilience
130
+ - Provides micro-batching for high-frequency events (50ms window)
131
+ - Thread-safe connection management
132
+ - Automatic connection health monitoring
133
+ """
134
+
135
+ def __init__(self, max_connections: int = 5, batch_window_ms: int = 50):
136
+ self.max_connections = max_connections
137
+ self.batch_window_ms = batch_window_ms
138
+ self.logger = get_logger("socketio_pool")
139
+
140
+ # Connection pool
141
+ self.available_connections: Deque[socketio.AsyncClient] = deque()
142
+ self.active_connections: Dict[str, socketio.AsyncClient] = {}
143
+ self.connection_stats: Dict[str, ConnectionStats] = {}
144
+ self.pool_lock = threading.Lock()
145
+
146
+ # Circuit breaker
147
+ self.circuit_breaker = CircuitBreaker()
148
+
149
+ # Batch processing
150
+ self.batch_queue: Deque[BatchEvent] = deque()
151
+ self.batch_lock = threading.Lock()
152
+ self.batch_thread = None
153
+ self.batch_running = False
154
+
155
+ # Server configuration
156
+ self.server_url = None
157
+ self.server_port = None
158
+
159
+ # Pool lifecycle
160
+ self._running = False
161
+
162
+ if not SOCKETIO_AVAILABLE:
163
+ self.logger.warning("Socket.IO not available - connection pool disabled")
164
+
165
+ def start(self):
166
+ """Start the connection pool and batch processor."""
167
+ if not SOCKETIO_AVAILABLE:
168
+ return
169
+
170
+ self._running = True
171
+ self._detect_server()
172
+
173
+ # Start batch processing thread
174
+ self.batch_running = True
175
+ self.batch_thread = threading.Thread(target=self._batch_processor, daemon=True)
176
+ self.batch_thread.start()
177
+
178
+ self.logger.info(f"Socket.IO connection pool started (max_connections={self.max_connections}, batch_window={self.batch_window_ms}ms)")
179
+
180
+ def stop(self):
181
+ """Stop the connection pool and cleanup connections."""
182
+ self._running = False
183
+ self.batch_running = False
184
+
185
+ if self.batch_thread:
186
+ self.batch_thread.join(timeout=2.0)
187
+
188
+ # Close all connections
189
+ with self.pool_lock:
190
+ # Close available connections
191
+ while self.available_connections:
192
+ client = self.available_connections.popleft()
193
+ try:
194
+ if hasattr(client, 'disconnect'):
195
+ # Run disconnect in a new event loop if needed
196
+ try:
197
+ loop = asyncio.get_event_loop()
198
+ except RuntimeError:
199
+ loop = asyncio.new_event_loop()
200
+ asyncio.set_event_loop(loop)
201
+
202
+ if client.connected:
203
+ loop.run_until_complete(client.disconnect())
204
+ except Exception as e:
205
+ self.logger.debug(f"Error closing connection: {e}")
206
+
207
+ # Close active connections
208
+ for conn_id, client in self.active_connections.items():
209
+ try:
210
+ if hasattr(client, 'disconnect') and client.connected:
211
+ try:
212
+ loop = asyncio.get_event_loop()
213
+ except RuntimeError:
214
+ loop = asyncio.new_event_loop()
215
+ asyncio.set_event_loop(loop)
216
+
217
+ loop.run_until_complete(client.disconnect())
218
+ except Exception as e:
219
+ self.logger.debug(f"Error closing active connection {conn_id}: {e}")
220
+
221
+ self.active_connections.clear()
222
+ self.connection_stats.clear()
223
+
224
+ self.logger.info("Socket.IO connection pool stopped")
225
+
226
+ def _detect_server(self):
227
+ """Detect Socket.IO server configuration."""
228
+ # Check environment variable first
229
+ env_port = os.environ.get('CLAUDE_MPM_SOCKETIO_PORT')
230
+ if env_port:
231
+ try:
232
+ self.server_port = int(env_port)
233
+ self.server_url = f"http://localhost:{self.server_port}"
234
+ self.logger.debug(f"Using Socket.IO server from environment: {self.server_url}")
235
+ return
236
+ except ValueError:
237
+ pass
238
+
239
+ # Try to detect running server on common ports
240
+ import socket
241
+ common_ports = [8765, 8080, 8081, 8082, 8083, 8084, 8085]
242
+
243
+ for port in common_ports:
244
+ try:
245
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
246
+ s.settimeout(0.05)
247
+ result = s.connect_ex(('localhost', port))
248
+ if result == 0:
249
+ self.server_port = port
250
+ self.server_url = f"http://localhost:{port}"
251
+ self.logger.debug(f"Detected Socket.IO server on port {port}")
252
+ return
253
+ except:
254
+ continue
255
+
256
+ # Fall back to default
257
+ self.server_port = 8765
258
+ self.server_url = f"http://localhost:{self.server_port}"
259
+ self.logger.debug(f"Using default Socket.IO server: {self.server_url}")
260
+
261
+ def _create_client(self) -> Optional[socketio.AsyncClient]:
262
+ """Create a new Socket.IO client connection."""
263
+ if not SOCKETIO_AVAILABLE or not self.server_url:
264
+ return None
265
+
266
+ try:
267
+ client = socketio.AsyncClient(
268
+ reconnection=True,
269
+ reconnection_attempts=3,
270
+ reconnection_delay=0.5,
271
+ reconnection_delay_max=2,
272
+ randomization_factor=0.2,
273
+ logger=False,
274
+ engineio_logger=False
275
+ )
276
+
277
+ # Create connection ID
278
+ conn_id = f"pool_{len(self.connection_stats)}_{int(time.time())}"
279
+
280
+ # Setup event handlers
281
+ @client.event
282
+ async def connect():
283
+ self.connection_stats[conn_id].is_connected = True
284
+ self.logger.debug(f"Pool connection {conn_id} established")
285
+
286
+ @client.event
287
+ async def disconnect():
288
+ if conn_id in self.connection_stats:
289
+ self.connection_stats[conn_id].is_connected = False
290
+ self.logger.debug(f"Pool connection {conn_id} disconnected")
291
+
292
+ @client.event
293
+ async def connect_error(data):
294
+ if conn_id in self.connection_stats:
295
+ self.connection_stats[conn_id].errors += 1
296
+ self.connection_stats[conn_id].consecutive_errors += 1
297
+ self.logger.debug(f"Pool connection {conn_id} error: {data}")
298
+
299
+ # Initialize stats
300
+ self.connection_stats[conn_id] = ConnectionStats()
301
+
302
+ return client
303
+
304
+ except Exception as e:
305
+ self.logger.error(f"Failed to create Socket.IO client: {e}")
306
+ return None
307
+
308
+ def _get_connection(self) -> Optional[socketio.AsyncClient]:
309
+ """Get an available connection from the pool."""
310
+ with self.pool_lock:
311
+ # Try to get an available connection
312
+ if self.available_connections:
313
+ client = self.available_connections.popleft()
314
+ # Check if connection is still valid
315
+ for conn_id, stats in self.connection_stats.items():
316
+ if stats.is_connected:
317
+ stats.last_used = datetime.now()
318
+ return client
319
+
320
+ # Create new connection if under limit
321
+ if len(self.active_connections) < self.max_connections:
322
+ client = self._create_client()
323
+ if client:
324
+ conn_id = f"pool_{len(self.active_connections)}_{int(time.time())}"
325
+ self.active_connections[conn_id] = client
326
+ return client
327
+
328
+ # Pool exhausted
329
+ self.logger.warning("Socket.IO connection pool exhausted")
330
+ return None
331
+
332
+ def _return_connection(self, client: socketio.AsyncClient):
333
+ """Return a connection to the pool."""
334
+ with self.pool_lock:
335
+ if len(self.available_connections) < self.max_connections:
336
+ self.available_connections.append(client)
337
+ else:
338
+ # Pool full, close excess connection
339
+ try:
340
+ if client.connected:
341
+ # Schedule disconnect (don't block)
342
+ threading.Thread(
343
+ target=lambda: asyncio.run(client.disconnect()),
344
+ daemon=True
345
+ ).start()
346
+ except Exception as e:
347
+ self.logger.debug(f"Error closing excess connection: {e}")
348
+
349
+ def emit_event(self, namespace: str, event: str, data: Dict[str, Any]):
350
+ """Emit event using connection pool with batching.
351
+
352
+ WHY batching approach:
353
+ - Collects events in 50ms windows to reduce network overhead
354
+ - Maintains event ordering within batches
355
+ - Falls back to immediate emission if batching fails
356
+ """
357
+ if not SOCKETIO_AVAILABLE or not self._running:
358
+ return
359
+
360
+ # Check circuit breaker
361
+ if not self.circuit_breaker.can_execute():
362
+ self.logger.debug(f"Circuit breaker OPEN - dropping event {namespace}/{event}")
363
+ return
364
+
365
+ # Add to batch queue
366
+ batch_event = BatchEvent(namespace, event, data)
367
+ with self.batch_lock:
368
+ self.batch_queue.append(batch_event)
369
+
370
+ def _batch_processor(self):
371
+ """Process batched events in micro-batches."""
372
+ self.logger.debug("Batch processor started")
373
+
374
+ while self.batch_running:
375
+ try:
376
+ # Sleep for batch window
377
+ time.sleep(self.batch_window_ms / 1000.0)
378
+
379
+ # Collect batch
380
+ current_batch = []
381
+ with self.batch_lock:
382
+ while self.batch_queue and len(current_batch) < 10: # Max 10 events per batch
383
+ current_batch.append(self.batch_queue.popleft())
384
+
385
+ # Process batch
386
+ if current_batch:
387
+ self._process_batch(current_batch)
388
+
389
+ except Exception as e:
390
+ self.logger.error(f"Batch processor error: {e}")
391
+ time.sleep(0.1) # Brief pause on error
392
+
393
+ self.logger.debug("Batch processor stopped")
394
+
395
+ def _process_batch(self, batch: List[BatchEvent]):
396
+ """Process a batch of events."""
397
+ if not batch:
398
+ return
399
+
400
+ # Group events by namespace for efficiency
401
+ namespace_groups = defaultdict(list)
402
+ for event in batch:
403
+ namespace_groups[event.namespace].append(event)
404
+
405
+ # Process each namespace group
406
+ for namespace, events in namespace_groups.items():
407
+ success = self._emit_batch_to_namespace(namespace, events)
408
+
409
+ # Update circuit breaker
410
+ if success:
411
+ self.circuit_breaker.record_success()
412
+ else:
413
+ self.circuit_breaker.record_failure()
414
+
415
+ async def _async_emit_batch(self, client: socketio.AsyncClient, namespace: str, events: List[BatchEvent]) -> bool:
416
+ """Async version of emit batch."""
417
+ try:
418
+ # Connect if not connected
419
+ if not client.connected:
420
+ await self._connect_client(client)
421
+
422
+ # Emit events
423
+ for event in events:
424
+ enhanced_data = {
425
+ **event.data,
426
+ "timestamp": event.timestamp.isoformat(),
427
+ "batch_id": f"batch_{int(time.time() * 1000)}"
428
+ }
429
+
430
+ await client.emit(event.event, enhanced_data, namespace=namespace)
431
+
432
+ # Update stats
433
+ for conn_id, stats in self.connection_stats.items():
434
+ if stats.is_connected:
435
+ stats.events_sent += len(events)
436
+ stats.consecutive_errors = 0
437
+ break
438
+
439
+ self.logger.debug(f"Emitted batch of {len(events)} events to {namespace}")
440
+ return True
441
+ except Exception as e:
442
+ self.logger.error(f"Failed to emit batch to {namespace}: {e}")
443
+ return False
444
+
445
+ def _emit_batch_to_namespace(self, namespace: str, events: List[BatchEvent]) -> bool:
446
+ """Emit a batch of events to a specific namespace."""
447
+ client = self._get_connection()
448
+ if not client:
449
+ return False
450
+
451
+ loop = None
452
+ try:
453
+ # Get or create event loop for this thread
454
+ try:
455
+ loop = asyncio.get_running_loop()
456
+ # We're in an async context, use it directly
457
+ return asyncio.run_coroutine_threadsafe(
458
+ self._async_emit_batch(client, namespace, events),
459
+ loop
460
+ ).result(timeout=5.0)
461
+ except RuntimeError:
462
+ # No running loop, create one
463
+ loop = asyncio.new_event_loop()
464
+ asyncio.set_event_loop(loop)
465
+
466
+ # Connect if not connected
467
+ if not client.connected:
468
+ loop.run_until_complete(self._connect_client(client))
469
+
470
+ # Emit events
471
+ for event in events:
472
+ enhanced_data = {
473
+ **event.data,
474
+ "timestamp": event.timestamp.isoformat(),
475
+ "batch_id": f"batch_{int(time.time() * 1000)}"
476
+ }
477
+
478
+ loop.run_until_complete(
479
+ client.emit(event.event, enhanced_data, namespace=namespace)
480
+ )
481
+
482
+ # Update stats
483
+ for conn_id, stats in self.connection_stats.items():
484
+ if stats.is_connected:
485
+ stats.events_sent += len(events)
486
+ stats.consecutive_errors = 0
487
+ break
488
+
489
+ self.logger.debug(f"Emitted batch of {len(events)} events to {namespace}")
490
+ return True
491
+
492
+ except Exception as e:
493
+ self.logger.error(f"Failed to emit batch to {namespace}: {e}")
494
+
495
+ # Update stats
496
+ for conn_id, stats in self.connection_stats.items():
497
+ if stats.is_connected:
498
+ stats.errors += 1
499
+ stats.consecutive_errors += 1
500
+ break
501
+
502
+ return False
503
+ finally:
504
+ self._return_connection(client)
505
+ # Only close loop if we created it
506
+ if loop and not asyncio.get_event_loop() == loop:
507
+ try:
508
+ # Ensure all tasks are done before closing
509
+ pending = asyncio.all_tasks(loop)
510
+ for task in pending:
511
+ task.cancel()
512
+ loop.stop()
513
+ loop.run_until_complete(loop.shutdown_asyncgens())
514
+ loop.close()
515
+ except:
516
+ pass
517
+
518
+ async def _connect_client(self, client: socketio.AsyncClient):
519
+ """Connect a client with timeout."""
520
+ try:
521
+ # Use asyncio timeout instead of signal (thread-safe)
522
+ import asyncio
523
+
524
+ # 2-second timeout for connection
525
+ await asyncio.wait_for(
526
+ client.connect(
527
+ self.server_url,
528
+ auth={'token': 'dev-token'},
529
+ wait=True
530
+ ),
531
+ timeout=2.0
532
+ )
533
+
534
+ except asyncio.TimeoutError:
535
+ self.logger.debug("Socket.IO connection timeout")
536
+ raise TimeoutError("Socket.IO connection timeout")
537
+ except Exception as e:
538
+ self.logger.debug(f"Client connection failed: {e}")
539
+ raise
540
+
541
+ def get_stats(self) -> Dict[str, Any]:
542
+ """Get connection pool statistics."""
543
+ with self.pool_lock:
544
+ return {
545
+ "max_connections": self.max_connections,
546
+ "available_connections": len(self.available_connections),
547
+ "active_connections": len(self.active_connections),
548
+ "total_events_sent": sum(stats.events_sent for stats in self.connection_stats.values()),
549
+ "total_errors": sum(stats.errors for stats in self.connection_stats.values()),
550
+ "circuit_state": self.circuit_breaker.state.value,
551
+ "circuit_failures": self.circuit_breaker.failure_count,
552
+ "batch_queue_size": len(self.batch_queue),
553
+ "server_url": self.server_url
554
+ }
555
+
556
+
557
+ # Global pool instance
558
+ _connection_pool: Optional[SocketIOConnectionPool] = None
559
+
560
+
561
+ def get_connection_pool() -> SocketIOConnectionPool:
562
+ """Get or create the global connection pool."""
563
+ global _connection_pool
564
+ if _connection_pool is None:
565
+ _connection_pool = SocketIOConnectionPool()
566
+ _connection_pool.start()
567
+ return _connection_pool
568
+
569
+
570
+ def stop_connection_pool():
571
+ """Stop the global connection pool."""
572
+ global _connection_pool
573
+ if _connection_pool:
574
+ _connection_pool.stop()
575
+ _connection_pool = None
576
+
577
+
578
+ # Backwards compatibility function
579
+ def emit_hook_event(namespace: str, event: str, data: Dict[str, Any]):
580
+ """Emit a hook event using the connection pool."""
581
+ pool = get_connection_pool()
582
+ pool.emit_event(namespace, event, data)