claude-mpm 3.7.8__py3-none-any.whl → 3.9.0__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 (100) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_PM.md +0 -106
  3. claude_mpm/agents/INSTRUCTIONS.md +0 -96
  4. claude_mpm/agents/MEMORY.md +94 -0
  5. claude_mpm/agents/WORKFLOW.md +86 -0
  6. claude_mpm/agents/templates/code_analyzer.json +2 -2
  7. claude_mpm/agents/templates/data_engineer.json +1 -1
  8. claude_mpm/agents/templates/documentation.json +1 -1
  9. claude_mpm/agents/templates/engineer.json +1 -1
  10. claude_mpm/agents/templates/ops.json +1 -1
  11. claude_mpm/agents/templates/qa.json +1 -1
  12. claude_mpm/agents/templates/research.json +1 -1
  13. claude_mpm/agents/templates/security.json +1 -1
  14. claude_mpm/agents/templates/ticketing.json +3 -8
  15. claude_mpm/agents/templates/version_control.json +1 -1
  16. claude_mpm/agents/templates/web_qa.json +2 -2
  17. claude_mpm/agents/templates/web_ui.json +2 -2
  18. claude_mpm/cli/__init__.py +2 -2
  19. claude_mpm/cli/commands/__init__.py +2 -1
  20. claude_mpm/cli/commands/agents.py +8 -3
  21. claude_mpm/cli/commands/tickets.py +596 -19
  22. claude_mpm/cli/parser.py +217 -5
  23. claude_mpm/config/__init__.py +30 -39
  24. claude_mpm/config/socketio_config.py +8 -5
  25. claude_mpm/constants.py +13 -0
  26. claude_mpm/core/__init__.py +8 -18
  27. claude_mpm/core/cache.py +596 -0
  28. claude_mpm/core/claude_runner.py +166 -622
  29. claude_mpm/core/config.py +7 -3
  30. claude_mpm/core/constants.py +339 -0
  31. claude_mpm/core/container.py +548 -38
  32. claude_mpm/core/exceptions.py +392 -0
  33. claude_mpm/core/framework_loader.py +249 -93
  34. claude_mpm/core/interactive_session.py +479 -0
  35. claude_mpm/core/interfaces.py +424 -0
  36. claude_mpm/core/lazy.py +467 -0
  37. claude_mpm/core/logging_config.py +444 -0
  38. claude_mpm/core/oneshot_session.py +465 -0
  39. claude_mpm/core/optimized_agent_loader.py +485 -0
  40. claude_mpm/core/optimized_startup.py +490 -0
  41. claude_mpm/core/service_registry.py +52 -26
  42. claude_mpm/core/socketio_pool.py +162 -5
  43. claude_mpm/core/types.py +292 -0
  44. claude_mpm/core/typing_utils.py +477 -0
  45. claude_mpm/hooks/claude_hooks/hook_handler.py +213 -99
  46. claude_mpm/init.py +2 -1
  47. claude_mpm/services/__init__.py +78 -14
  48. claude_mpm/services/agent/__init__.py +24 -0
  49. claude_mpm/services/agent/deployment.py +2548 -0
  50. claude_mpm/services/agent/management.py +598 -0
  51. claude_mpm/services/agent/registry.py +813 -0
  52. claude_mpm/services/agents/deployment/agent_deployment.py +728 -308
  53. claude_mpm/services/agents/memory/agent_memory_manager.py +160 -4
  54. claude_mpm/services/async_session_logger.py +8 -3
  55. claude_mpm/services/communication/__init__.py +21 -0
  56. claude_mpm/services/communication/socketio.py +1933 -0
  57. claude_mpm/services/communication/websocket.py +479 -0
  58. claude_mpm/services/core/__init__.py +123 -0
  59. claude_mpm/services/core/base.py +247 -0
  60. claude_mpm/services/core/interfaces.py +951 -0
  61. claude_mpm/services/framework_claude_md_generator/__init__.py +10 -3
  62. claude_mpm/services/framework_claude_md_generator/deployment_manager.py +14 -11
  63. claude_mpm/services/framework_claude_md_generator/section_generators/todo_task_tools.py +23 -23
  64. claude_mpm/services/framework_claude_md_generator.py +3 -2
  65. claude_mpm/services/health_monitor.py +4 -3
  66. claude_mpm/services/hook_service.py +64 -4
  67. claude_mpm/services/infrastructure/__init__.py +21 -0
  68. claude_mpm/services/infrastructure/logging.py +202 -0
  69. claude_mpm/services/infrastructure/monitoring.py +893 -0
  70. claude_mpm/services/memory/indexed_memory.py +648 -0
  71. claude_mpm/services/project/__init__.py +21 -0
  72. claude_mpm/services/project/analyzer.py +864 -0
  73. claude_mpm/services/project/registry.py +608 -0
  74. claude_mpm/services/project_analyzer.py +95 -2
  75. claude_mpm/services/recovery_manager.py +15 -9
  76. claude_mpm/services/response_tracker.py +3 -5
  77. claude_mpm/services/socketio/__init__.py +25 -0
  78. claude_mpm/services/socketio/handlers/__init__.py +25 -0
  79. claude_mpm/services/socketio/handlers/base.py +121 -0
  80. claude_mpm/services/socketio/handlers/connection.py +198 -0
  81. claude_mpm/services/socketio/handlers/file.py +213 -0
  82. claude_mpm/services/socketio/handlers/git.py +723 -0
  83. claude_mpm/services/socketio/handlers/memory.py +27 -0
  84. claude_mpm/services/socketio/handlers/project.py +25 -0
  85. claude_mpm/services/socketio/handlers/registry.py +145 -0
  86. claude_mpm/services/socketio_client_manager.py +12 -7
  87. claude_mpm/services/socketio_server.py +156 -30
  88. claude_mpm/services/ticket_manager.py +172 -9
  89. claude_mpm/services/ticket_manager_di.py +1 -1
  90. claude_mpm/services/version_control/semantic_versioning.py +80 -7
  91. claude_mpm/services/version_control/version_parser.py +528 -0
  92. claude_mpm/utils/error_handler.py +1 -1
  93. claude_mpm/validation/agent_validator.py +27 -14
  94. claude_mpm/validation/frontmatter_validator.py +231 -0
  95. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/METADATA +38 -128
  96. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/RECORD +100 -59
  97. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/WHEEL +0 -0
  98. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/entry_points.txt +0 -0
  99. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/licenses/LICENSE +0 -0
  100. {claude_mpm-3.7.8.dist-info → claude_mpm-3.9.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1933 @@
1
+ """Socket.IO server for real-time monitoring of Claude MPM sessions.
2
+
3
+ WHY: This provides a Socket.IO-based alternative to the WebSocket server,
4
+ offering improved connection reliability and automatic reconnection.
5
+ Socket.IO handles connection drops gracefully and provides better
6
+ cross-platform compatibility.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import threading
14
+ import time
15
+ from datetime import datetime
16
+ from typing import Set, Dict, Any, Optional, List
17
+ from collections import deque
18
+ from pathlib import Path
19
+
20
+ try:
21
+ import socketio
22
+ import aiohttp
23
+ from aiohttp import web
24
+ SOCKETIO_AVAILABLE = True
25
+ # Don't print at module level - this causes output during imports
26
+ # Version will be logged when server is actually started
27
+ except ImportError:
28
+ SOCKETIO_AVAILABLE = False
29
+ socketio = None
30
+ aiohttp = None
31
+ web = None
32
+ # Don't print warnings at module level
33
+
34
+ from ..core.logging_config import get_logger, log_operation, log_performance_context
35
+ from ..deployment_paths import get_project_root, get_scripts_dir
36
+ from .socketio.handlers import EventHandlerRegistry, FileEventHandler, GitEventHandler
37
+ from ..core.constants import (
38
+ SystemLimits,
39
+ NetworkConfig,
40
+ TimeoutConfig,
41
+ PerformanceConfig
42
+ )
43
+ from ..core.interfaces import SocketIOServiceInterface
44
+ from ..exceptions import MPMConnectionError
45
+
46
+
47
+ class SocketIOClientProxy:
48
+ """Proxy that connects to an existing Socket.IO server as a client.
49
+
50
+ WHY: In exec mode, a persistent Socket.IO server runs in a separate process.
51
+ The hook handler in the Claude process needs a Socket.IO-like interface
52
+ but shouldn't start another server. This proxy provides that interface
53
+ while the actual events are handled by the persistent server.
54
+ """
55
+
56
+ def __init__(self, host: str = "localhost", port: int = 8765):
57
+ self.host = host
58
+ self.port = port
59
+ self.logger = get_logger(__name__ + ".SocketIOClientProxy")
60
+ self.running = True # Always "running" for compatibility
61
+ self._sio_client = None
62
+ self._client_thread = None
63
+ self._client_loop = None
64
+
65
+ def start_sync(self):
66
+ """Start the Socket.IO client connection to the persistent server."""
67
+ self.logger.debug(f"SocketIOClientProxy: Connecting to server on {self.host}:{self.port}")
68
+ if SOCKETIO_AVAILABLE:
69
+ self._start_client()
70
+
71
+ def stop_sync(self):
72
+ """Stop the Socket.IO client connection."""
73
+ self.logger.debug(f"SocketIOClientProxy: Disconnecting from server")
74
+ if self._sio_client:
75
+ self._sio_client.disconnect()
76
+
77
+ def _start_client(self):
78
+ """Start Socket.IO client in a background thread."""
79
+ def run_client():
80
+ self._client_loop = asyncio.new_event_loop()
81
+ asyncio.set_event_loop(self._client_loop)
82
+ try:
83
+ self._client_loop.run_until_complete(self._connect_and_run())
84
+ except Exception as e:
85
+ self.logger.error(f"SocketIOClientProxy client thread error: {e}")
86
+ finally:
87
+ self._client_loop.close()
88
+
89
+ self._client_thread = threading.Thread(target=run_client, daemon=True)
90
+ self._client_thread.start()
91
+ # Give it a moment to connect
92
+ time.sleep(0.2)
93
+
94
+ async def _connect_and_run(self):
95
+ """Connect to the persistent Socket.IO server and keep connection alive."""
96
+ try:
97
+ self._sio_client = socketio.AsyncClient()
98
+
99
+ @self._sio_client.event
100
+ async def connect():
101
+ self.logger.info(f"SocketIOClientProxy: Connected to server at http://{self.host}:{self.port}")
102
+
103
+ @self._sio_client.event
104
+ async def disconnect():
105
+ self.logger.info(f"SocketIOClientProxy: Disconnected from server")
106
+
107
+ # Connect to the server
108
+ await self._sio_client.connect(f'http://127.0.0.1:{self.port}')
109
+
110
+ # Keep the connection alive until stopped
111
+ while self.running:
112
+ await asyncio.sleep(1)
113
+
114
+ except Exception as e:
115
+ self.logger.error(f"SocketIOClientProxy: Connection error: {e}")
116
+ self._sio_client = None
117
+
118
+ def broadcast_event(self, event_type: str, data: Dict[str, Any]):
119
+ """Send event to the persistent Socket.IO server."""
120
+ if not SOCKETIO_AVAILABLE:
121
+ return
122
+
123
+ # Ensure client is started
124
+ if not self._client_thread or not self._client_thread.is_alive():
125
+ self.logger.debug(f"SocketIOClientProxy: Starting client for {event_type}")
126
+ self._start_client()
127
+
128
+ if self._sio_client and self._sio_client.connected:
129
+ try:
130
+ event = {
131
+ "type": event_type,
132
+ "timestamp": datetime.now().isoformat(),
133
+ "data": data
134
+ }
135
+
136
+ # Send event safely using run_coroutine_threadsafe
137
+ if hasattr(self, '_client_loop') and self._client_loop and not self._client_loop.is_closed():
138
+ try:
139
+ future = asyncio.run_coroutine_threadsafe(
140
+ self._sio_client.emit('claude_event', event),
141
+ self._client_loop
142
+ )
143
+ # Don't wait for the result to avoid blocking
144
+ self.logger.debug(f"SocketIOClientProxy: Scheduled emit for {event_type}")
145
+ except Exception as e:
146
+ self.logger.error(f"SocketIOClientProxy: Failed to schedule emit for {event_type}: {e}")
147
+ else:
148
+ self.logger.warning(f"SocketIOClientProxy: Client event loop not available for {event_type}")
149
+
150
+ self.logger.debug(f"SocketIOClientProxy: Sent event {event_type}")
151
+ except Exception as e:
152
+ self.logger.error(f"SocketIOClientProxy: Failed to send event {event_type}: {e}")
153
+ else:
154
+ self.logger.warning(f"SocketIOClientProxy: Client not ready for {event_type}")
155
+
156
+ # Compatibility methods for WebSocketServer interface
157
+ def session_started(self, session_id: str, launch_method: str, working_dir: str):
158
+ self.logger.debug(f"SocketIOClientProxy: Session started {session_id}")
159
+
160
+ def session_ended(self):
161
+ self.logger.debug(f"SocketIOClientProxy: Session ended")
162
+
163
+ def claude_status_changed(self, status: str, pid: Optional[int] = None, message: str = ""):
164
+ self.logger.debug(f"SocketIOClientProxy: Claude status {status}")
165
+
166
+ def agent_delegated(self, agent: str, task: str, status: str = "started"):
167
+ self.logger.debug(f"SocketIOClientProxy: Agent {agent} delegated")
168
+
169
+ def todo_updated(self, todos: List[Dict[str, Any]]):
170
+ self.logger.debug(f"SocketIOClientProxy: Todo updated ({len(todos)} todos)")
171
+
172
+
173
+ class SocketIOServer(SocketIOServiceInterface):
174
+ """Socket.IO server for broadcasting Claude MPM events.
175
+
176
+ WHY: Socket.IO provides better connection reliability than raw WebSockets,
177
+ with automatic reconnection, fallback transports, and better error handling.
178
+ It maintains the same event interface as WebSocketServer for compatibility.
179
+ """
180
+
181
+ def __init__(self, host: str = "localhost", port: int = 8765):
182
+ self.host = host
183
+ self.port = port
184
+ self.logger = get_logger(__name__)
185
+ self.clients: Set[str] = set() # Store session IDs instead of connection objects
186
+ self.event_history: deque = deque(maxlen=SystemLimits.MAX_EVENT_HISTORY)
187
+ self.sio = None
188
+ self.app = None
189
+ self.runner = None
190
+ self.site = None
191
+ self.loop = None
192
+ self.thread = None
193
+ self.running = False
194
+
195
+ # Session state
196
+ self.session_id = None
197
+ self.session_start = None
198
+ self.claude_status = "stopped"
199
+ self.claude_pid = None
200
+
201
+ if not SOCKETIO_AVAILABLE:
202
+ self.logger.warning("Socket.IO support not available. Install 'python-socketio' and 'aiohttp' packages to enable.")
203
+ else:
204
+ # Log version info when server is actually created
205
+ try:
206
+ version = getattr(socketio, '__version__', 'unknown')
207
+ self.logger.info(f"Socket.IO server using python-socketio v{version}")
208
+ except:
209
+ self.logger.info("Socket.IO server using python-socketio (version unavailable)")
210
+
211
+ def start_sync(self):
212
+ """Start the Socket.IO server in a background thread (synchronous version)."""
213
+ if not SOCKETIO_AVAILABLE:
214
+ self.logger.debug("Socket.IO server skipped - required packages not installed")
215
+ return
216
+
217
+ if self.running:
218
+ self.logger.debug(f"Socket.IO server already running on port {self.port}")
219
+ return
220
+
221
+ self.running = True
222
+ self.thread = threading.Thread(target=self._run_server, daemon=True)
223
+ self.thread.start()
224
+ self.logger.info(f"🚀 Socket.IO server STARTING on http://{self.host}:{self.port}")
225
+ self.logger.info(f"🔧 Thread created: {self.thread.name} (daemon={self.thread.daemon})")
226
+
227
+ # Give server a moment to start
228
+ time.sleep(0.1)
229
+
230
+ if self.thread.is_alive():
231
+ self.logger.info(f"✅ Socket.IO server thread is alive and running")
232
+ else:
233
+ self.logger.error(f"❌ Socket.IO server thread failed to start!")
234
+
235
+ def stop_sync(self):
236
+ """Stop the Socket.IO server (synchronous version)."""
237
+ self.running = False
238
+ if self.loop:
239
+ asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop)
240
+ if self.thread:
241
+ self.thread.join(timeout=TimeoutConfig.THREAD_JOIN_TIMEOUT)
242
+ self.logger.info("Socket.IO server stopped")
243
+
244
+ def _run_server(self):
245
+ """Run the server event loop."""
246
+ self.logger.info(f"🔄 _run_server starting on thread: {threading.current_thread().name}")
247
+ self.loop = asyncio.new_event_loop()
248
+ asyncio.set_event_loop(self.loop)
249
+ self.logger.info(f"📡 Event loop created and set for Socket.IO server")
250
+
251
+ try:
252
+ self.logger.info(f"🎯 About to start _serve() coroutine")
253
+ self.loop.run_until_complete(self._serve())
254
+ except Exception as e:
255
+ self.logger.error(f"❌ Socket.IO server error in _run_server: {e}")
256
+ import traceback
257
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
258
+ finally:
259
+ self.logger.info(f"🔚 Socket.IO server _run_server shutting down")
260
+ self.loop.close()
261
+
262
+ async def _serve(self):
263
+ """Start the Socket.IO server."""
264
+ try:
265
+ self.logger.info(f"🔌 _serve() starting - attempting to bind to {self.host}:{self.port}")
266
+
267
+ # Create Socket.IO server with improved configuration
268
+ self.sio = socketio.AsyncServer(
269
+ cors_allowed_origins="*",
270
+ ping_timeout=NetworkConfig.PING_TIMEOUT,
271
+ ping_interval=NetworkConfig.PING_INTERVAL,
272
+ max_http_buffer_size=1000000,
273
+ allow_upgrades=True,
274
+ transports=['websocket', 'polling'],
275
+ logger=False, # Reduce noise in logs
276
+ engineio_logger=False
277
+ )
278
+
279
+ # Create aiohttp web application
280
+ self.app = web.Application()
281
+ self.sio.attach(self.app)
282
+
283
+ # Add CORS middleware
284
+ import aiohttp_cors
285
+ cors = aiohttp_cors.setup(self.app, defaults={
286
+ "*": aiohttp_cors.ResourceOptions(
287
+ allow_credentials=True,
288
+ expose_headers="*",
289
+ allow_headers="*",
290
+ allow_methods="*"
291
+ )
292
+ })
293
+
294
+ # Add HTTP routes
295
+ self.app.router.add_get('/health', self._handle_health)
296
+ self.app.router.add_get('/status', self._handle_health)
297
+ self.app.router.add_get('/api/git-diff', self._handle_git_diff)
298
+ self.app.router.add_options('/api/git-diff', self._handle_cors_preflight)
299
+ self.app.router.add_get('/api/file-content', self._handle_file_content)
300
+ self.app.router.add_options('/api/file-content', self._handle_cors_preflight)
301
+
302
+ # Add dashboard routes
303
+ self.app.router.add_get('/', self._handle_dashboard)
304
+ self.app.router.add_get('/dashboard', self._handle_dashboard)
305
+
306
+ # Add static file serving for web assets
307
+ static_path = self._find_static_path()
308
+ if static_path and static_path.exists():
309
+ self.app.router.add_static('/static/', path=str(static_path), name='static')
310
+ self.logger.info(f"Static files served from: {static_path}")
311
+ else:
312
+ self.logger.warning("Static files directory not found - CSS/JS files will not be available")
313
+
314
+ # Register event handlers
315
+ self._register_events()
316
+
317
+ # Start the server
318
+ self.runner = web.AppRunner(self.app)
319
+ await self.runner.setup()
320
+
321
+ self.site = web.TCPSite(self.runner, self.host, self.port)
322
+ try:
323
+ await self.site.start()
324
+ except OSError as e:
325
+ if "Address already in use" in str(e) or "address already in use" in str(e).lower():
326
+ raise MPMConnectionError(
327
+ f"Port {self.port} is already in use",
328
+ context={"host": self.host, "port": self.port, "error": str(e)}
329
+ ) from e
330
+ else:
331
+ raise
332
+
333
+ self.logger.info(f"🎉 Socket.IO server SUCCESSFULLY listening on http://{self.host}:{self.port}")
334
+
335
+ # Keep server running
336
+ loop_count = 0
337
+ while self.running:
338
+ await asyncio.sleep(0.1)
339
+ loop_count += 1
340
+ if loop_count % PerformanceConfig.LOG_EVERY_N_ITERATIONS == 0:
341
+ self.logger.debug(f"🔄 Socket.IO server heartbeat - {len(self.clients)} clients connected")
342
+
343
+ except Exception as e:
344
+ self.logger.error(f"❌ Failed to start Socket.IO server: {e}")
345
+ import traceback
346
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
347
+ raise
348
+
349
+ async def _shutdown(self):
350
+ """Shutdown the server."""
351
+ if self.site:
352
+ await self.site.stop()
353
+ if self.runner:
354
+ await self.runner.cleanup()
355
+
356
+ async def _handle_health(self, request):
357
+ """Handle health check requests."""
358
+ return web.json_response({
359
+ "status": "healthy",
360
+ "server": "claude-mpm-python-socketio",
361
+ "timestamp": datetime.utcnow().isoformat() + "Z",
362
+ "port": self.port,
363
+ "host": self.host,
364
+ "clients_connected": len(self.clients)
365
+ }, headers={
366
+ 'Access-Control-Allow-Origin': '*',
367
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
368
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
369
+ })
370
+
371
+ def _find_static_path(self):
372
+ """Find the static files directory using multiple approaches.
373
+
374
+ WHY: Static files need to be found in both development and installed environments.
375
+ This uses the same multi-approach pattern as dashboard HTML resolution.
376
+ """
377
+
378
+ # Approach 1: Use module-relative path (works in installed environment)
379
+ try:
380
+ import claude_mpm.dashboard
381
+
382
+ # Try __file__ attribute first
383
+ if hasattr(claude_mpm.dashboard, '__file__') and claude_mpm.dashboard.__file__:
384
+ dashboard_module_path = Path(claude_mpm.dashboard.__file__).parent
385
+ candidate_path = dashboard_module_path / "static"
386
+ if candidate_path.exists():
387
+ self.logger.info(f"Found static files using module __file__ path: {candidate_path}")
388
+ return candidate_path
389
+
390
+ # Try __path__ attribute for namespace packages
391
+ elif hasattr(claude_mpm.dashboard, '__path__') and claude_mpm.dashboard.__path__:
392
+ # __path__ is a list, take the first entry
393
+ dashboard_module_path = Path(claude_mpm.dashboard.__path__[0])
394
+ candidate_path = dashboard_module_path / "static"
395
+ if candidate_path.exists():
396
+ self.logger.info(f"Found static files using module __path__: {candidate_path}")
397
+ return candidate_path
398
+
399
+ except Exception as e:
400
+ self.logger.debug(f"Module-relative static path failed: {e}")
401
+
402
+ # Approach 2: Use project root (works in development environment)
403
+ try:
404
+ candidate_path = get_project_root() / 'src' / 'claude_mpm' / 'dashboard' / 'static'
405
+ if candidate_path.exists():
406
+ self.logger.info(f"Found static files using project root: {candidate_path}")
407
+ return candidate_path
408
+ except Exception as e:
409
+ self.logger.debug(f"Project root static path failed: {e}")
410
+
411
+ # Approach 3: Search for static files in package installation
412
+ try:
413
+ candidate_path = get_project_root() / 'claude_mpm' / 'dashboard' / 'static'
414
+ if candidate_path.exists():
415
+ self.logger.info(f"Found static files using package path: {candidate_path}")
416
+ return candidate_path
417
+ except Exception as e:
418
+ self.logger.debug(f"Package static path failed: {e}")
419
+
420
+ return None
421
+
422
+ async def _handle_dashboard(self, request):
423
+ """Serve the dashboard HTML file."""
424
+ # Try to find dashboard path using multiple approaches
425
+ dashboard_path = None
426
+
427
+ # Approach 1: Use module-relative path (works in installed environment)
428
+ try:
429
+ import claude_mpm.dashboard
430
+
431
+ # Try __file__ attribute first
432
+ if hasattr(claude_mpm.dashboard, '__file__') and claude_mpm.dashboard.__file__:
433
+ dashboard_module_path = Path(claude_mpm.dashboard.__file__).parent
434
+ candidate_path = dashboard_module_path / "templates" / "index.html"
435
+ if candidate_path.exists():
436
+ dashboard_path = candidate_path
437
+ self.logger.info(f"Found dashboard using module __file__ path: {dashboard_path}")
438
+
439
+ # Try __path__ attribute for namespace packages
440
+ elif hasattr(claude_mpm.dashboard, '__path__') and claude_mpm.dashboard.__path__:
441
+ # __path__ is a list, take the first entry
442
+ dashboard_module_path = Path(claude_mpm.dashboard.__path__[0])
443
+ candidate_path = dashboard_module_path / "templates" / "index.html"
444
+ if candidate_path.exists():
445
+ dashboard_path = candidate_path
446
+ self.logger.info(f"Found dashboard using module __path__: {dashboard_path}")
447
+
448
+ except Exception as e:
449
+ self.logger.debug(f"Module-relative path failed: {e}")
450
+
451
+ # Approach 2: Use project root (works in development environment)
452
+ if dashboard_path is None:
453
+ try:
454
+ candidate_path = get_project_root() / 'src' / 'claude_mpm' / 'dashboard' / 'templates' / 'index.html'
455
+ if candidate_path.exists():
456
+ dashboard_path = candidate_path
457
+ self.logger.info(f"Found dashboard using project root: {dashboard_path}")
458
+ except Exception as e:
459
+ self.logger.debug(f"Project root path failed: {e}")
460
+
461
+ # Approach 3: Search for dashboard in package installation
462
+ if dashboard_path is None:
463
+ try:
464
+ candidate_path = get_project_root() / 'claude_mpm' / 'dashboard' / 'templates' / 'index.html'
465
+ if candidate_path.exists():
466
+ dashboard_path = candidate_path
467
+ self.logger.info(f"Found dashboard using package path: {dashboard_path}")
468
+ except Exception as e:
469
+ self.logger.debug(f"Package path failed: {e}")
470
+
471
+ if dashboard_path and dashboard_path.exists():
472
+ return web.FileResponse(str(dashboard_path))
473
+ else:
474
+ error_msg = f"Dashboard not found. Searched paths:\n"
475
+ error_msg += f"1. Module-relative: {dashboard_module_path / 'templates' / 'index.html' if 'dashboard_module_path' in locals() else 'N/A'}\n"
476
+ error_msg += f"2. Development: {get_project_root() / 'src' / 'claude_mpm' / 'dashboard' / 'templates' / 'index.html'}\n"
477
+ error_msg += f"3. Package: {get_project_root() / 'claude_mpm' / 'dashboard' / 'templates' / 'index.html'}"
478
+ self.logger.error(error_msg)
479
+ return web.Response(text=error_msg, status=404)
480
+
481
+ async def _handle_cors_preflight(self, request):
482
+ """Handle CORS preflight requests."""
483
+ return web.Response(
484
+ status=NetworkConfig.HTTP_OK,
485
+ headers={
486
+ 'Access-Control-Allow-Origin': '*',
487
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
488
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
489
+ 'Access-Control-Max-Age': '86400'
490
+ }
491
+ )
492
+
493
+ async def _handle_git_diff(self, request):
494
+ """Handle git diff requests for file operations.
495
+
496
+ Expected query parameters:
497
+ - file: The file path to generate diff for
498
+ - timestamp: ISO timestamp of the operation (optional)
499
+ - working_dir: Working directory for git operations (optional)
500
+ """
501
+ try:
502
+ # Extract query parameters
503
+ file_path = request.query.get('file')
504
+ timestamp = request.query.get('timestamp')
505
+ working_dir = request.query.get('working_dir', os.getcwd())
506
+
507
+ self.logger.info(f"Git diff API request: file={file_path}, timestamp={timestamp}, working_dir={working_dir}")
508
+ self.logger.info(f"Git diff request details: query_params={dict(request.query)}, file_exists={os.path.exists(file_path) if file_path else False}")
509
+
510
+ if not file_path:
511
+ self.logger.warning("Git diff request missing file parameter")
512
+ return web.json_response({
513
+ "success": False,
514
+ "error": "Missing required parameter: file"
515
+ }, status=400, headers={
516
+ 'Access-Control-Allow-Origin': '*',
517
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
518
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
519
+ })
520
+
521
+ self.logger.debug(f"Git diff requested for file: {file_path}, timestamp: {timestamp}")
522
+
523
+ # Generate git diff using the git handler's method
524
+ if hasattr(self, 'git_handler') and self.git_handler:
525
+ diff_result = await self.git_handler.generate_git_diff(file_path, timestamp, working_dir)
526
+ else:
527
+ # Fallback to old method if handler not available
528
+ diff_result = await self._generate_git_diff(file_path, timestamp, working_dir)
529
+
530
+ self.logger.info(f"Git diff result: success={diff_result.get('success', False)}, method={diff_result.get('method', 'unknown')}")
531
+
532
+ return web.json_response(diff_result, headers={
533
+ 'Access-Control-Allow-Origin': '*',
534
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
535
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
536
+ })
537
+
538
+ except Exception as e:
539
+ self.logger.error(f"Error generating git diff: {e}")
540
+ import traceback
541
+ self.logger.error(f"Git diff error traceback: {traceback.format_exc()}")
542
+ return web.json_response({
543
+ "success": False,
544
+ "error": f"Failed to generate git diff: {str(e)}"
545
+ }, status=NetworkConfig.HTTP_INTERNAL_ERROR, headers={
546
+ 'Access-Control-Allow-Origin': '*',
547
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
548
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
549
+ })
550
+
551
+ async def _handle_file_content(self, request):
552
+ """Handle file content requests via HTTP API.
553
+
554
+ Expected query parameters:
555
+ - file_path: The file path to read
556
+ - working_dir: Working directory for file operations (optional)
557
+ - max_size: Maximum file size in bytes (optional, default 1MB)
558
+ """
559
+ try:
560
+ # Extract query parameters
561
+ file_path = request.query.get('file_path')
562
+ working_dir = request.query.get('working_dir', os.getcwd())
563
+ max_size = int(request.query.get('max_size', SystemLimits.MAX_FILE_SIZE))
564
+
565
+ self.logger.info(f"File content API request: file_path={file_path}, working_dir={working_dir}")
566
+
567
+ if not file_path:
568
+ self.logger.warning("File content request missing file_path parameter")
569
+ return web.json_response({
570
+ "success": False,
571
+ "error": "Missing required parameter: file_path"
572
+ }, status=400, headers={
573
+ 'Access-Control-Allow-Origin': '*',
574
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
575
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
576
+ })
577
+
578
+ # Use the file handler's safe reading logic
579
+ if hasattr(self, 'file_handler') and self.file_handler:
580
+ result = await self.file_handler._read_file_safely(file_path, working_dir, max_size)
581
+ else:
582
+ # Fallback to old method if handler not available
583
+ result = await self._read_file_safely(file_path, working_dir, max_size)
584
+
585
+ status_code = 200 if result.get('success') else 400
586
+ return web.json_response(result, status=status_code, headers={
587
+ 'Access-Control-Allow-Origin': '*',
588
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
589
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
590
+ })
591
+
592
+ except Exception as e:
593
+ self.logger.error(f"Error reading file content: {e}")
594
+ import traceback
595
+ self.logger.error(f"File content error traceback: {traceback.format_exc()}")
596
+ return web.json_response({
597
+ "success": False,
598
+ "error": f"Failed to read file: {str(e)}"
599
+ }, status=NetworkConfig.HTTP_INTERNAL_ERROR, headers={
600
+ 'Access-Control-Allow-Origin': '*',
601
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
602
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
603
+ })
604
+
605
+ async def _read_file_safely(self, file_path: str, working_dir: str = None, max_size: int = SystemLimits.MAX_FILE_SIZE):
606
+ """Safely read file content with security checks.
607
+
608
+ This method contains the core file reading logic that can be used by both
609
+ HTTP API endpoints and Socket.IO event handlers.
610
+
611
+ Args:
612
+ file_path: Path to the file to read
613
+ working_dir: Working directory (defaults to current directory)
614
+ max_size: Maximum file size in bytes
615
+
616
+ Returns:
617
+ dict: Response with success status, content, and metadata
618
+ """
619
+ try:
620
+ if working_dir is None:
621
+ working_dir = os.getcwd()
622
+
623
+ # Resolve absolute path based on working directory
624
+ if not os.path.isabs(file_path):
625
+ full_path = os.path.join(working_dir, file_path)
626
+ else:
627
+ full_path = file_path
628
+
629
+ # Security check: ensure file is within working directory or project
630
+ try:
631
+ real_path = os.path.realpath(full_path)
632
+ real_working_dir = os.path.realpath(working_dir)
633
+
634
+ # Allow access to files within working directory or the project root
635
+ project_root = os.path.realpath(get_project_root())
636
+ allowed_paths = [real_working_dir, project_root]
637
+
638
+ is_allowed = any(real_path.startswith(allowed_path) for allowed_path in allowed_paths)
639
+
640
+ if not is_allowed:
641
+ return {
642
+ 'success': False,
643
+ 'error': 'Access denied: file is outside allowed directories',
644
+ 'file_path': file_path
645
+ }
646
+
647
+ except Exception as path_error:
648
+ self.logger.error(f"Path validation error: {path_error}")
649
+ return {
650
+ 'success': False,
651
+ 'error': 'Invalid file path',
652
+ 'file_path': file_path
653
+ }
654
+
655
+ # Check if file exists
656
+ if not os.path.exists(real_path):
657
+ return {
658
+ 'success': False,
659
+ 'error': 'File does not exist',
660
+ 'file_path': file_path
661
+ }
662
+
663
+ # Check if it's a file (not directory)
664
+ if not os.path.isfile(real_path):
665
+ return {
666
+ 'success': False,
667
+ 'error': 'Path is not a file',
668
+ 'file_path': file_path
669
+ }
670
+
671
+ # Check file size
672
+ file_size = os.path.getsize(real_path)
673
+ if file_size > max_size:
674
+ return {
675
+ 'success': False,
676
+ 'error': f'File too large ({file_size} bytes). Maximum allowed: {max_size} bytes',
677
+ 'file_path': file_path,
678
+ 'file_size': file_size
679
+ }
680
+
681
+ # Read file content
682
+ try:
683
+ with open(real_path, 'r', encoding='utf-8') as f:
684
+ content = f.read()
685
+
686
+ # Get file extension for syntax highlighting hint
687
+ _, ext = os.path.splitext(real_path)
688
+
689
+ return {
690
+ 'success': True,
691
+ 'file_path': file_path,
692
+ 'content': content,
693
+ 'file_size': file_size,
694
+ 'extension': ext.lower(),
695
+ 'encoding': 'utf-8'
696
+ }
697
+
698
+ except UnicodeDecodeError:
699
+ # Try reading as binary if UTF-8 fails
700
+ try:
701
+ with open(real_path, 'rb') as f:
702
+ binary_content = f.read()
703
+
704
+ # Check if it's a text file by looking for common text patterns
705
+ try:
706
+ text_content = binary_content.decode('latin-1')
707
+ if '\x00' in text_content:
708
+ # Binary file
709
+ return {
710
+ 'success': False,
711
+ 'error': 'File appears to be binary and cannot be displayed as text',
712
+ 'file_path': file_path,
713
+ 'file_size': file_size
714
+ }
715
+ else:
716
+ # Text file with different encoding
717
+ _, ext = os.path.splitext(real_path)
718
+ return {
719
+ 'success': True,
720
+ 'file_path': file_path,
721
+ 'content': text_content,
722
+ 'file_size': file_size,
723
+ 'extension': ext.lower(),
724
+ 'encoding': 'latin-1'
725
+ }
726
+ except Exception:
727
+ return {
728
+ 'success': False,
729
+ 'error': 'File encoding not supported',
730
+ 'file_path': file_path
731
+ }
732
+ except Exception as read_error:
733
+ return {
734
+ 'success': False,
735
+ 'error': f'Failed to read file: {str(read_error)}',
736
+ 'file_path': file_path
737
+ }
738
+
739
+ except Exception as e:
740
+ self.logger.error(f"Error in _read_file_safely: {e}")
741
+ return {
742
+ 'success': False,
743
+ 'error': str(e),
744
+ 'file_path': file_path
745
+ }
746
+
747
+ async def _generate_git_diff(self, file_path: str, timestamp: Optional[str] = None, working_dir: str = None):
748
+ """Generate git diff for a specific file operation.
749
+
750
+ WHY: This method generates a git diff showing the changes made to a file
751
+ during a specific write operation. It uses git log and show commands to
752
+ find the most relevant commit around the specified timestamp.
753
+
754
+ Args:
755
+ file_path: Path to the file relative to the git repository
756
+ timestamp: ISO timestamp of the file operation (optional)
757
+ working_dir: Working directory containing the git repository
758
+
759
+ Returns:
760
+ dict: Contains diff content, metadata, and status information
761
+ """
762
+ try:
763
+ # If file_path is absolute, determine its git repository
764
+ if os.path.isabs(file_path):
765
+ # Find the directory containing the file
766
+ file_dir = os.path.dirname(file_path)
767
+ if os.path.exists(file_dir):
768
+ # Try to find the git root from the file's directory
769
+ current_dir = file_dir
770
+ while current_dir != "/" and current_dir:
771
+ if os.path.exists(os.path.join(current_dir, ".git")):
772
+ working_dir = current_dir
773
+ self.logger.info(f"Found git repository at: {working_dir}")
774
+ break
775
+ current_dir = os.path.dirname(current_dir)
776
+ else:
777
+ # If no git repo found, use the file's directory
778
+ working_dir = file_dir
779
+ self.logger.info(f"No git repo found, using file's directory: {working_dir}")
780
+
781
+ # Handle case where working_dir is None, empty string, or 'Unknown'
782
+ original_working_dir = working_dir
783
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '':
784
+ working_dir = os.getcwd()
785
+ self.logger.info(f"[GIT-DIFF-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
786
+ else:
787
+ self.logger.info(f"[GIT-DIFF-DEBUG] Using provided working_dir: {working_dir}")
788
+
789
+ # For read-only git operations, we can work from any directory
790
+ # by passing the -C flag to git commands instead of changing directories
791
+ original_cwd = os.getcwd()
792
+ try:
793
+ # We'll use git -C <working_dir> for all commands instead of chdir
794
+
795
+ # Check if this is a git repository
796
+ git_check = await asyncio.create_subprocess_exec(
797
+ 'git', '-C', working_dir, 'rev-parse', '--git-dir',
798
+ stdout=asyncio.subprocess.PIPE,
799
+ stderr=asyncio.subprocess.PIPE
800
+ )
801
+ await git_check.communicate()
802
+
803
+ if git_check.returncode != 0:
804
+ return {
805
+ "success": False,
806
+ "error": "Not a git repository",
807
+ "file_path": file_path,
808
+ "working_dir": working_dir
809
+ }
810
+
811
+ # Get the absolute path of the file relative to git root
812
+ git_root_proc = await asyncio.create_subprocess_exec(
813
+ 'git', '-C', working_dir, 'rev-parse', '--show-toplevel',
814
+ stdout=asyncio.subprocess.PIPE,
815
+ stderr=asyncio.subprocess.PIPE
816
+ )
817
+ git_root_output, _ = await git_root_proc.communicate()
818
+
819
+ if git_root_proc.returncode != 0:
820
+ return {"success": False, "error": "Failed to determine git root directory"}
821
+
822
+ git_root = git_root_output.decode().strip()
823
+
824
+ # Make file_path relative to git root if it's absolute
825
+ if os.path.isabs(file_path):
826
+ try:
827
+ file_path = os.path.relpath(file_path, git_root)
828
+ except ValueError:
829
+ # File is not under git root
830
+ pass
831
+
832
+ # If timestamp is provided, try to find commits around that time
833
+ if timestamp:
834
+ # Convert timestamp to git format
835
+ try:
836
+ from datetime import datetime
837
+ dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
838
+ git_since = dt.strftime('%Y-%m-%d %H:%M:%S')
839
+
840
+ # Find commits that modified this file around the timestamp
841
+ log_proc = await asyncio.create_subprocess_exec(
842
+ 'git', '-C', working_dir, 'log', '--oneline', '--since', git_since,
843
+ '--until', f'{git_since} +1 hour', '--', file_path,
844
+ stdout=asyncio.subprocess.PIPE,
845
+ stderr=asyncio.subprocess.PIPE
846
+ )
847
+ log_output, _ = await log_proc.communicate()
848
+
849
+ if log_proc.returncode == 0 and log_output:
850
+ # Get the most recent commit hash
851
+ commits = log_output.decode().strip().split('\n')
852
+ if commits and commits[0]:
853
+ commit_hash = commits[0].split()[0]
854
+
855
+ # Get the diff for this specific commit
856
+ diff_proc = await asyncio.create_subprocess_exec(
857
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
858
+ stdout=asyncio.subprocess.PIPE,
859
+ stderr=asyncio.subprocess.PIPE
860
+ )
861
+ diff_output, diff_error = await diff_proc.communicate()
862
+
863
+ if diff_proc.returncode == 0:
864
+ return {
865
+ "success": True,
866
+ "diff": diff_output.decode(),
867
+ "commit_hash": commit_hash,
868
+ "file_path": file_path,
869
+ "method": "timestamp_based",
870
+ "timestamp": timestamp
871
+ }
872
+ except Exception as e:
873
+ self.logger.warning(f"Failed to parse timestamp or find commits: {e}")
874
+
875
+ # Fallback: Get the most recent change to the file
876
+ log_proc = await asyncio.create_subprocess_exec(
877
+ 'git', '-C', working_dir, 'log', '-1', '--oneline', '--', file_path,
878
+ stdout=asyncio.subprocess.PIPE,
879
+ stderr=asyncio.subprocess.PIPE
880
+ )
881
+ log_output, _ = await log_proc.communicate()
882
+
883
+ if log_proc.returncode == 0 and log_output:
884
+ commit_hash = log_output.decode().strip().split()[0]
885
+
886
+ # Get the diff for the most recent commit
887
+ diff_proc = await asyncio.create_subprocess_exec(
888
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
889
+ stdout=asyncio.subprocess.PIPE,
890
+ stderr=asyncio.subprocess.PIPE
891
+ )
892
+ diff_output, diff_error = await diff_proc.communicate()
893
+
894
+ if diff_proc.returncode == 0:
895
+ return {
896
+ "success": True,
897
+ "diff": diff_output.decode(),
898
+ "commit_hash": commit_hash,
899
+ "file_path": file_path,
900
+ "method": "latest_commit",
901
+ "timestamp": timestamp
902
+ }
903
+
904
+ # Try to show unstaged changes first
905
+ diff_proc = await asyncio.create_subprocess_exec(
906
+ 'git', '-C', working_dir, 'diff', '--', file_path,
907
+ stdout=asyncio.subprocess.PIPE,
908
+ stderr=asyncio.subprocess.PIPE
909
+ )
910
+ diff_output, _ = await diff_proc.communicate()
911
+
912
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
913
+ return {
914
+ "success": True,
915
+ "diff": diff_output.decode(),
916
+ "commit_hash": "unstaged_changes",
917
+ "file_path": file_path,
918
+ "method": "unstaged_changes",
919
+ "timestamp": timestamp
920
+ }
921
+
922
+ # Then try staged changes
923
+ diff_proc = await asyncio.create_subprocess_exec(
924
+ 'git', '-C', working_dir, 'diff', '--cached', '--', file_path,
925
+ stdout=asyncio.subprocess.PIPE,
926
+ stderr=asyncio.subprocess.PIPE
927
+ )
928
+ diff_output, _ = await diff_proc.communicate()
929
+
930
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
931
+ return {
932
+ "success": True,
933
+ "diff": diff_output.decode(),
934
+ "commit_hash": "staged_changes",
935
+ "file_path": file_path,
936
+ "method": "staged_changes",
937
+ "timestamp": timestamp
938
+ }
939
+
940
+ # Final fallback: Show changes against HEAD
941
+ diff_proc = await asyncio.create_subprocess_exec(
942
+ 'git', '-C', working_dir, 'diff', 'HEAD', '--', file_path,
943
+ stdout=asyncio.subprocess.PIPE,
944
+ stderr=asyncio.subprocess.PIPE
945
+ )
946
+ diff_output, _ = await diff_proc.communicate()
947
+
948
+ if diff_proc.returncode == 0:
949
+ working_diff = diff_output.decode()
950
+ if working_diff.strip():
951
+ return {
952
+ "success": True,
953
+ "diff": working_diff,
954
+ "commit_hash": "working_directory",
955
+ "file_path": file_path,
956
+ "method": "working_directory",
957
+ "timestamp": timestamp
958
+ }
959
+
960
+ # Check if file is tracked by git
961
+ status_proc = await asyncio.create_subprocess_exec(
962
+ 'git', '-C', working_dir, 'ls-files', '--', file_path,
963
+ stdout=asyncio.subprocess.PIPE,
964
+ stderr=asyncio.subprocess.PIPE
965
+ )
966
+ status_output, _ = await status_proc.communicate()
967
+
968
+ is_tracked = status_proc.returncode == 0 and status_output.decode().strip()
969
+
970
+ if not is_tracked:
971
+ # File is not tracked by git
972
+ return {
973
+ "success": False,
974
+ "error": "This file is not tracked by git",
975
+ "file_path": file_path,
976
+ "working_dir": working_dir,
977
+ "suggestions": [
978
+ "This file has not been added to git yet",
979
+ "Use 'git add' to track this file before viewing its diff",
980
+ "Git diff can only show changes for files that are tracked by git"
981
+ ]
982
+ }
983
+
984
+ # File is tracked but has no changes to show
985
+ suggestions = [
986
+ "The file may not have any committed changes yet",
987
+ "The file may have been added but not committed",
988
+ "The timestamp may be outside the git history range"
989
+ ]
990
+
991
+ if os.path.isabs(file_path) and not file_path.startswith(os.getcwd()):
992
+ current_repo = os.path.basename(os.getcwd())
993
+ file_repo = "unknown"
994
+ # Try to extract repository name from path
995
+ path_parts = file_path.split("/")
996
+ if "Projects" in path_parts:
997
+ idx = path_parts.index("Projects")
998
+ if idx + 1 < len(path_parts):
999
+ file_repo = path_parts[idx + 1]
1000
+
1001
+ suggestions.clear()
1002
+ suggestions.append(f"This file is from the '{file_repo}' repository")
1003
+ suggestions.append(f"The git diff viewer is running from the '{current_repo}' repository")
1004
+ suggestions.append("Git diff can only show changes for files in the current repository")
1005
+ suggestions.append("To view changes for this file, run the monitoring dashboard from its repository")
1006
+
1007
+ return {
1008
+ "success": False,
1009
+ "error": "No git history found for this file",
1010
+ "file_path": file_path,
1011
+ "suggestions": suggestions
1012
+ }
1013
+
1014
+ finally:
1015
+ os.chdir(original_cwd)
1016
+
1017
+ except Exception as e:
1018
+ self.logger.error(f"Error in _generate_git_diff: {e}")
1019
+ return {
1020
+ "success": False,
1021
+ "error": f"Git diff generation failed: {str(e)}",
1022
+ "file_path": file_path
1023
+ }
1024
+
1025
+
1026
+ def _register_events(self):
1027
+ """Register Socket.IO event handlers.
1028
+
1029
+ WHY: This method now uses the EventHandlerRegistry to manage all event
1030
+ handlers in a modular way. Each handler focuses on a specific domain,
1031
+ reducing complexity and improving maintainability.
1032
+ """
1033
+ # Initialize the event handler registry
1034
+ self.event_registry = EventHandlerRegistry(self)
1035
+ self.event_registry.initialize()
1036
+
1037
+ # Register all events from all handlers
1038
+ self.event_registry.register_all_events()
1039
+
1040
+ # Keep handler instances for HTTP endpoint compatibility
1041
+ self.file_handler = self.event_registry.get_handler(FileEventHandler)
1042
+ self.git_handler = self.event_registry.get_handler(GitEventHandler)
1043
+
1044
+ self.logger.info("All Socket.IO events registered via handler system")
1045
+
1046
+ # Note: The actual event registration is now handled by individual
1047
+ # handler classes in socketio/handlers/. This dramatically reduces
1048
+ # the complexity of this method from 514 lines to under 20 lines.
1049
+
1050
+ return # Early return to skip old implementation
1051
+
1052
+ @self.sio.event
1053
+ async def connect(sid, environ, *args):
1054
+ """Handle client connection."""
1055
+ self.clients.add(sid)
1056
+ client_addr = environ.get('REMOTE_ADDR', 'unknown')
1057
+ user_agent = environ.get('HTTP_USER_AGENT', 'unknown')
1058
+ self.logger.info(f"🔗 NEW CLIENT CONNECTED: {sid} from {client_addr}")
1059
+ self.logger.info(f"📱 User Agent: {user_agent[:100]}...")
1060
+ self.logger.info(f"📈 Total clients now: {len(self.clients)}")
1061
+
1062
+ # Send initial status immediately with enhanced data
1063
+ status_data = {
1064
+ "server": "claude-mpm-python-socketio",
1065
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1066
+ "clients_connected": len(self.clients),
1067
+ "session_id": self.session_id,
1068
+ "claude_status": self.claude_status,
1069
+ "claude_pid": self.claude_pid,
1070
+ "server_version": "2.0.0",
1071
+ "client_id": sid
1072
+ }
1073
+
1074
+ try:
1075
+ await self.sio.emit('status', status_data, room=sid)
1076
+ await self.sio.emit('welcome', {
1077
+ "message": "Connected to Claude MPM Socket.IO server",
1078
+ "client_id": sid,
1079
+ "server_time": datetime.utcnow().isoformat() + "Z"
1080
+ }, room=sid)
1081
+
1082
+ # Automatically send the last 50 events to new clients
1083
+ await self._send_event_history(sid, limit=50)
1084
+
1085
+ self.logger.debug(f"✅ Sent welcome messages and event history to client {sid}")
1086
+ except Exception as e:
1087
+ self.logger.error(f"❌ Failed to send welcome to client {sid}: {e}")
1088
+ import traceback
1089
+ self.logger.error(f"Full traceback: {traceback.format_exc()}")
1090
+
1091
+ @self.sio.event
1092
+ async def disconnect(sid):
1093
+ """Handle client disconnection."""
1094
+ if sid in self.clients:
1095
+ self.clients.remove(sid)
1096
+ self.logger.info(f"🔌 CLIENT DISCONNECTED: {sid}")
1097
+ self.logger.info(f"📉 Total clients now: {len(self.clients)}")
1098
+ else:
1099
+ self.logger.warning(f"⚠️ Attempted to disconnect unknown client: {sid}")
1100
+
1101
+ @self.sio.event
1102
+ async def get_status(sid):
1103
+ """Handle status request."""
1104
+ # Send compatible status event (not claude_event)
1105
+ status_data = {
1106
+ "server": "claude-mpm-python-socketio",
1107
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1108
+ "clients_connected": len(self.clients),
1109
+ "session_id": self.session_id,
1110
+ "claude_status": self.claude_status,
1111
+ "claude_pid": self.claude_pid
1112
+ }
1113
+ await self.sio.emit('status', status_data, room=sid)
1114
+ self.logger.debug(f"Sent status response to client {sid}")
1115
+
1116
+ @self.sio.event
1117
+ async def get_history(sid, data=None):
1118
+ """Handle history request."""
1119
+ params = data or {}
1120
+ event_types = params.get("event_types", [])
1121
+ limit = min(params.get("limit", 100), len(self.event_history))
1122
+
1123
+ await self._send_event_history(sid, event_types=event_types, limit=limit)
1124
+
1125
+ @self.sio.event
1126
+ async def request_history(sid, data=None):
1127
+ """Handle legacy history request (for client compatibility)."""
1128
+ # This handles the 'request.history' event that the client currently emits
1129
+ params = data or {}
1130
+ event_types = params.get("event_types", [])
1131
+ limit = min(params.get("limit", 50), len(self.event_history))
1132
+
1133
+ await self._send_event_history(sid, event_types=event_types, limit=limit)
1134
+
1135
+ @self.sio.event
1136
+ async def subscribe(sid, data=None):
1137
+ """Handle subscription request."""
1138
+ channels = data.get("channels", ["*"]) if data else ["*"]
1139
+ await self.sio.emit('subscribed', {
1140
+ "channels": channels
1141
+ }, room=sid)
1142
+
1143
+ @self.sio.event
1144
+ async def claude_event(sid, data):
1145
+ """Handle events from client proxies."""
1146
+ # Store in history
1147
+ self.event_history.append(data)
1148
+ self.logger.debug(f"📚 Event from client stored in history (total: {len(self.event_history)})")
1149
+
1150
+ # Re-broadcast to all other clients
1151
+ await self.sio.emit('claude_event', data, skip_sid=sid)
1152
+
1153
+ @self.sio.event
1154
+ async def get_git_branch(sid, working_dir=None):
1155
+ """Get the current git branch for a directory"""
1156
+ import subprocess
1157
+ try:
1158
+ self.logger.info(f"[GIT-BRANCH-DEBUG] get_git_branch called with working_dir: {repr(working_dir)} (type: {type(working_dir)})")
1159
+
1160
+ # Handle case where working_dir is None, empty string, or common invalid states
1161
+ original_working_dir = working_dir
1162
+ invalid_states = [
1163
+ None, '', 'Unknown', 'Loading...', 'Loading', 'undefined', 'null',
1164
+ 'Not Connected', 'Invalid Directory', 'No Directory'
1165
+ ]
1166
+
1167
+ if working_dir in invalid_states or (isinstance(working_dir, str) and working_dir.strip() == ''):
1168
+ working_dir = os.getcwd()
1169
+ self.logger.info(f"[GIT-BRANCH-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1170
+ else:
1171
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Using provided working_dir: {working_dir}")
1172
+
1173
+ # Additional validation for obviously invalid paths
1174
+ if isinstance(working_dir, str):
1175
+ working_dir = working_dir.strip()
1176
+ # Check for null bytes or other invalid characters
1177
+ if '\x00' in working_dir:
1178
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] working_dir contains null bytes, using cwd instead")
1179
+ working_dir = os.getcwd()
1180
+
1181
+ # Validate that the directory exists and is a valid path
1182
+ if not os.path.exists(working_dir):
1183
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Directory does not exist: {working_dir} - responding gracefully")
1184
+ await self.sio.emit('git_branch_response', {
1185
+ 'success': False,
1186
+ 'error': f'Directory not found',
1187
+ 'working_dir': working_dir,
1188
+ 'original_working_dir': original_working_dir,
1189
+ 'detail': f'Path does not exist: {working_dir}'
1190
+ }, room=sid)
1191
+ return
1192
+
1193
+ if not os.path.isdir(working_dir):
1194
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Path is not a directory: {working_dir} - responding gracefully")
1195
+ await self.sio.emit('git_branch_response', {
1196
+ 'success': False,
1197
+ 'error': f'Not a directory',
1198
+ 'working_dir': working_dir,
1199
+ 'original_working_dir': original_working_dir,
1200
+ 'detail': f'Path is not a directory: {working_dir}'
1201
+ }, room=sid)
1202
+ return
1203
+
1204
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Running git command in directory: {working_dir}")
1205
+
1206
+ # Run git command to get current branch
1207
+ result = subprocess.run(
1208
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1209
+ cwd=working_dir,
1210
+ capture_output=True,
1211
+ text=True
1212
+ )
1213
+
1214
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Git command result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1215
+
1216
+ if result.returncode == 0:
1217
+ branch = result.stdout.strip()
1218
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Successfully got git branch: {branch}")
1219
+ await self.sio.emit('git_branch_response', {
1220
+ 'success': True,
1221
+ 'branch': branch,
1222
+ 'working_dir': working_dir,
1223
+ 'original_working_dir': original_working_dir
1224
+ }, room=sid)
1225
+ else:
1226
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Git command failed: {result.stderr}")
1227
+ await self.sio.emit('git_branch_response', {
1228
+ 'success': False,
1229
+ 'error': 'Not a git repository',
1230
+ 'working_dir': working_dir,
1231
+ 'original_working_dir': original_working_dir,
1232
+ 'git_error': result.stderr
1233
+ }, room=sid)
1234
+
1235
+ except Exception as e:
1236
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Exception in get_git_branch: {e}")
1237
+ import traceback
1238
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Stack trace: {traceback.format_exc()}")
1239
+ await self.sio.emit('git_branch_response', {
1240
+ 'success': False,
1241
+ 'error': str(e),
1242
+ 'working_dir': working_dir,
1243
+ 'original_working_dir': original_working_dir
1244
+ }, room=sid)
1245
+
1246
+ @self.sio.event
1247
+ async def check_file_tracked(sid, data):
1248
+ """Check if a file is tracked by git"""
1249
+ import subprocess
1250
+ try:
1251
+ file_path = data.get('file_path')
1252
+ working_dir = data.get('working_dir', os.getcwd())
1253
+
1254
+ if not file_path:
1255
+ await self.sio.emit('file_tracked_response', {
1256
+ 'success': False,
1257
+ 'error': 'file_path is required',
1258
+ 'file_path': file_path
1259
+ }, room=sid)
1260
+ return
1261
+
1262
+ # Use git ls-files to check if file is tracked
1263
+ result = subprocess.run(
1264
+ ["git", "-C", working_dir, "ls-files", "--", file_path],
1265
+ capture_output=True,
1266
+ text=True
1267
+ )
1268
+
1269
+ is_tracked = result.returncode == 0 and result.stdout.strip()
1270
+
1271
+ await self.sio.emit('file_tracked_response', {
1272
+ 'success': True,
1273
+ 'file_path': file_path,
1274
+ 'working_dir': working_dir,
1275
+ 'is_tracked': bool(is_tracked)
1276
+ }, room=sid)
1277
+
1278
+ except Exception as e:
1279
+ self.logger.error(f"Error checking file tracked status: {e}")
1280
+ await self.sio.emit('file_tracked_response', {
1281
+ 'success': False,
1282
+ 'error': str(e),
1283
+ 'file_path': data.get('file_path', 'unknown')
1284
+ }, room=sid)
1285
+
1286
+ @self.sio.event
1287
+ async def read_file(sid, data):
1288
+ """Read file contents safely"""
1289
+ try:
1290
+ file_path = data.get('file_path')
1291
+ working_dir = data.get('working_dir', os.getcwd())
1292
+ max_size = data.get('max_size', SystemLimits.MAX_FILE_SIZE)
1293
+
1294
+ if not file_path:
1295
+ await self.sio.emit('file_content_response', {
1296
+ 'success': False,
1297
+ 'error': 'file_path is required',
1298
+ 'file_path': file_path
1299
+ }, room=sid)
1300
+ return
1301
+
1302
+ # Use the shared file reading logic
1303
+ result = await self._read_file_safely(file_path, working_dir, max_size)
1304
+
1305
+ # Send the result back to the client
1306
+ await self.sio.emit('file_content_response', result, room=sid)
1307
+
1308
+ except Exception as e:
1309
+ self.logger.error(f"Error reading file: {e}")
1310
+ await self.sio.emit('file_content_response', {
1311
+ 'success': False,
1312
+ 'error': str(e),
1313
+ 'file_path': data.get('file_path', 'unknown')
1314
+ }, room=sid)
1315
+
1316
+ @self.sio.event
1317
+ async def check_git_status(sid, data):
1318
+ """Check git status for a file to determine if git diff icons should be shown"""
1319
+ import subprocess
1320
+ try:
1321
+ file_path = data.get('file_path')
1322
+ working_dir = data.get('working_dir', os.getcwd())
1323
+
1324
+ self.logger.info(f"[GIT-STATUS-DEBUG] check_git_status called with file_path: {repr(file_path)}, working_dir: {repr(working_dir)}")
1325
+
1326
+ if not file_path:
1327
+ await self.sio.emit('git_status_response', {
1328
+ 'success': False,
1329
+ 'error': 'file_path is required',
1330
+ 'file_path': file_path
1331
+ }, room=sid)
1332
+ return
1333
+
1334
+ # Validate and sanitize working_dir
1335
+ original_working_dir = working_dir
1336
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '' or working_dir == '.':
1337
+ working_dir = os.getcwd()
1338
+ self.logger.info(f"[GIT-STATUS-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1339
+ else:
1340
+ self.logger.info(f"[GIT-STATUS-DEBUG] Using provided working_dir: {working_dir}")
1341
+
1342
+ # Check if the working directory exists and is a directory
1343
+ if not os.path.exists(working_dir):
1344
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Directory does not exist: {working_dir}")
1345
+ await self.sio.emit('git_status_response', {
1346
+ 'success': False,
1347
+ 'error': f'Directory does not exist: {working_dir}',
1348
+ 'file_path': file_path,
1349
+ 'working_dir': working_dir,
1350
+ 'original_working_dir': original_working_dir
1351
+ }, room=sid)
1352
+ return
1353
+
1354
+ if not os.path.isdir(working_dir):
1355
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Path is not a directory: {working_dir}")
1356
+ await self.sio.emit('git_status_response', {
1357
+ 'success': False,
1358
+ 'error': f'Path is not a directory: {working_dir}',
1359
+ 'file_path': file_path,
1360
+ 'working_dir': working_dir,
1361
+ 'original_working_dir': original_working_dir
1362
+ }, room=sid)
1363
+ return
1364
+
1365
+ # Check if this is a git repository
1366
+ self.logger.info(f"[GIT-STATUS-DEBUG] Checking if {working_dir} is a git repository")
1367
+ git_check = subprocess.run(
1368
+ ["git", "-C", working_dir, "rev-parse", "--git-dir"],
1369
+ capture_output=True,
1370
+ text=True
1371
+ )
1372
+
1373
+ if git_check.returncode != 0:
1374
+ self.logger.info(f"[GIT-STATUS-DEBUG] Not a git repository: {working_dir}")
1375
+ await self.sio.emit('git_status_response', {
1376
+ 'success': False,
1377
+ 'error': 'Not a git repository',
1378
+ 'file_path': file_path,
1379
+ 'working_dir': working_dir,
1380
+ 'original_working_dir': original_working_dir
1381
+ }, room=sid)
1382
+ return
1383
+
1384
+ # Determine if the file path should be made relative to git root
1385
+ file_path_for_git = file_path
1386
+ if os.path.isabs(file_path):
1387
+ # Get git root to make path relative if needed
1388
+ git_root_result = subprocess.run(
1389
+ ["git", "-C", working_dir, "rev-parse", "--show-toplevel"],
1390
+ capture_output=True,
1391
+ text=True
1392
+ )
1393
+
1394
+ if git_root_result.returncode == 0:
1395
+ git_root = git_root_result.stdout.strip()
1396
+ try:
1397
+ file_path_for_git = os.path.relpath(file_path, git_root)
1398
+ self.logger.info(f"[GIT-STATUS-DEBUG] Made file path relative to git root: {file_path_for_git}")
1399
+ except ValueError:
1400
+ # File is not under git root - keep original path
1401
+ self.logger.info(f"[GIT-STATUS-DEBUG] File not under git root, keeping original path: {file_path}")
1402
+ pass
1403
+
1404
+ # Check if the file exists
1405
+ full_path = file_path if os.path.isabs(file_path) else os.path.join(working_dir, file_path)
1406
+ if not os.path.exists(full_path):
1407
+ self.logger.warning(f"[GIT-STATUS-DEBUG] File does not exist: {full_path}")
1408
+ await self.sio.emit('git_status_response', {
1409
+ 'success': False,
1410
+ 'error': f'File does not exist: {file_path}',
1411
+ 'file_path': file_path,
1412
+ 'working_dir': working_dir,
1413
+ 'original_working_dir': original_working_dir
1414
+ }, room=sid)
1415
+ return
1416
+
1417
+ # Check git status for the file - this succeeds if git knows about the file
1418
+ # (either tracked, modified, staged, etc.)
1419
+ self.logger.info(f"[GIT-STATUS-DEBUG] Checking git status for file: {file_path_for_git}")
1420
+ git_status_result = subprocess.run(
1421
+ ["git", "-C", working_dir, "status", "--porcelain", file_path_for_git],
1422
+ capture_output=True,
1423
+ text=True
1424
+ )
1425
+
1426
+ self.logger.info(f"[GIT-STATUS-DEBUG] Git status result: returncode={git_status_result.returncode}, stdout={repr(git_status_result.stdout)}, stderr={repr(git_status_result.stderr)}")
1427
+
1428
+ # Also check if file is tracked by git (alternative approach)
1429
+ ls_files_result = subprocess.run(
1430
+ ["git", "-C", working_dir, "ls-files", file_path_for_git],
1431
+ capture_output=True,
1432
+ text=True
1433
+ )
1434
+
1435
+ is_tracked = ls_files_result.returncode == 0 and ls_files_result.stdout.strip()
1436
+ has_status = git_status_result.returncode == 0
1437
+
1438
+ self.logger.info(f"[GIT-STATUS-DEBUG] File tracking status: is_tracked={is_tracked}, has_status={has_status}")
1439
+
1440
+ # Success if git knows about the file (either tracked or has status changes)
1441
+ if is_tracked or has_status:
1442
+ self.logger.info(f"[GIT-STATUS-DEBUG] Git status check successful for {file_path}")
1443
+ await self.sio.emit('git_status_response', {
1444
+ 'success': True,
1445
+ 'file_path': file_path,
1446
+ 'working_dir': working_dir,
1447
+ 'original_working_dir': original_working_dir,
1448
+ 'is_tracked': is_tracked,
1449
+ 'has_changes': bool(git_status_result.stdout.strip()) if has_status else False
1450
+ }, room=sid)
1451
+ else:
1452
+ self.logger.info(f"[GIT-STATUS-DEBUG] File {file_path} is not tracked by git")
1453
+ await self.sio.emit('git_status_response', {
1454
+ 'success': False,
1455
+ 'error': 'File is not tracked by git',
1456
+ 'file_path': file_path,
1457
+ 'working_dir': working_dir,
1458
+ 'original_working_dir': original_working_dir,
1459
+ 'is_tracked': False
1460
+ }, room=sid)
1461
+
1462
+ except Exception as e:
1463
+ self.logger.error(f"[GIT-STATUS-DEBUG] Exception in check_git_status: {e}")
1464
+ import traceback
1465
+ self.logger.error(f"[GIT-STATUS-DEBUG] Stack trace: {traceback.format_exc()}")
1466
+ await self.sio.emit('git_status_response', {
1467
+ 'success': False,
1468
+ 'error': str(e),
1469
+ 'file_path': data.get('file_path', 'unknown'),
1470
+ 'working_dir': data.get('working_dir', 'unknown')
1471
+ }, room=sid)
1472
+
1473
+ @self.sio.event
1474
+ async def git_add_file(sid, data):
1475
+ """Add file to git tracking"""
1476
+ import subprocess
1477
+ try:
1478
+ file_path = data.get('file_path')
1479
+ working_dir = data.get('working_dir', os.getcwd())
1480
+
1481
+ self.logger.info(f"[GIT-ADD-DEBUG] git_add_file called with file_path: {repr(file_path)}, working_dir: {repr(working_dir)} (type: {type(working_dir)})")
1482
+
1483
+ if not file_path:
1484
+ await self.sio.emit('git_add_response', {
1485
+ 'success': False,
1486
+ 'error': 'file_path is required',
1487
+ 'file_path': file_path
1488
+ }, room=sid)
1489
+ return
1490
+
1491
+ # Validate and sanitize working_dir
1492
+ original_working_dir = working_dir
1493
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '' or working_dir == '.':
1494
+ working_dir = os.getcwd()
1495
+ self.logger.info(f"[GIT-ADD-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1496
+ else:
1497
+ self.logger.info(f"[GIT-ADD-DEBUG] Using provided working_dir: {working_dir}")
1498
+
1499
+ # Validate that the directory exists and is a valid path
1500
+ if not os.path.exists(working_dir):
1501
+ self.logger.warning(f"[GIT-ADD-DEBUG] Directory does not exist: {working_dir}")
1502
+ await self.sio.emit('git_add_response', {
1503
+ 'success': False,
1504
+ 'error': f'Directory does not exist: {working_dir}',
1505
+ 'file_path': file_path,
1506
+ 'working_dir': working_dir,
1507
+ 'original_working_dir': original_working_dir
1508
+ }, room=sid)
1509
+ return
1510
+
1511
+ if not os.path.isdir(working_dir):
1512
+ self.logger.warning(f"[GIT-ADD-DEBUG] Path is not a directory: {working_dir}")
1513
+ await self.sio.emit('git_add_response', {
1514
+ 'success': False,
1515
+ 'error': f'Path is not a directory: {working_dir}',
1516
+ 'file_path': file_path,
1517
+ 'working_dir': working_dir,
1518
+ 'original_working_dir': original_working_dir
1519
+ }, room=sid)
1520
+ return
1521
+
1522
+ self.logger.info(f"[GIT-ADD-DEBUG] Running git add command in directory: {working_dir}")
1523
+
1524
+ # Use git add to track the file
1525
+ result = subprocess.run(
1526
+ ["git", "-C", working_dir, "add", file_path],
1527
+ capture_output=True,
1528
+ text=True
1529
+ )
1530
+
1531
+ self.logger.info(f"[GIT-ADD-DEBUG] Git add result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1532
+
1533
+ if result.returncode == 0:
1534
+ self.logger.info(f"[GIT-ADD-DEBUG] Successfully added {file_path} to git in {working_dir}")
1535
+ await self.sio.emit('git_add_response', {
1536
+ 'success': True,
1537
+ 'file_path': file_path,
1538
+ 'working_dir': working_dir,
1539
+ 'original_working_dir': original_working_dir,
1540
+ 'message': 'File successfully added to git tracking'
1541
+ }, room=sid)
1542
+ else:
1543
+ error_message = result.stderr.strip() or 'Unknown git error'
1544
+ self.logger.warning(f"[GIT-ADD-DEBUG] Git add failed: {error_message}")
1545
+ await self.sio.emit('git_add_response', {
1546
+ 'success': False,
1547
+ 'error': f'Git add failed: {error_message}',
1548
+ 'file_path': file_path,
1549
+ 'working_dir': working_dir,
1550
+ 'original_working_dir': original_working_dir
1551
+ }, room=sid)
1552
+
1553
+ except Exception as e:
1554
+ self.logger.error(f"[GIT-ADD-DEBUG] Exception in git_add_file: {e}")
1555
+ import traceback
1556
+ self.logger.error(f"[GIT-ADD-DEBUG] Stack trace: {traceback.format_exc()}")
1557
+ await self.sio.emit('git_add_response', {
1558
+ 'success': False,
1559
+ 'error': str(e),
1560
+ 'file_path': data.get('file_path', 'unknown'),
1561
+ 'working_dir': data.get('working_dir', 'unknown')
1562
+ }, room=sid)
1563
+
1564
+ async def _send_current_status(self, sid: str):
1565
+ """Send current system status to a client."""
1566
+ try:
1567
+ status = {
1568
+ "type": "system.status",
1569
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1570
+ "data": {
1571
+ "session_id": self.session_id,
1572
+ "session_start": self.session_start,
1573
+ "claude_status": self.claude_status,
1574
+ "claude_pid": self.claude_pid,
1575
+ "connected_clients": len(self.clients),
1576
+ "websocket_port": self.port,
1577
+ "instance_info": {
1578
+ "port": self.port,
1579
+ "host": self.host,
1580
+ "working_dir": os.getcwd() if self.session_id else None
1581
+ }
1582
+ }
1583
+ }
1584
+ await self.sio.emit('claude_event', status, room=sid)
1585
+ self.logger.debug("Sent status to client")
1586
+ except Exception as e:
1587
+ self.logger.error(f"Failed to send status to client: {e}")
1588
+ raise
1589
+
1590
+ async def _send_event_history(self, sid: str, event_types: list = None, limit: int = 50):
1591
+ """Send event history to a specific client.
1592
+
1593
+ WHY: When clients connect to the dashboard, they need context from recent events
1594
+ to understand what's been happening. This sends the most recent events in
1595
+ chronological order (oldest first) so the dashboard displays them properly.
1596
+
1597
+ Args:
1598
+ sid: Socket.IO session ID of the client
1599
+ event_types: Optional list of event types to filter by
1600
+ limit: Maximum number of events to send (default: 50)
1601
+ """
1602
+ try:
1603
+ if not self.event_history:
1604
+ self.logger.debug(f"No event history to send to client {sid}")
1605
+ return
1606
+
1607
+ # Limit to reasonable number to avoid overwhelming client
1608
+ limit = min(limit, 100)
1609
+
1610
+ # Get the most recent events, filtered by type if specified
1611
+ history = []
1612
+ for event in reversed(self.event_history):
1613
+ if not event_types or event.get("type") in event_types:
1614
+ history.append(event)
1615
+ if len(history) >= limit:
1616
+ break
1617
+
1618
+ # Reverse to get chronological order (oldest first)
1619
+ history = list(reversed(history))
1620
+
1621
+ if history:
1622
+ # Send as 'history' event that the client expects
1623
+ await self.sio.emit('history', {
1624
+ "events": history,
1625
+ "count": len(history),
1626
+ "total_available": len(self.event_history)
1627
+ }, room=sid)
1628
+
1629
+ self.logger.info(f"📚 Sent {len(history)} historical events to client {sid}")
1630
+ else:
1631
+ self.logger.debug(f"No matching events found for client {sid} with filters: {event_types}")
1632
+
1633
+ except Exception as e:
1634
+ self.logger.error(f"❌ Failed to send event history to client {sid}: {e}")
1635
+ import traceback
1636
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
1637
+
1638
+ def broadcast_event(self, event_type: str, data: Dict[str, Any]):
1639
+ """Broadcast an event to all connected clients."""
1640
+ if not SOCKETIO_AVAILABLE:
1641
+ self.logger.debug(f"⚠️ Socket.IO broadcast skipped - packages not available")
1642
+ return
1643
+
1644
+ event = {
1645
+ "type": event_type,
1646
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1647
+ "data": data
1648
+ }
1649
+
1650
+ self.logger.info(f"📤 BROADCASTING EVENT: {event_type}")
1651
+ self.logger.debug(f"📄 Event data: {json.dumps(data, indent=2)[:200]}...")
1652
+
1653
+ # Store in history
1654
+ self.event_history.append(event)
1655
+ self.logger.debug(f"📚 Event stored in history (total: {len(self.event_history)})")
1656
+
1657
+ # Check if we have clients and event loop
1658
+ if not self.clients:
1659
+ self.logger.warning(f"⚠️ No Socket.IO clients connected - event will not be delivered")
1660
+ return
1661
+
1662
+ if not self.loop or not self.sio:
1663
+ self.logger.error(f"❌ No event loop or Socket.IO instance available - cannot broadcast event")
1664
+ return
1665
+
1666
+ self.logger.info(f"🎯 Broadcasting to {len(self.clients)} clients via event loop")
1667
+
1668
+ # Broadcast to clients with timeout and error handling
1669
+ try:
1670
+ # Check if the event loop is still running and not closed
1671
+ if self.loop and not self.loop.is_closed() and self.loop.is_running():
1672
+ future = asyncio.run_coroutine_threadsafe(
1673
+ self.sio.emit('claude_event', event),
1674
+ self.loop
1675
+ )
1676
+ # Wait for completion with timeout to detect issues
1677
+ try:
1678
+ future.result(timeout=TimeoutConfig.QUICK_TIMEOUT)
1679
+ self.logger.debug(f"📨 Successfully broadcasted {event_type} to {len(self.clients)} clients")
1680
+ except asyncio.TimeoutError:
1681
+ self.logger.warning(f"⏰ Broadcast timeout for event {event_type} - continuing anyway")
1682
+ except Exception as emit_error:
1683
+ self.logger.error(f"❌ Broadcast emit error for {event_type}: {emit_error}")
1684
+ else:
1685
+ self.logger.warning(f"⚠️ Event loop not available for broadcast of {event_type} - event loop closed or not running")
1686
+ except Exception as e:
1687
+ self.logger.error(f"❌ Failed to submit broadcast to event loop: {e}")
1688
+ import traceback
1689
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
1690
+
1691
+ # Convenience methods for common events (same interface as WebSocketServer)
1692
+
1693
+ def session_started(self, session_id: str, launch_method: str, working_dir: str):
1694
+ """Notify that a session has started."""
1695
+ self.session_id = session_id
1696
+ self.session_start = datetime.utcnow().isoformat() + "Z"
1697
+ self.broadcast_event("session.start", {
1698
+ "session_id": session_id,
1699
+ "start_time": self.session_start,
1700
+ "launch_method": launch_method,
1701
+ "working_directory": working_dir,
1702
+ "websocket_port": self.port,
1703
+ "instance_info": {
1704
+ "port": self.port,
1705
+ "host": self.host,
1706
+ "working_dir": working_dir
1707
+ }
1708
+ })
1709
+
1710
+ def session_ended(self):
1711
+ """Notify that a session has ended."""
1712
+ if self.session_id:
1713
+ duration = None
1714
+ if self.session_start:
1715
+ start = datetime.fromisoformat(self.session_start.replace("Z", "+00:00"))
1716
+ duration = (datetime.utcnow() - start.replace(tzinfo=None)).total_seconds()
1717
+
1718
+ self.broadcast_event("session.end", {
1719
+ "session_id": self.session_id,
1720
+ "end_time": datetime.utcnow().isoformat() + "Z",
1721
+ "duration_seconds": duration
1722
+ })
1723
+
1724
+ self.session_id = None
1725
+ self.session_start = None
1726
+
1727
+ def claude_status_changed(self, status: str, pid: Optional[int] = None, message: str = ""):
1728
+ """Notify Claude status change."""
1729
+ self.claude_status = status
1730
+ self.claude_pid = pid
1731
+ self.broadcast_event("claude.status", {
1732
+ "status": status,
1733
+ "pid": pid,
1734
+ "message": message
1735
+ })
1736
+
1737
+ def claude_output(self, content: str, stream: str = "stdout"):
1738
+ """Broadcast Claude output."""
1739
+ self.broadcast_event("claude.output", {
1740
+ "content": content,
1741
+ "stream": stream
1742
+ })
1743
+
1744
+ def agent_delegated(self, agent: str, task: str, status: str = "started"):
1745
+ """Notify agent delegation."""
1746
+ self.broadcast_event("agent.delegation", {
1747
+ "agent": agent,
1748
+ "task": task,
1749
+ "status": status,
1750
+ "timestamp": datetime.utcnow().isoformat() + "Z"
1751
+ })
1752
+
1753
+ def todo_updated(self, todos: List[Dict[str, Any]]):
1754
+ """Notify todo list update."""
1755
+ stats = {
1756
+ "total": len(todos),
1757
+ "completed": sum(1 for t in todos if t.get("status") == "completed"),
1758
+ "in_progress": sum(1 for t in todos if t.get("status") == "in_progress"),
1759
+ "pending": sum(1 for t in todos if t.get("status") == "pending")
1760
+ }
1761
+
1762
+ self.broadcast_event("todo.update", {
1763
+ "todos": todos,
1764
+ "stats": stats
1765
+ })
1766
+
1767
+ def ticket_created(self, ticket_id: str, title: str, priority: str = "medium"):
1768
+ """Notify ticket creation."""
1769
+ self.broadcast_event("ticket.created", {
1770
+ "id": ticket_id,
1771
+ "title": title,
1772
+ "priority": priority,
1773
+ "created_at": datetime.utcnow().isoformat() + "Z"
1774
+ })
1775
+
1776
+ def memory_loaded(self, agent_id: str, memory_size: int, sections_count: int):
1777
+ """Notify when agent memory is loaded from file."""
1778
+ self.broadcast_event("memory:loaded", {
1779
+ "agent_id": agent_id,
1780
+ "memory_size": memory_size,
1781
+ "sections_count": sections_count,
1782
+ "timestamp": datetime.utcnow().isoformat() + "Z"
1783
+ })
1784
+
1785
+ def memory_created(self, agent_id: str, template_type: str):
1786
+ """Notify when new agent memory is created from template."""
1787
+ self.broadcast_event("memory:created", {
1788
+ "agent_id": agent_id,
1789
+ "template_type": template_type,
1790
+ "timestamp": datetime.utcnow().isoformat() + "Z"
1791
+ })
1792
+
1793
+ def memory_updated(self, agent_id: str, learning_type: str, content: str, section: str):
1794
+ """Notify when learning is added to agent memory."""
1795
+ self.broadcast_event("memory:updated", {
1796
+ "agent_id": agent_id,
1797
+ "learning_type": learning_type,
1798
+ "content": content,
1799
+ "section": section,
1800
+ "timestamp": datetime.utcnow().isoformat() + "Z"
1801
+ })
1802
+
1803
+ def memory_injected(self, agent_id: str, context_size: int):
1804
+ """Notify when agent memory is injected into context."""
1805
+ self.broadcast_event("memory:injected", {
1806
+ "agent_id": agent_id,
1807
+ "context_size": context_size,
1808
+ "timestamp": datetime.utcnow().isoformat() + "Z"
1809
+ })
1810
+
1811
+ # ================================================================================
1812
+ # Interface Adapter Methods
1813
+ # ================================================================================
1814
+ # These methods adapt the existing implementation to comply with SocketIOServiceInterface
1815
+
1816
+ async def start(self, host: str = "localhost", port: int = 8765) -> None:
1817
+ """Start the WebSocket server (async adapter).
1818
+
1819
+ WHY: The interface expects async methods, but our implementation uses
1820
+ synchronous start with background threads. This adapter provides compatibility.
1821
+
1822
+ Args:
1823
+ host: Host to bind to
1824
+ port: Port to listen on
1825
+ """
1826
+ self.host = host
1827
+ self.port = port
1828
+ # Call the synchronous start method
1829
+ self.start_sync()
1830
+
1831
+ async def stop(self) -> None:
1832
+ """Stop the WebSocket server (async adapter).
1833
+
1834
+ WHY: The interface expects async methods. This adapter wraps the
1835
+ synchronous stop method for interface compliance.
1836
+ """
1837
+ # Call the synchronous stop method
1838
+ self.stop_sync()
1839
+
1840
+ async def emit(self, event: str, data: Any, room: Optional[str] = None) -> None:
1841
+ """Emit an event to connected clients.
1842
+
1843
+ WHY: Provides interface compliance by wrapping broadcast_event with
1844
+ async signature and room support.
1845
+
1846
+ Args:
1847
+ event: Event name
1848
+ data: Event data
1849
+ room: Optional room to target (not supported in current implementation)
1850
+ """
1851
+ if room:
1852
+ self.logger.warning(f"Room-based emit not supported, broadcasting to all: {event}")
1853
+
1854
+ # Use existing broadcast_event method
1855
+ self.broadcast_event(event, data)
1856
+
1857
+ async def broadcast(self, event: str, data: Any) -> None:
1858
+ """Broadcast event to all connected clients.
1859
+
1860
+ WHY: Provides interface compliance with async signature.
1861
+
1862
+ Args:
1863
+ event: Event name
1864
+ data: Event data
1865
+ """
1866
+ self.broadcast_event(event, data)
1867
+
1868
+ def get_connection_count(self) -> int:
1869
+ """Get number of connected clients.
1870
+
1871
+ WHY: Provides interface compliance for monitoring connections.
1872
+
1873
+ Returns:
1874
+ Number of active connections
1875
+ """
1876
+ return len(self.clients)
1877
+
1878
+ def is_running(self) -> bool:
1879
+ """Check if server is running.
1880
+
1881
+ WHY: Provides interface compliance for status checking.
1882
+
1883
+ Returns:
1884
+ True if server is active
1885
+ """
1886
+ return self.running
1887
+
1888
+
1889
+ # Global instance for easy access
1890
+ _socketio_server: Optional[SocketIOServer] = None
1891
+
1892
+
1893
+ def get_socketio_server() -> SocketIOServer:
1894
+ """Get or create the global Socket.IO server instance.
1895
+
1896
+ WHY: In exec mode, a persistent Socket.IO server may already be running
1897
+ in a separate process. We need to detect this and create a client proxy
1898
+ instead of trying to start another server.
1899
+ """
1900
+ global _socketio_server
1901
+ if _socketio_server is None:
1902
+ # Check if a Socket.IO server is already running on the default port
1903
+ import socket
1904
+ try:
1905
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1906
+ s.settimeout(0.5)
1907
+ result = s.connect_ex(('127.0.0.1', 8765))
1908
+ if result == 0:
1909
+ # Server is already running - create a client proxy
1910
+ _socketio_server = SocketIOClientProxy(port=8765)
1911
+ else:
1912
+ # No server running - create a real server
1913
+ _socketio_server = SocketIOServer()
1914
+ except Exception:
1915
+ # On any error, create a real server
1916
+ _socketio_server = SocketIOServer()
1917
+
1918
+ return _socketio_server
1919
+
1920
+
1921
+ def start_socketio_server():
1922
+ """Start the global Socket.IO server."""
1923
+ server = get_socketio_server()
1924
+ server.start_sync()
1925
+ return server
1926
+
1927
+
1928
+ def stop_socketio_server():
1929
+ """Stop the global Socket.IO server."""
1930
+ global _socketio_server
1931
+ if _socketio_server:
1932
+ _socketio_server.stop_sync()
1933
+ _socketio_server = None