claude-mpm 4.2.7__py3-none-any.whl → 4.2.11__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 (57) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/cli/commands/dashboard.py +62 -120
  3. claude_mpm/cli/commands/monitor.py +71 -212
  4. claude_mpm/cli/commands/run.py +33 -33
  5. claude_mpm/cli/parser.py +79 -2
  6. claude_mpm/cli/parsers/__init__.py +29 -0
  7. claude_mpm/dashboard/static/css/code-tree.css +16 -4
  8. claude_mpm/dashboard/static/css/dashboard.css +15 -1
  9. claude_mpm/dashboard/static/dist/components/code-tree.js +1 -1
  10. claude_mpm/dashboard/static/dist/components/file-viewer.js +2 -0
  11. claude_mpm/dashboard/static/dist/components/module-viewer.js +1 -1
  12. claude_mpm/dashboard/static/dist/components/unified-data-viewer.js +1 -1
  13. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  14. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  15. claude_mpm/dashboard/static/js/components/code-tree.js +775 -142
  16. claude_mpm/dashboard/static/js/components/file-viewer.js +538 -0
  17. claude_mpm/dashboard/static/js/components/module-viewer.js +26 -0
  18. claude_mpm/dashboard/static/js/components/unified-data-viewer.js +166 -14
  19. claude_mpm/dashboard/static/js/dashboard.js +108 -91
  20. claude_mpm/dashboard/static/js/socket-client.js +9 -7
  21. claude_mpm/dashboard/templates/index.html +5 -2
  22. claude_mpm/hooks/claude_hooks/hook_handler.py +1 -11
  23. claude_mpm/hooks/claude_hooks/services/connection_manager.py +54 -59
  24. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +112 -72
  25. claude_mpm/services/agents/deployment/agent_format_converter.py +3 -3
  26. claude_mpm/services/agents/deployment/agent_template_builder.py +3 -5
  27. claude_mpm/services/cli/unified_dashboard_manager.py +354 -0
  28. claude_mpm/services/monitor/__init__.py +20 -0
  29. claude_mpm/services/monitor/daemon.py +256 -0
  30. claude_mpm/services/monitor/event_emitter.py +279 -0
  31. claude_mpm/services/monitor/handlers/__init__.py +20 -0
  32. claude_mpm/services/monitor/handlers/code_analysis.py +334 -0
  33. claude_mpm/services/monitor/handlers/dashboard.py +298 -0
  34. claude_mpm/services/monitor/handlers/hooks.py +491 -0
  35. claude_mpm/services/monitor/management/__init__.py +18 -0
  36. claude_mpm/services/monitor/management/health.py +124 -0
  37. claude_mpm/services/monitor/management/lifecycle.py +298 -0
  38. claude_mpm/services/monitor/server.py +442 -0
  39. claude_mpm/services/socketio/client_proxy.py +20 -12
  40. claude_mpm/services/socketio/dashboard_server.py +4 -4
  41. claude_mpm/services/socketio/monitor_client.py +4 -6
  42. claude_mpm/tools/code_tree_analyzer.py +33 -17
  43. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/METADATA +1 -1
  44. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/RECORD +48 -43
  45. claude_mpm/cli/commands/socketio_monitor.py +0 -233
  46. claude_mpm/scripts/socketio_daemon.py +0 -571
  47. claude_mpm/scripts/socketio_daemon_hardened.py +0 -937
  48. claude_mpm/scripts/socketio_daemon_wrapper.py +0 -78
  49. claude_mpm/scripts/socketio_server_manager.py +0 -349
  50. claude_mpm/services/cli/dashboard_launcher.py +0 -423
  51. claude_mpm/services/cli/socketio_manager.py +0 -595
  52. claude_mpm/services/dashboard/stable_server.py +0 -962
  53. claude_mpm/services/socketio/monitor_server.py +0 -505
  54. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/WHEEL +0 -0
  55. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/entry_points.txt +0 -0
  56. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/licenses/LICENSE +0 -0
  57. {claude_mpm-4.2.7.dist-info → claude_mpm-4.2.11.dist-info}/top_level.txt +0 -0
@@ -1,962 +0,0 @@
1
- """
2
- Stable Dashboard Server for claude-mpm.
3
-
4
- WHY: This module provides a simple, stable HTTP + SocketIO server that works
5
- across all installation methods (direct, pip, pipx, homebrew, npm).
6
-
7
- DESIGN DECISIONS:
8
- - Uses proven python-socketio + aiohttp combination
9
- - Automatically finds dashboard files across installation methods
10
- - Provides both HTTP endpoints and SocketIO real-time features
11
- - Simple mock AST analysis to avoid complex backend dependencies
12
- - Graceful fallbacks for missing dependencies
13
- """
14
-
15
- import asyncio
16
- import glob
17
- import json
18
- import logging
19
- import os
20
- import sys
21
- import time
22
- import traceback
23
- from collections import deque
24
- from datetime import datetime
25
- from pathlib import Path
26
- from typing import Any, Deque, Dict, Optional
27
-
28
- try:
29
- import aiohttp
30
- import socketio
31
- from aiohttp import web
32
-
33
- DEPENDENCIES_AVAILABLE = True
34
- except ImportError:
35
- DEPENDENCIES_AVAILABLE = False
36
- socketio = None
37
- aiohttp = None
38
- web = None
39
-
40
- # Set up logging
41
- logging.basicConfig(
42
- level=logging.INFO,
43
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
44
- )
45
- logger = logging.getLogger(__name__)
46
-
47
-
48
- def find_dashboard_files() -> Optional[Path]:
49
- """Find dashboard files across different installation methods."""
50
- # Try different possible locations
51
- possible_locations = [
52
- # Development/direct install
53
- Path(__file__).parent.parent.parent / "dashboard",
54
- # Current working directory (for development)
55
- Path.cwd() / "src" / "claude_mpm" / "dashboard",
56
- # Pip install in current Python environment
57
- Path(sys.prefix)
58
- / "lib"
59
- / f"python{sys.version_info.major}.{sys.version_info.minor}"
60
- / "site-packages"
61
- / "claude_mpm"
62
- / "dashboard",
63
- # User site-packages
64
- Path.home()
65
- / ".local"
66
- / "lib"
67
- / f"python{sys.version_info.major}.{sys.version_info.minor}"
68
- / "site-packages"
69
- / "claude_mpm"
70
- / "dashboard",
71
- ]
72
-
73
- # Add glob patterns for different Python versions
74
- python_patterns = [
75
- f"/opt/homebrew/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/claude_mpm/dashboard",
76
- f"/usr/local/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/claude_mpm/dashboard",
77
- ]
78
-
79
- # Check direct paths first
80
- for location in possible_locations:
81
- if location.exists() and (location / "templates" / "index.html").exists():
82
- return location
83
-
84
- # Check pattern-based paths
85
- for pattern in python_patterns:
86
- matches = glob.glob(pattern)
87
- for match in matches:
88
- path = Path(match)
89
- if path.exists() and (path / "templates" / "index.html").exists():
90
- return path
91
-
92
- # Fallback: try to find via module import
93
- try:
94
- import claude_mpm.dashboard
95
-
96
- module_path = Path(claude_mpm.dashboard.__file__).parent
97
- if (module_path / "templates" / "index.html").exists():
98
- return module_path
99
- except ImportError:
100
- pass
101
-
102
- return None
103
-
104
-
105
- def create_mock_ast_data(file_path: str, file_name: str) -> Dict[str, Any]:
106
- """Create mock AST analysis data."""
107
- ext = file_name.split(".")[-1].lower() if "." in file_name else ""
108
-
109
- elements = []
110
- if ext == "py":
111
- elements = [
112
- {
113
- "name": "MockClass",
114
- "type": "class",
115
- "line": 10,
116
- "complexity": 2,
117
- "docstring": "Mock class for demonstration",
118
- "methods": [
119
- {"name": "__init__", "type": "method", "line": 11, "complexity": 1},
120
- {
121
- "name": "mock_method",
122
- "type": "method",
123
- "line": 15,
124
- "complexity": 1,
125
- },
126
- ],
127
- },
128
- {
129
- "name": "mock_function",
130
- "type": "function",
131
- "line": 20,
132
- "complexity": 1,
133
- "docstring": "Mock function for demonstration",
134
- },
135
- ]
136
- elif ext in ["js", "ts", "jsx", "tsx"]:
137
- elements = [
138
- {
139
- "name": "MockClass",
140
- "type": "class",
141
- "line": 5,
142
- "complexity": 2,
143
- "methods": [
144
- {
145
- "name": "constructor",
146
- "type": "method",
147
- "line": 6,
148
- "complexity": 1,
149
- },
150
- {
151
- "name": "mockMethod",
152
- "type": "method",
153
- "line": 10,
154
- "complexity": 1,
155
- },
156
- ],
157
- },
158
- {"name": "mockFunction", "type": "function", "line": 15, "complexity": 1},
159
- ]
160
-
161
- return {
162
- "path": file_path,
163
- "elements": elements,
164
- "complexity": sum(e.get("complexity", 1) for e in elements),
165
- "lines": 50,
166
- "stats": {
167
- "classes": len([e for e in elements if e["type"] == "class"]),
168
- "functions": len([e for e in elements if e["type"] == "function"]),
169
- "methods": sum(len(e.get("methods", [])) for e in elements),
170
- "lines": 50,
171
- },
172
- }
173
-
174
-
175
- class StableDashboardServer:
176
- """Stable dashboard server that works across all installation methods."""
177
-
178
- def __init__(self, host: str = "localhost", port: int = 8765, debug: bool = False):
179
- self.host = host
180
- self.port = port
181
- self.debug = debug
182
- self.dashboard_path = None
183
- self.app = None
184
- self.sio = None
185
- self.server_runner = None
186
- self.server_site = None
187
-
188
- # Event storage with circular buffer (keep last 500 events)
189
- self.event_history: Deque[Dict[str, Any]] = deque(maxlen=500)
190
- self.event_count = 0
191
- self.server_start_time = time.time()
192
- self.last_event_time = None
193
- self.connected_clients = set()
194
-
195
- # Resilience features
196
- self.retry_count = 0
197
- self.max_retries = 3
198
- self.health_check_failures = 0
199
- self.is_healthy = True
200
-
201
- # Persistent event storage (optional)
202
- self.persist_events = os.environ.get('CLAUDE_MPM_PERSIST_EVENTS', 'false').lower() == 'true'
203
- self.event_log_path = Path.home() / '.claude' / 'dashboard_events.jsonl'
204
- if self.persist_events:
205
- self.event_log_path.parent.mkdir(parents=True, exist_ok=True)
206
-
207
- def setup(self) -> bool:
208
- """Set up the server components."""
209
- if not DEPENDENCIES_AVAILABLE:
210
- print(
211
- "❌ Error: Missing dependencies. Install with: pip install aiohttp python-socketio"
212
- )
213
- return False
214
-
215
- # Find dashboard files only if not already set (for testing)
216
- if not self.dashboard_path:
217
- self.dashboard_path = find_dashboard_files()
218
- if not self.dashboard_path:
219
- print("❌ Error: Could not find dashboard files")
220
- print("Please ensure Claude MPM is properly installed")
221
- return False
222
-
223
- # Validate that the dashboard path has the required files
224
- template_path = self.dashboard_path / "templates" / "index.html"
225
- static_path = self.dashboard_path / "static"
226
-
227
- if not template_path.exists():
228
- print(f"❌ Error: Dashboard template not found at {template_path}")
229
- print("Please ensure Claude MPM dashboard files are properly installed")
230
- return False
231
-
232
- if not static_path.exists():
233
- print(f"❌ Error: Dashboard static files not found at {static_path}")
234
- print("Please ensure Claude MPM dashboard files are properly installed")
235
- return False
236
-
237
- if self.debug:
238
- print(f"🔍 Debug: Dashboard path resolved to: {self.dashboard_path}")
239
- print(f"🔍 Debug: Checking for required files...")
240
- template_exists = (self.dashboard_path / "templates" / "index.html").exists()
241
- static_exists = (self.dashboard_path / "static").exists()
242
- print(f" - templates/index.html: {template_exists}")
243
- print(f" - static directory: {static_exists}")
244
-
245
- print(f"📁 Using dashboard files from: {self.dashboard_path}")
246
-
247
- # Create SocketIO server with improved timeout settings
248
- logger_enabled = self.debug # Only enable verbose logging in debug mode
249
- self.sio = socketio.AsyncServer(
250
- cors_allowed_origins="*",
251
- logger=logger_enabled,
252
- engineio_logger=logger_enabled,
253
- ping_interval=30, # Match client's 30 second ping interval
254
- ping_timeout=60, # Match client's 60 second timeout
255
- max_http_buffer_size=1e8, # Allow larger messages
256
- )
257
- # Create app WITHOUT any static file handlers to prevent directory listing
258
- # This is critical - we only want explicit routes we define
259
- self.app = web.Application()
260
- self.sio.attach(self.app)
261
- print("✅ SocketIO server created and attached")
262
-
263
- # Set up routes
264
- self._setup_routes()
265
- self._setup_socketio_events()
266
-
267
- print("✅ Server setup complete!")
268
-
269
- return True
270
-
271
- def _setup_routes(self):
272
- """Set up HTTP routes."""
273
- # IMPORTANT: Only add explicit routes, never add static file serving for root
274
- # This prevents aiohttp from serving directory listings
275
- self.app.router.add_get("/", self._serve_dashboard)
276
- self.app.router.add_get("/index.html", self._serve_dashboard) # Also handle /index.html
277
- self.app.router.add_get("/static/{path:.*}", self._serve_static)
278
- self.app.router.add_get("/api/directory/list", self._list_directory)
279
- self.app.router.add_get("/api/file/read", self._read_file)
280
- self.app.router.add_get("/version.json", self._serve_version)
281
-
282
- # New resilience endpoints
283
- self.app.router.add_get("/health", self._health_check)
284
- self.app.router.add_get("/api/status", self._serve_status)
285
- self.app.router.add_get("/api/events/history", self._serve_event_history)
286
-
287
- # CRITICAL: Add the missing /api/events endpoint for receiving events
288
- self.app.router.add_post("/api/events", self._receive_event)
289
-
290
- def _setup_socketio_events(self):
291
- """Set up SocketIO event handlers."""
292
-
293
- @self.sio.event
294
- async def connect(sid, environ):
295
- self.connected_clients.add(sid)
296
- if self.debug:
297
- print(f"✅ SocketIO client connected: {sid}")
298
- user_agent = environ.get('HTTP_USER_AGENT', 'Unknown')
299
- # Truncate long user agents for readability
300
- if len(user_agent) > 80:
301
- user_agent = user_agent[:77] + "..."
302
- print(f" Client info: {user_agent}")
303
-
304
- # Send connection confirmation
305
- await self.sio.emit(
306
- "connection_test", {"status": "connected", "server": "stable"}, room=sid
307
- )
308
-
309
- # Send recent event history to new client
310
- if self.event_history:
311
- # Send last 20 events to catch up new client
312
- recent_events = list(self.event_history)[-20:]
313
- for event in recent_events:
314
- await self.sio.emit("claude_event", event, room=sid)
315
-
316
- @self.sio.event
317
- async def disconnect(sid):
318
- self.connected_clients.discard(sid)
319
- if self.debug:
320
- print(f"📤 SocketIO client disconnected: {sid}")
321
-
322
- @self.sio.event
323
- async def code_analyze_file(sid, data):
324
- if self.debug:
325
- print(
326
- f"📡 Received file analysis request from {sid}: {data.get('path', 'unknown')}"
327
- )
328
-
329
- file_path = data.get("path", "")
330
- file_name = file_path.split("/")[-1] if file_path else "unknown"
331
-
332
- # Create mock response
333
- response = create_mock_ast_data(file_path, file_name)
334
-
335
- if self.debug:
336
- print(f"📤 Sending analysis response: {len(response['elements'])} elements")
337
- await self.sio.emit("code:file:analyzed", response, room=sid)
338
-
339
- # CRITICAL: Handle the actual event name with colons that the client sends
340
- @self.sio.on("code:analyze:file")
341
- async def handle_code_analyze_file(sid, data):
342
- if self.debug:
343
- print(
344
- f"📡 Received code:analyze:file from {sid}: {data.get('path', 'unknown')}"
345
- )
346
-
347
- file_path = data.get("path", "")
348
- file_name = file_path.split("/")[-1] if file_path else "unknown"
349
-
350
- # Create mock response
351
- response = create_mock_ast_data(file_path, file_name)
352
-
353
- if self.debug:
354
- print(f"📤 Sending analysis response: {len(response['elements'])} elements")
355
- await self.sio.emit("code:file:analyzed", response, room=sid)
356
-
357
- # Handle other events the dashboard sends
358
- @self.sio.event
359
- async def get_git_branch(sid, data):
360
- if self.debug:
361
- print(f"📡 Received git branch request from {sid}: {data}")
362
- await self.sio.emit(
363
- "git_branch_response", {"branch": "main", "path": data}, room=sid
364
- )
365
-
366
- @self.sio.event
367
- async def request_status(sid, data):
368
- if self.debug:
369
- print(f"📡 Received status request from {sid}")
370
- await self.sio.emit(
371
- "status_response", {"status": "running", "server": "stable"}, room=sid
372
- )
373
-
374
- # Handle the event with dots (SocketIO converts colons to dots sometimes)
375
- @self.sio.event
376
- async def request_dot_status(sid, data):
377
- if self.debug:
378
- print(f"📡 Received request.status from {sid}")
379
- await self.sio.emit(
380
- "status_response", {"status": "running", "server": "stable"}, room=sid
381
- )
382
-
383
- @self.sio.event
384
- async def code_discover_top_level(sid, data):
385
- if self.debug:
386
- print(f"📡 Received top-level discovery request from {sid}")
387
- await self.sio.emit("code:top_level:discovered", {"status": "ok"}, room=sid)
388
-
389
- # Mock event generator when no real events
390
- @self.sio.event
391
- async def request_mock_event(sid, data):
392
- """Generate a mock event for testing."""
393
- if self.debug:
394
- print(f"📡 Mock event requested by {sid}")
395
-
396
- mock_event = self._create_mock_event()
397
- # Store and broadcast like a real event
398
- self.event_count += 1
399
- self.last_event_time = datetime.now()
400
- self.event_history.append(mock_event)
401
- await self.sio.emit("claude_event", mock_event)
402
-
403
- def _create_mock_event(self) -> Dict[str, Any]:
404
- """Create a mock event for testing/demo purposes."""
405
- import random
406
-
407
- event_types = ["file", "command", "test", "build", "deploy"]
408
- event_subtypes = ["start", "progress", "complete", "error", "warning"]
409
-
410
- return {
411
- "type": random.choice(event_types),
412
- "subtype": random.choice(event_subtypes),
413
- "timestamp": datetime.now().isoformat(),
414
- "source": "mock",
415
- "data": {
416
- "message": f"Mock {random.choice(['operation', 'task', 'process'])} {random.choice(['started', 'completed', 'in progress'])}",
417
- "file": f"/path/to/file_{random.randint(1, 100)}.py",
418
- "line": random.randint(1, 500),
419
- "progress": random.randint(0, 100)
420
- },
421
- "session_id": "mock-session",
422
- "server_event_id": self.event_count + 1
423
- }
424
-
425
- async def _start_mock_event_generator(self):
426
- """Start generating mock events if no real events for a while."""
427
- try:
428
- while True:
429
- await asyncio.sleep(30) # Check every 30 seconds
430
-
431
- # If no events in last 60 seconds and clients connected, generate mock
432
- if self.connected_clients and (
433
- not self.last_event_time or
434
- (datetime.now() - self.last_event_time).total_seconds() > 60
435
- ):
436
- if self.debug:
437
- print("⏰ No recent events, generating mock event")
438
-
439
- mock_event = self._create_mock_event()
440
- self.event_count += 1
441
- self.last_event_time = datetime.now()
442
- self.event_history.append(mock_event)
443
-
444
- await self.sio.emit("claude_event", mock_event)
445
- except asyncio.CancelledError:
446
- pass
447
- except Exception as e:
448
- logger.error(f"Mock event generator error: {e}")
449
-
450
- async def _serve_dashboard(self, request):
451
- """Serve the main dashboard HTML with fallback."""
452
- dashboard_file = self.dashboard_path / "templates" / "index.html" if self.dashboard_path else None
453
-
454
- # Try to serve actual dashboard
455
- if dashboard_file and dashboard_file.exists():
456
- try:
457
- with open(dashboard_file, 'r', encoding='utf-8') as f:
458
- content = f.read()
459
- return web.Response(text=content, content_type="text/html")
460
- except Exception as e:
461
- logger.error(f"Error reading dashboard template: {e}")
462
- # Fall through to fallback HTML
463
-
464
- # Fallback HTML if template missing or error
465
- fallback_html = '''
466
- <!DOCTYPE html>
467
- <html lang="en">
468
- <head>
469
- <meta charset="UTF-8">
470
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
471
- <title>Claude MPM Dashboard - Fallback Mode</title>
472
- <style>
473
- body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; background: #1e1e1e; color: #e0e0e0; }
474
- .container { max-width: 1200px; margin: 0 auto; }
475
- .header { background: #2d2d2d; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
476
- .status { background: #2d2d2d; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
477
- .status.healthy { border-left: 4px solid #4caf50; }
478
- .status.degraded { border-left: 4px solid #ff9800; }
479
- .events { background: #2d2d2d; padding: 20px; border-radius: 8px; }
480
- .event { background: #1e1e1e; padding: 10px; margin: 10px 0; border-radius: 4px; }
481
- h1 { color: #fff; margin: 0; }
482
- .subtitle { color: #999; margin-top: 5px; }
483
- .metric { display: inline-block; margin-right: 20px; }
484
- .metric-label { color: #999; font-size: 12px; }
485
- .metric-value { color: #fff; font-size: 20px; font-weight: bold; }
486
- </style>
487
- </head>
488
- <body>
489
- <div class="container">
490
- <div class="header">
491
- <h1>Claude MPM Dashboard</h1>
492
- <div class="subtitle">Fallback Mode - Template not found</div>
493
- </div>
494
-
495
- <div id="status" class="status healthy">
496
- <h3>Server Status</h3>
497
- <div class="metric">
498
- <div class="metric-label">Health</div>
499
- <div class="metric-value" id="health">Loading...</div>
500
- </div>
501
- <div class="metric">
502
- <div class="metric-label">Uptime</div>
503
- <div class="metric-value" id="uptime">Loading...</div>
504
- </div>
505
- <div class="metric">
506
- <div class="metric-label">Events</div>
507
- <div class="metric-value" id="events">Loading...</div>
508
- </div>
509
- </div>
510
-
511
- <div class="events">
512
- <h3>Recent Events</h3>
513
- <div id="event-list">
514
- <div class="event">Waiting for events...</div>
515
- </div>
516
- </div>
517
- </div>
518
-
519
- <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
520
- <script>
521
- // Fallback dashboard JavaScript
522
- const socket = io();
523
-
524
- // Update status periodically
525
- async function updateStatus() {
526
- try {
527
- const response = await fetch('/api/status');
528
- const data = await response.json();
529
-
530
- document.getElementById('health').textContent = data.status;
531
- document.getElementById('uptime').textContent = data.uptime.human;
532
- document.getElementById('events').textContent = data.events.total;
533
-
534
- const statusDiv = document.getElementById('status');
535
- statusDiv.className = data.status === 'running' ? 'status healthy' : 'status degraded';
536
- } catch (e) {
537
- console.error('Failed to fetch status:', e);
538
- }
539
- }
540
-
541
- // Listen for events
542
- socket.on('claude_event', (event) => {
543
- const eventList = document.getElementById('event-list');
544
- const eventDiv = document.createElement('div');
545
- eventDiv.className = 'event';
546
- eventDiv.textContent = JSON.stringify(event, null, 2);
547
- eventList.insertBefore(eventDiv, eventList.firstChild);
548
-
549
- // Keep only last 10 events
550
- while (eventList.children.length > 10) {
551
- eventList.removeChild(eventList.lastChild);
552
- }
553
- });
554
-
555
- socket.on('connect', () => {
556
- console.log('Connected to dashboard server');
557
- });
558
-
559
- // Initial load and periodic updates
560
- updateStatus();
561
- setInterval(updateStatus, 5000);
562
- </script>
563
- </body>
564
- </html>
565
- '''
566
-
567
- logger.warning("Serving fallback dashboard HTML")
568
- return web.Response(text=fallback_html, content_type="text/html")
569
-
570
- async def _serve_static(self, request):
571
- """Serve static files."""
572
- file_path = request.match_info["path"]
573
- static_file = self.dashboard_path / "static" / file_path
574
-
575
- if static_file.exists() and static_file.is_file():
576
- content_type = (
577
- "text/javascript"
578
- if file_path.endswith(".js")
579
- else "text/css" if file_path.endswith(".css") else "text/plain"
580
- )
581
- with open(static_file) as f:
582
- content = f.read()
583
- return web.Response(text=content, content_type=content_type)
584
- return web.Response(text="File not found", status=404)
585
-
586
- async def _list_directory(self, request):
587
- """List directory contents."""
588
- path = request.query.get("path", ".")
589
- abs_path = os.path.abspath(os.path.expanduser(path))
590
-
591
- result = {"path": abs_path, "exists": os.path.exists(abs_path), "contents": []}
592
-
593
- if os.path.exists(abs_path) and os.path.isdir(abs_path):
594
- try:
595
- for item in sorted(os.listdir(abs_path)):
596
- item_path = os.path.join(abs_path, item)
597
- result["contents"].append(
598
- {
599
- "name": item,
600
- "path": item_path,
601
- "is_directory": os.path.isdir(item_path),
602
- "is_file": os.path.isfile(item_path),
603
- "is_code_file": item.endswith(
604
- (".py", ".js", ".ts", ".jsx", ".tsx")
605
- ),
606
- }
607
- )
608
- except PermissionError:
609
- result["error"] = "Permission denied"
610
-
611
- return web.json_response(result)
612
-
613
- async def _read_file(self, request):
614
- """Read file content for source viewer."""
615
- file_path = request.query.get("path", "")
616
-
617
- if not file_path:
618
- return web.json_response({"error": "No path provided"}, status=400)
619
-
620
- abs_path = os.path.abspath(os.path.expanduser(file_path))
621
-
622
- # Security check - ensure file is within the project
623
- try:
624
- # Get the project root (current working directory)
625
- project_root = os.getcwd()
626
- # Ensure the path is within the project
627
- if not abs_path.startswith(project_root):
628
- return web.json_response({"error": "Access denied"}, status=403)
629
- except Exception:
630
- pass # Allow read if we can't determine project root
631
-
632
- if not os.path.exists(abs_path):
633
- return web.json_response({"error": "File not found"}, status=404)
634
-
635
- if not os.path.isfile(abs_path):
636
- return web.json_response({"error": "Not a file"}, status=400)
637
-
638
- try:
639
- # Read file with appropriate encoding
640
- encodings = ["utf-8", "latin-1", "cp1252"]
641
- content = None
642
-
643
- for encoding in encodings:
644
- try:
645
- with open(abs_path, encoding=encoding) as f:
646
- content = f.read()
647
- break
648
- except UnicodeDecodeError:
649
- continue
650
-
651
- if content is None:
652
- return web.json_response({"error": "Could not decode file"}, status=400)
653
-
654
- return web.json_response(
655
- {
656
- "path": abs_path,
657
- "name": os.path.basename(abs_path),
658
- "content": content,
659
- "lines": len(content.splitlines()),
660
- "size": os.path.getsize(abs_path),
661
- }
662
- )
663
-
664
- except PermissionError:
665
- return web.json_response({"error": "Permission denied"}, status=403)
666
- except Exception as e:
667
- return web.json_response({"error": str(e)}, status=500)
668
-
669
- async def _health_check(self, request):
670
- """Health check endpoint for monitoring."""
671
- uptime = time.time() - self.server_start_time
672
- status = "healthy" if self.is_healthy else "degraded"
673
-
674
- health_info = {
675
- "status": status,
676
- "uptime_seconds": round(uptime, 2),
677
- "connected_clients": len(self.connected_clients),
678
- "event_count": self.event_count,
679
- "last_event": self.last_event_time.isoformat() if self.last_event_time else None,
680
- "retry_count": self.retry_count,
681
- "health_check_failures": self.health_check_failures,
682
- "event_history_size": len(self.event_history)
683
- }
684
-
685
- status_code = 200 if self.is_healthy else 503
686
- return web.json_response(health_info, status=status_code)
687
-
688
- async def _serve_status(self, request):
689
- """Detailed server status endpoint."""
690
- uptime = time.time() - self.server_start_time
691
-
692
- status_info = {
693
- "server": "stable",
694
- "version": "4.2.3",
695
- "status": "running" if self.is_healthy else "degraded",
696
- "uptime": {
697
- "seconds": round(uptime, 2),
698
- "human": self._format_uptime(uptime)
699
- },
700
- "connections": {
701
- "active": len(self.connected_clients),
702
- "clients": list(self.connected_clients)
703
- },
704
- "events": {
705
- "total": self.event_count,
706
- "buffered": len(self.event_history),
707
- "last_received": self.last_event_time.isoformat() if self.last_event_time else None
708
- },
709
- "features": [
710
- "http", "socketio", "event_bridge", "health_monitoring",
711
- "auto_retry", "event_history", "graceful_degradation"
712
- ],
713
- "resilience": {
714
- "retry_count": self.retry_count,
715
- "max_retries": self.max_retries,
716
- "health_failures": self.health_check_failures,
717
- "persist_events": self.persist_events
718
- }
719
- }
720
- return web.json_response(status_info)
721
-
722
- async def _serve_event_history(self, request):
723
- """Serve recent event history."""
724
- limit = int(request.query.get('limit', '100'))
725
- events = list(self.event_history)[-limit:]
726
- return web.json_response({
727
- "events": events,
728
- "count": len(events),
729
- "total_events": self.event_count
730
- })
731
-
732
- async def _receive_event(self, request):
733
- """Receive events from hook system via HTTP POST."""
734
- try:
735
- # Parse event data
736
- data = await request.json()
737
-
738
- # Add server metadata
739
- event = {
740
- **data,
741
- "received_at": datetime.now().isoformat(),
742
- "server_event_id": self.event_count + 1
743
- }
744
-
745
- # Update tracking
746
- self.event_count += 1
747
- self.last_event_time = datetime.now()
748
-
749
- # Store in circular buffer
750
- self.event_history.append(event)
751
-
752
- # Persist to disk if enabled
753
- if self.persist_events:
754
- try:
755
- with open(self.event_log_path, 'a') as f:
756
- f.write(json.dumps(event) + '\n')
757
- except Exception as e:
758
- logger.error(f"Failed to persist event: {e}")
759
-
760
- # Emit to all connected SocketIO clients
761
- if self.sio and self.connected_clients:
762
- await self.sio.emit("claude_event", event)
763
- if self.debug:
764
- print(f"📡 Forwarded event to {len(self.connected_clients)} clients")
765
-
766
- # Return success response
767
- return web.json_response({
768
- "status": "received",
769
- "event_id": event["server_event_id"],
770
- "clients_notified": len(self.connected_clients)
771
- })
772
-
773
- except json.JSONDecodeError as e:
774
- logger.error(f"Invalid JSON in event request: {e}")
775
- return web.json_response(
776
- {"error": "Invalid JSON", "details": str(e)},
777
- status=400
778
- )
779
- except Exception as e:
780
- logger.error(f"Error processing event: {e}")
781
- if self.debug:
782
- traceback.print_exc()
783
- return web.json_response(
784
- {"error": "Failed to process event", "details": str(e)},
785
- status=500
786
- )
787
-
788
- async def _serve_version(self, request):
789
- """Serve version information."""
790
- version_info = {
791
- "version": "4.2.3",
792
- "server": "stable",
793
- "features": ["http", "socketio", "event_bridge", "resilience"],
794
- "status": "running" if self.is_healthy else "degraded",
795
- }
796
- return web.json_response(version_info)
797
-
798
- def _format_uptime(self, seconds: float) -> str:
799
- """Format uptime in human-readable format."""
800
- days = int(seconds // 86400)
801
- hours = int((seconds % 86400) // 3600)
802
- minutes = int((seconds % 3600) // 60)
803
- secs = int(seconds % 60)
804
-
805
- parts = []
806
- if days > 0:
807
- parts.append(f"{days}d")
808
- if hours > 0:
809
- parts.append(f"{hours}h")
810
- if minutes > 0:
811
- parts.append(f"{minutes}m")
812
- parts.append(f"{secs}s")
813
-
814
- return " ".join(parts)
815
-
816
- def run(self):
817
- """Run the server with automatic restart on crash."""
818
- restart_attempts = 0
819
- max_restart_attempts = 5
820
-
821
- while restart_attempts < max_restart_attempts:
822
- try:
823
- print(f"🔧 Setting up server... (attempt {restart_attempts + 1}/{max_restart_attempts})")
824
-
825
- # Reset health status on restart
826
- self.is_healthy = True
827
- self.health_check_failures = 0
828
-
829
- if not self.setup():
830
- if not DEPENDENCIES_AVAILABLE:
831
- print("❌ Missing required dependencies")
832
- return False
833
-
834
- # Continue with fallback mode even if dashboard files not found
835
- print("⚠️ Dashboard files not found - running in fallback mode")
836
- print(" Server will provide basic functionality and receive events")
837
-
838
- # Set up minimal server without dashboard files
839
- self.sio = socketio.AsyncServer(
840
- cors_allowed_origins="*",
841
- logger=self.debug,
842
- engineio_logger=self.debug,
843
- ping_interval=30,
844
- ping_timeout=60,
845
- max_http_buffer_size=1e8,
846
- )
847
- self.app = web.Application()
848
- self.sio.attach(self.app)
849
- self._setup_routes()
850
- self._setup_socketio_events()
851
-
852
- return self._run_with_resilience()
853
-
854
- except Exception as e:
855
- restart_attempts += 1
856
- logger.error(f"Server crashed: {e}")
857
- if self.debug:
858
- traceback.print_exc()
859
-
860
- if restart_attempts < max_restart_attempts:
861
- wait_time = min(2 ** restart_attempts, 30) # Exponential backoff, max 30s
862
- print(f"🔄 Restarting server in {wait_time} seconds...")
863
- time.sleep(wait_time)
864
- else:
865
- print(f"❌ Server failed after {max_restart_attempts} restart attempts")
866
- return False
867
-
868
- return False
869
-
870
- def _run_with_resilience(self):
871
- """Run server with port conflict resolution and error handling."""
872
-
873
- print(f"🚀 Starting stable dashboard server at http://{self.host}:{self.port}")
874
- print("✅ Server ready: HTTP + SocketIO with resilience features")
875
- print("🛡️ Resilience features enabled:")
876
- print(" - Automatic restart on crash")
877
- print(" - Health monitoring endpoint (/health)")
878
- print(" - Event history buffer (500 events)")
879
- print(" - Graceful degradation")
880
- print(" - Connection retry logic")
881
- print("📡 SocketIO events:")
882
- print(" - claude_event (real-time events from hooks)")
883
- print(" - code:analyze:file (code analysis)")
884
- print(" - connection management")
885
- print("🌐 HTTP endpoints:")
886
- print(" - GET / (dashboard)")
887
- print(" - GET /health (health check)")
888
- print(" - POST /api/events (receive hook events)")
889
- print(" - GET /api/status (detailed status)")
890
- print(" - GET /api/events/history (event history)")
891
- print(" - GET /api/directory/list")
892
- print(" - GET /api/file/read")
893
- print(f"\n🔗 Open in browser: http://{self.host}:{self.port}")
894
- print("\n Press Ctrl+C to stop the server\n")
895
-
896
- # Try to start server with port conflict handling
897
- max_port_attempts = 10
898
- original_port = self.port
899
-
900
- for attempt in range(max_port_attempts):
901
- try:
902
- # Use the print_func parameter to control access log output
903
- if self.debug:
904
- web.run_app(self.app, host=self.host, port=self.port)
905
- else:
906
- web.run_app(
907
- self.app,
908
- host=self.host,
909
- port=self.port,
910
- access_log=None,
911
- print=lambda *args: None # Suppress startup messages in non-debug mode
912
- )
913
- return True # Server started successfully
914
- except KeyboardInterrupt:
915
- print("\n🛑 Server stopped by user")
916
- return True
917
- except OSError as e:
918
- error_str = str(e)
919
- if "[Errno 48]" in error_str or "Address already in use" in error_str or "address already in use" in error_str.lower():
920
- # Port is already in use
921
- if attempt < max_port_attempts - 1:
922
- self.port += 1
923
- print(
924
- f"⚠️ Port {self.port - 1} is in use, trying port {self.port}..."
925
- )
926
- # Recreate the app with new port
927
- self.setup()
928
- else:
929
- print(
930
- f"❌ Could not find available port after {max_port_attempts} attempts"
931
- )
932
- print(f" Ports {original_port} to {self.port} are all in use")
933
- print("\n💡 Tip: Check if another dashboard instance is running")
934
- print(" You can stop it with: claude-mpm dashboard stop")
935
- return False
936
- else:
937
- # Other OS error
938
- print(f"❌ Server error: {e}")
939
- if self.debug:
940
- import traceback
941
- traceback.print_exc()
942
- return False
943
- except Exception as e:
944
- print(f"❌ Unexpected server error: {e}")
945
- if self.debug:
946
- import traceback
947
- traceback.print_exc()
948
- else:
949
- print("\n💡 Run with --debug flag for more details")
950
- return False
951
-
952
- return True
953
-
954
-
955
- def create_stable_server(
956
- dashboard_path: Optional[Path] = None, **kwargs
957
- ) -> StableDashboardServer:
958
- """Create a stable dashboard server instance."""
959
- server = StableDashboardServer(**kwargs)
960
- if dashboard_path:
961
- server.dashboard_path = dashboard_path
962
- return server