claude-mpm 3.1.3__py3-none-any.whl → 3.3.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 (80) hide show
  1. claude_mpm/__init__.py +3 -3
  2. claude_mpm/__main__.py +0 -17
  3. claude_mpm/agents/INSTRUCTIONS.md +149 -17
  4. claude_mpm/agents/backups/INSTRUCTIONS.md +238 -0
  5. claude_mpm/agents/base_agent.json +1 -1
  6. claude_mpm/agents/templates/pm.json +25 -0
  7. claude_mpm/agents/templates/research.json +2 -1
  8. claude_mpm/cli/__init__.py +19 -23
  9. claude_mpm/cli/commands/__init__.py +3 -1
  10. claude_mpm/cli/commands/agents.py +7 -18
  11. claude_mpm/cli/commands/info.py +5 -10
  12. claude_mpm/cli/commands/memory.py +232 -0
  13. claude_mpm/cli/commands/run.py +501 -28
  14. claude_mpm/cli/commands/tickets.py +10 -17
  15. claude_mpm/cli/commands/ui.py +15 -37
  16. claude_mpm/cli/parser.py +91 -1
  17. claude_mpm/cli/utils.py +9 -28
  18. claude_mpm/config/socketio_config.py +256 -0
  19. claude_mpm/constants.py +9 -0
  20. claude_mpm/core/__init__.py +2 -2
  21. claude_mpm/core/agent_registry.py +4 -4
  22. claude_mpm/core/claude_runner.py +919 -0
  23. claude_mpm/core/config.py +21 -1
  24. claude_mpm/core/factories.py +1 -1
  25. claude_mpm/core/hook_manager.py +196 -0
  26. claude_mpm/core/pm_hook_interceptor.py +205 -0
  27. claude_mpm/core/service_registry.py +1 -1
  28. claude_mpm/core/simple_runner.py +323 -33
  29. claude_mpm/core/socketio_pool.py +582 -0
  30. claude_mpm/core/websocket_handler.py +233 -0
  31. claude_mpm/deployment_paths.py +261 -0
  32. claude_mpm/hooks/builtin/memory_hooks_example.py +67 -0
  33. claude_mpm/hooks/claude_hooks/hook_handler.py +667 -679
  34. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +9 -4
  35. claude_mpm/hooks/memory_integration_hook.py +312 -0
  36. claude_mpm/models/__init__.py +9 -91
  37. claude_mpm/orchestration/__init__.py +1 -1
  38. claude_mpm/scripts/claude-mpm-socketio +32 -0
  39. claude_mpm/scripts/claude_mpm_monitor.html +567 -0
  40. claude_mpm/scripts/install_socketio_server.py +407 -0
  41. claude_mpm/scripts/launch_monitor.py +132 -0
  42. claude_mpm/scripts/launch_socketio_dashboard.py +261 -0
  43. claude_mpm/scripts/manage_version.py +479 -0
  44. claude_mpm/scripts/socketio_daemon.py +181 -0
  45. claude_mpm/scripts/socketio_server_manager.py +428 -0
  46. claude_mpm/services/__init__.py +5 -0
  47. claude_mpm/services/agent_lifecycle_manager.py +76 -25
  48. claude_mpm/services/agent_memory_manager.py +684 -0
  49. claude_mpm/services/agent_modification_tracker.py +98 -17
  50. claude_mpm/services/agent_persistence_service.py +33 -13
  51. claude_mpm/services/agent_registry.py +82 -43
  52. claude_mpm/services/hook_service.py +362 -0
  53. claude_mpm/services/socketio_client_manager.py +474 -0
  54. claude_mpm/services/socketio_server.py +922 -0
  55. claude_mpm/services/standalone_socketio_server.py +631 -0
  56. claude_mpm/services/ticket_manager.py +4 -5
  57. claude_mpm/services/{ticket_manager_dependency_injection.py → ticket_manager_di.py} +12 -39
  58. claude_mpm/services/{legacy_ticketing_service.py → ticketing_service_original.py} +9 -16
  59. claude_mpm/services/version_control/semantic_versioning.py +9 -10
  60. claude_mpm/services/websocket_server.py +376 -0
  61. claude_mpm/utils/dependency_manager.py +211 -0
  62. claude_mpm/utils/import_migration_example.py +80 -0
  63. claude_mpm/utils/path_operations.py +0 -20
  64. claude_mpm/web/open_dashboard.py +34 -0
  65. {claude_mpm-3.1.3.dist-info → claude_mpm-3.3.0.dist-info}/METADATA +20 -9
  66. {claude_mpm-3.1.3.dist-info → claude_mpm-3.3.0.dist-info}/RECORD +71 -50
  67. claude_mpm-3.3.0.dist-info/entry_points.txt +7 -0
  68. claude_mpm/cli_old.py +0 -728
  69. claude_mpm/models/common.py +0 -41
  70. claude_mpm/models/lifecycle.py +0 -97
  71. claude_mpm/models/modification.py +0 -126
  72. claude_mpm/models/persistence.py +0 -57
  73. claude_mpm/models/registry.py +0 -91
  74. claude_mpm/security/__init__.py +0 -8
  75. claude_mpm/security/bash_validator.py +0 -393
  76. claude_mpm-3.1.3.dist-info/entry_points.txt +0 -4
  77. /claude_mpm/{cli_enhancements.py → experimental/cli_enhancements.py} +0 -0
  78. {claude_mpm-3.1.3.dist-info → claude_mpm-3.3.0.dist-info}/WHEEL +0 -0
  79. {claude_mpm-3.1.3.dist-info → claude_mpm-3.3.0.dist-info}/licenses/LICENSE +0 -0
  80. {claude_mpm-3.1.3.dist-info → claude_mpm-3.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,922 @@
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
+
19
+ try:
20
+ import socketio
21
+ import aiohttp
22
+ from aiohttp import web
23
+ SOCKETIO_AVAILABLE = True
24
+ try:
25
+ version = getattr(socketio, '__version__', 'unknown')
26
+ print(f"Socket.IO server using python-socketio v{version}")
27
+ except:
28
+ print("Socket.IO server using python-socketio (version unavailable)")
29
+ except ImportError:
30
+ SOCKETIO_AVAILABLE = False
31
+ socketio = None
32
+ aiohttp = None
33
+ web = None
34
+ print("WARNING: python-socketio or aiohttp package not available")
35
+
36
+ from ..core.logger import get_logger
37
+ from ..deployment_paths import get_project_root, get_scripts_dir
38
+
39
+
40
+ class SocketIOClientProxy:
41
+ """Proxy that connects to an existing Socket.IO server as a client.
42
+
43
+ WHY: In exec mode, a persistent Socket.IO server runs in a separate process.
44
+ The hook handler in the Claude process needs a Socket.IO-like interface
45
+ but shouldn't start another server. This proxy provides that interface
46
+ while the actual events are handled by the persistent server.
47
+ """
48
+
49
+ def __init__(self, host: str = "localhost", port: int = 8765):
50
+ self.host = host
51
+ self.port = port
52
+ self.logger = get_logger("socketio_client_proxy")
53
+ self.running = True # Always "running" for compatibility
54
+ self._sio_client = None
55
+ self._client_thread = None
56
+
57
+ def start(self):
58
+ """Start the Socket.IO client connection to the persistent server."""
59
+ self.logger.debug(f"SocketIOClientProxy: Connecting to server on {self.host}:{self.port}")
60
+ if SOCKETIO_AVAILABLE:
61
+ self._start_client()
62
+
63
+ def stop(self):
64
+ """Stop the Socket.IO client connection."""
65
+ self.logger.debug(f"SocketIOClientProxy: Disconnecting from server")
66
+ if self._sio_client:
67
+ self._sio_client.disconnect()
68
+
69
+ def _start_client(self):
70
+ """Start Socket.IO client in a background thread."""
71
+ def run_client():
72
+ loop = asyncio.new_event_loop()
73
+ asyncio.set_event_loop(loop)
74
+ try:
75
+ loop.run_until_complete(self._connect_and_run())
76
+ except Exception as e:
77
+ self.logger.error(f"SocketIOClientProxy client thread error: {e}")
78
+
79
+ self._client_thread = threading.Thread(target=run_client, daemon=True)
80
+ self._client_thread.start()
81
+ # Give it a moment to connect
82
+ time.sleep(0.2)
83
+
84
+ async def _connect_and_run(self):
85
+ """Connect to the persistent Socket.IO server and keep connection alive."""
86
+ try:
87
+ self._sio_client = socketio.AsyncClient()
88
+
89
+ @self._sio_client.event
90
+ async def connect():
91
+ self.logger.info(f"SocketIOClientProxy: Connected to server at http://{self.host}:{self.port}")
92
+
93
+ @self._sio_client.event
94
+ async def disconnect():
95
+ self.logger.info(f"SocketIOClientProxy: Disconnected from server")
96
+
97
+ # Connect to the server
98
+ await self._sio_client.connect(f'http://127.0.0.1:{self.port}')
99
+
100
+ # Keep the connection alive until stopped
101
+ while self.running:
102
+ await asyncio.sleep(1)
103
+
104
+ except Exception as e:
105
+ self.logger.error(f"SocketIOClientProxy: Connection error: {e}")
106
+ self._sio_client = None
107
+
108
+ def broadcast_event(self, event_type: str, data: Dict[str, Any]):
109
+ """Send event to the persistent Socket.IO server."""
110
+ if not SOCKETIO_AVAILABLE:
111
+ return
112
+
113
+ # Ensure client is started
114
+ if not self._client_thread or not self._client_thread.is_alive():
115
+ self.logger.debug(f"SocketIOClientProxy: Starting client for {event_type}")
116
+ self._start_client()
117
+
118
+ if self._sio_client and self._sio_client.connected:
119
+ try:
120
+ event = {
121
+ "type": event_type,
122
+ "timestamp": datetime.now().isoformat(),
123
+ "data": data
124
+ }
125
+
126
+ # Send asynchronously using emit
127
+ asyncio.create_task(
128
+ self._sio_client.emit('claude_event', event)
129
+ )
130
+
131
+ self.logger.debug(f"SocketIOClientProxy: Sent event {event_type}")
132
+ except Exception as e:
133
+ self.logger.error(f"SocketIOClientProxy: Failed to send event {event_type}: {e}")
134
+ else:
135
+ self.logger.warning(f"SocketIOClientProxy: Client not ready for {event_type}")
136
+
137
+ # Compatibility methods for WebSocketServer interface
138
+ def session_started(self, session_id: str, launch_method: str, working_dir: str):
139
+ self.logger.debug(f"SocketIOClientProxy: Session started {session_id}")
140
+
141
+ def session_ended(self):
142
+ self.logger.debug(f"SocketIOClientProxy: Session ended")
143
+
144
+ def claude_status_changed(self, status: str, pid: Optional[int] = None, message: str = ""):
145
+ self.logger.debug(f"SocketIOClientProxy: Claude status {status}")
146
+
147
+ def agent_delegated(self, agent: str, task: str, status: str = "started"):
148
+ self.logger.debug(f"SocketIOClientProxy: Agent {agent} delegated")
149
+
150
+ def todo_updated(self, todos: List[Dict[str, Any]]):
151
+ self.logger.debug(f"SocketIOClientProxy: Todo updated ({len(todos)} todos)")
152
+
153
+
154
+ class SocketIOServer:
155
+ """Socket.IO server for broadcasting Claude MPM events.
156
+
157
+ WHY: Socket.IO provides better connection reliability than raw WebSockets,
158
+ with automatic reconnection, fallback transports, and better error handling.
159
+ It maintains the same event interface as WebSocketServer for compatibility.
160
+ """
161
+
162
+ def __init__(self, host: str = "localhost", port: int = 8765):
163
+ self.host = host
164
+ self.port = port
165
+ self.logger = get_logger("socketio_server")
166
+ self.clients: Set[str] = set() # Store session IDs instead of connection objects
167
+ self.event_history: deque = deque(maxlen=1000) # Keep last 1000 events
168
+ self.sio = None
169
+ self.app = None
170
+ self.runner = None
171
+ self.site = None
172
+ self.loop = None
173
+ self.thread = None
174
+ self.running = False
175
+
176
+ # Session state
177
+ self.session_id = None
178
+ self.session_start = None
179
+ self.claude_status = "stopped"
180
+ self.claude_pid = None
181
+
182
+ if not SOCKETIO_AVAILABLE:
183
+ self.logger.warning("Socket.IO support not available. Install 'python-socketio' and 'aiohttp' packages to enable.")
184
+
185
+ def start(self):
186
+ """Start the Socket.IO server in a background thread."""
187
+ if not SOCKETIO_AVAILABLE:
188
+ self.logger.debug("Socket.IO server skipped - required packages not installed")
189
+ return
190
+
191
+ if self.running:
192
+ self.logger.debug(f"Socket.IO server already running on port {self.port}")
193
+ return
194
+
195
+ self.running = True
196
+ self.thread = threading.Thread(target=self._run_server, daemon=True)
197
+ self.thread.start()
198
+ self.logger.info(f"🚀 Socket.IO server STARTING on http://{self.host}:{self.port}")
199
+ self.logger.info(f"🔧 Thread created: {self.thread.name} (daemon={self.thread.daemon})")
200
+
201
+ # Give server a moment to start
202
+ time.sleep(0.1)
203
+
204
+ if self.thread.is_alive():
205
+ self.logger.info(f"✅ Socket.IO server thread is alive and running")
206
+ else:
207
+ self.logger.error(f"❌ Socket.IO server thread failed to start!")
208
+
209
+ def stop(self):
210
+ """Stop the Socket.IO server."""
211
+ self.running = False
212
+ if self.loop:
213
+ asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop)
214
+ if self.thread:
215
+ self.thread.join(timeout=5)
216
+ self.logger.info("Socket.IO server stopped")
217
+
218
+ def _run_server(self):
219
+ """Run the server event loop."""
220
+ self.logger.info(f"🔄 _run_server starting on thread: {threading.current_thread().name}")
221
+ self.loop = asyncio.new_event_loop()
222
+ asyncio.set_event_loop(self.loop)
223
+ self.logger.info(f"📡 Event loop created and set for Socket.IO server")
224
+
225
+ try:
226
+ self.logger.info(f"🎯 About to start _serve() coroutine")
227
+ self.loop.run_until_complete(self._serve())
228
+ except Exception as e:
229
+ self.logger.error(f"❌ Socket.IO server error in _run_server: {e}")
230
+ import traceback
231
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
232
+ finally:
233
+ self.logger.info(f"🔚 Socket.IO server _run_server shutting down")
234
+ self.loop.close()
235
+
236
+ async def _serve(self):
237
+ """Start the Socket.IO server."""
238
+ try:
239
+ self.logger.info(f"🔌 _serve() starting - attempting to bind to {self.host}:{self.port}")
240
+
241
+ # Create Socket.IO server with improved configuration
242
+ self.sio = socketio.AsyncServer(
243
+ cors_allowed_origins="*",
244
+ ping_timeout=120,
245
+ ping_interval=30,
246
+ max_http_buffer_size=1000000,
247
+ allow_upgrades=True,
248
+ transports=['websocket', 'polling'],
249
+ logger=False, # Reduce noise in logs
250
+ engineio_logger=False
251
+ )
252
+
253
+ # Create aiohttp web application
254
+ self.app = web.Application()
255
+ self.sio.attach(self.app)
256
+
257
+ # Add HTTP routes
258
+ self.app.router.add_get('/health', self._handle_health)
259
+ self.app.router.add_get('/status', self._handle_health)
260
+ self.app.router.add_get('/api/git-diff', self._handle_git_diff)
261
+
262
+ # Add dashboard routes
263
+ self.app.router.add_get('/', self._handle_dashboard)
264
+ self.app.router.add_get('/dashboard', self._handle_dashboard)
265
+
266
+ # Add static file serving for web assets
267
+ static_path = get_project_root() / 'src' / 'claude_mpm' / 'web' / 'static'
268
+ if static_path.exists():
269
+ self.app.router.add_static('/static/', path=str(static_path), name='static')
270
+
271
+ # Register event handlers
272
+ self._register_events()
273
+
274
+ # Start the server
275
+ self.runner = web.AppRunner(self.app)
276
+ await self.runner.setup()
277
+
278
+ self.site = web.TCPSite(self.runner, self.host, self.port)
279
+ await self.site.start()
280
+
281
+ self.logger.info(f"🎉 Socket.IO server SUCCESSFULLY listening on http://{self.host}:{self.port}")
282
+
283
+ # Keep server running
284
+ loop_count = 0
285
+ while self.running:
286
+ await asyncio.sleep(0.1)
287
+ loop_count += 1
288
+ if loop_count % 100 == 0: # Log every 10 seconds
289
+ self.logger.debug(f"🔄 Socket.IO server heartbeat - {len(self.clients)} clients connected")
290
+
291
+ except Exception as e:
292
+ self.logger.error(f"❌ Failed to start Socket.IO server: {e}")
293
+ import traceback
294
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
295
+ raise
296
+
297
+ async def _shutdown(self):
298
+ """Shutdown the server."""
299
+ if self.site:
300
+ await self.site.stop()
301
+ if self.runner:
302
+ await self.runner.cleanup()
303
+
304
+ async def _handle_health(self, request):
305
+ """Handle health check requests."""
306
+ return web.json_response({
307
+ "status": "healthy",
308
+ "server": "claude-mpm-python-socketio",
309
+ "timestamp": datetime.utcnow().isoformat() + "Z",
310
+ "port": self.port,
311
+ "host": self.host,
312
+ "clients_connected": len(self.clients)
313
+ })
314
+
315
+ async def _handle_dashboard(self, request):
316
+ """Serve the dashboard HTML file."""
317
+ dashboard_path = get_project_root() / 'src' / 'claude_mpm' / 'web' / 'templates' / 'index.html'
318
+ self.logger.info(f"Dashboard requested, looking for: {dashboard_path}")
319
+ self.logger.info(f"Path exists: {dashboard_path.exists()}")
320
+ if dashboard_path.exists():
321
+ return web.FileResponse(str(dashboard_path))
322
+ else:
323
+ return web.Response(text=f"Dashboard not found at: {dashboard_path}", status=404)
324
+
325
+ async def _handle_git_diff(self, request):
326
+ """Handle git diff requests for file operations.
327
+
328
+ Expected query parameters:
329
+ - file: The file path to generate diff for
330
+ - timestamp: ISO timestamp of the operation (optional)
331
+ - working_dir: Working directory for git operations (optional)
332
+ """
333
+ try:
334
+ # Extract query parameters
335
+ file_path = request.query.get('file')
336
+ timestamp = request.query.get('timestamp')
337
+ working_dir = request.query.get('working_dir', os.getcwd())
338
+
339
+ if not file_path:
340
+ return web.json_response({
341
+ "error": "Missing required parameter: file"
342
+ }, status=400)
343
+
344
+ self.logger.debug(f"Git diff requested for file: {file_path}, timestamp: {timestamp}")
345
+
346
+ # Generate git diff using the _generate_git_diff helper
347
+ diff_result = await self._generate_git_diff(file_path, timestamp, working_dir)
348
+
349
+ return web.json_response(diff_result)
350
+
351
+ except Exception as e:
352
+ self.logger.error(f"Error generating git diff: {e}")
353
+ return web.json_response({
354
+ "error": f"Failed to generate git diff: {str(e)}"
355
+ }, status=500)
356
+
357
+ async def _generate_git_diff(self, file_path: str, timestamp: Optional[str] = None, working_dir: str = None):
358
+ """Generate git diff for a specific file operation.
359
+
360
+ WHY: This method generates a git diff showing the changes made to a file
361
+ during a specific write operation. It uses git log and show commands to
362
+ find the most relevant commit around the specified timestamp.
363
+
364
+ Args:
365
+ file_path: Path to the file relative to the git repository
366
+ timestamp: ISO timestamp of the file operation (optional)
367
+ working_dir: Working directory containing the git repository
368
+
369
+ Returns:
370
+ dict: Contains diff content, metadata, and status information
371
+ """
372
+ try:
373
+ if working_dir is None:
374
+ working_dir = os.getcwd()
375
+
376
+ # Change to the working directory
377
+ original_cwd = os.getcwd()
378
+ try:
379
+ os.chdir(working_dir)
380
+
381
+ # Check if this is a git repository
382
+ git_check = await asyncio.create_subprocess_exec(
383
+ 'git', 'rev-parse', '--git-dir',
384
+ stdout=asyncio.subprocess.PIPE,
385
+ stderr=asyncio.subprocess.PIPE
386
+ )
387
+ await git_check.communicate()
388
+
389
+ if git_check.returncode != 0:
390
+ return {
391
+ "error": "Not a git repository",
392
+ "file_path": file_path,
393
+ "working_dir": working_dir
394
+ }
395
+
396
+ # Get the absolute path of the file relative to git root
397
+ git_root_proc = await asyncio.create_subprocess_exec(
398
+ 'git', 'rev-parse', '--show-toplevel',
399
+ stdout=asyncio.subprocess.PIPE,
400
+ stderr=asyncio.subprocess.PIPE
401
+ )
402
+ git_root_output, _ = await git_root_proc.communicate()
403
+
404
+ if git_root_proc.returncode != 0:
405
+ return {"error": "Failed to determine git root directory"}
406
+
407
+ git_root = git_root_output.decode().strip()
408
+
409
+ # Make file_path relative to git root if it's absolute
410
+ if os.path.isabs(file_path):
411
+ try:
412
+ file_path = os.path.relpath(file_path, git_root)
413
+ except ValueError:
414
+ # File is not under git root
415
+ pass
416
+
417
+ # If timestamp is provided, try to find commits around that time
418
+ if timestamp:
419
+ # Convert timestamp to git format
420
+ try:
421
+ from datetime import datetime
422
+ dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
423
+ git_since = dt.strftime('%Y-%m-%d %H:%M:%S')
424
+
425
+ # Find commits that modified this file around the timestamp
426
+ log_proc = await asyncio.create_subprocess_exec(
427
+ 'git', 'log', '--oneline', '--since', git_since,
428
+ '--until', f'{git_since} +1 hour', '--', file_path,
429
+ stdout=asyncio.subprocess.PIPE,
430
+ stderr=asyncio.subprocess.PIPE
431
+ )
432
+ log_output, _ = await log_proc.communicate()
433
+
434
+ if log_proc.returncode == 0 and log_output:
435
+ # Get the most recent commit hash
436
+ commits = log_output.decode().strip().split('\n')
437
+ if commits and commits[0]:
438
+ commit_hash = commits[0].split()[0]
439
+
440
+ # Get the diff for this specific commit
441
+ diff_proc = await asyncio.create_subprocess_exec(
442
+ 'git', 'show', '--format=fuller', commit_hash, '--', file_path,
443
+ stdout=asyncio.subprocess.PIPE,
444
+ stderr=asyncio.subprocess.PIPE
445
+ )
446
+ diff_output, diff_error = await diff_proc.communicate()
447
+
448
+ if diff_proc.returncode == 0:
449
+ return {
450
+ "success": True,
451
+ "diff": diff_output.decode(),
452
+ "commit_hash": commit_hash,
453
+ "file_path": file_path,
454
+ "method": "timestamp_based",
455
+ "timestamp": timestamp
456
+ }
457
+ except Exception as e:
458
+ self.logger.warning(f"Failed to parse timestamp or find commits: {e}")
459
+
460
+ # Fallback: Get the most recent change to the file
461
+ log_proc = await asyncio.create_subprocess_exec(
462
+ 'git', 'log', '-1', '--oneline', '--', file_path,
463
+ stdout=asyncio.subprocess.PIPE,
464
+ stderr=asyncio.subprocess.PIPE
465
+ )
466
+ log_output, _ = await log_proc.communicate()
467
+
468
+ if log_proc.returncode == 0 and log_output:
469
+ commit_hash = log_output.decode().strip().split()[0]
470
+
471
+ # Get the diff for the most recent commit
472
+ diff_proc = await asyncio.create_subprocess_exec(
473
+ 'git', 'show', '--format=fuller', commit_hash, '--', file_path,
474
+ stdout=asyncio.subprocess.PIPE,
475
+ stderr=asyncio.subprocess.PIPE
476
+ )
477
+ diff_output, diff_error = await diff_proc.communicate()
478
+
479
+ if diff_proc.returncode == 0:
480
+ return {
481
+ "success": True,
482
+ "diff": diff_output.decode(),
483
+ "commit_hash": commit_hash,
484
+ "file_path": file_path,
485
+ "method": "latest_commit",
486
+ "timestamp": timestamp
487
+ }
488
+
489
+ # Final fallback: Show current working directory changes
490
+ diff_proc = await asyncio.create_subprocess_exec(
491
+ 'git', 'diff', 'HEAD', '--', file_path,
492
+ stdout=asyncio.subprocess.PIPE,
493
+ stderr=asyncio.subprocess.PIPE
494
+ )
495
+ diff_output, _ = await diff_proc.communicate()
496
+
497
+ if diff_proc.returncode == 0:
498
+ working_diff = diff_output.decode()
499
+ if working_diff.strip():
500
+ return {
501
+ "success": True,
502
+ "diff": working_diff,
503
+ "commit_hash": "working_directory",
504
+ "file_path": file_path,
505
+ "method": "working_directory",
506
+ "timestamp": timestamp
507
+ }
508
+
509
+ return {
510
+ "error": "No git history found for this file",
511
+ "file_path": file_path,
512
+ "suggestions": [
513
+ "The file may not be tracked by git",
514
+ "The file may not have any committed changes",
515
+ "The timestamp may be outside the git history range"
516
+ ]
517
+ }
518
+
519
+ finally:
520
+ os.chdir(original_cwd)
521
+
522
+ except Exception as e:
523
+ self.logger.error(f"Error in _generate_git_diff: {e}")
524
+ return {
525
+ "error": f"Git diff generation failed: {str(e)}",
526
+ "file_path": file_path
527
+ }
528
+
529
+
530
+ def _register_events(self):
531
+ """Register Socket.IO event handlers."""
532
+
533
+ @self.sio.event
534
+ async def connect(sid, environ, *args):
535
+ """Handle client connection."""
536
+ self.clients.add(sid)
537
+ client_addr = environ.get('REMOTE_ADDR', 'unknown')
538
+ user_agent = environ.get('HTTP_USER_AGENT', 'unknown')
539
+ self.logger.info(f"🔗 NEW CLIENT CONNECTED: {sid} from {client_addr}")
540
+ self.logger.info(f"📱 User Agent: {user_agent[:100]}...")
541
+ self.logger.info(f"📈 Total clients now: {len(self.clients)}")
542
+
543
+ # Send initial status immediately with enhanced data
544
+ status_data = {
545
+ "server": "claude-mpm-python-socketio",
546
+ "timestamp": datetime.utcnow().isoformat() + "Z",
547
+ "clients_connected": len(self.clients),
548
+ "session_id": self.session_id,
549
+ "claude_status": self.claude_status,
550
+ "claude_pid": self.claude_pid,
551
+ "server_version": "2.0.0",
552
+ "client_id": sid
553
+ }
554
+
555
+ try:
556
+ await self.sio.emit('status', status_data, room=sid)
557
+ await self.sio.emit('welcome', {
558
+ "message": "Connected to Claude MPM Socket.IO server",
559
+ "client_id": sid,
560
+ "server_time": datetime.utcnow().isoformat() + "Z"
561
+ }, room=sid)
562
+
563
+ # Automatically send the last 50 events to new clients
564
+ await self._send_event_history(sid, limit=50)
565
+
566
+ self.logger.debug(f"✅ Sent welcome messages and event history to client {sid}")
567
+ except Exception as e:
568
+ self.logger.error(f"❌ Failed to send welcome to client {sid}: {e}")
569
+ import traceback
570
+ self.logger.error(f"Full traceback: {traceback.format_exc()}")
571
+
572
+ @self.sio.event
573
+ async def disconnect(sid):
574
+ """Handle client disconnection."""
575
+ if sid in self.clients:
576
+ self.clients.remove(sid)
577
+ self.logger.info(f"🔌 CLIENT DISCONNECTED: {sid}")
578
+ self.logger.info(f"📉 Total clients now: {len(self.clients)}")
579
+ else:
580
+ self.logger.warning(f"⚠️ Attempted to disconnect unknown client: {sid}")
581
+
582
+ @self.sio.event
583
+ async def get_status(sid):
584
+ """Handle status request."""
585
+ # Send compatible status event (not claude_event)
586
+ status_data = {
587
+ "server": "claude-mpm-python-socketio",
588
+ "timestamp": datetime.utcnow().isoformat() + "Z",
589
+ "clients_connected": len(self.clients),
590
+ "session_id": self.session_id,
591
+ "claude_status": self.claude_status,
592
+ "claude_pid": self.claude_pid
593
+ }
594
+ await self.sio.emit('status', status_data, room=sid)
595
+ self.logger.debug(f"Sent status response to client {sid}")
596
+
597
+ @self.sio.event
598
+ async def get_history(sid, data=None):
599
+ """Handle history request."""
600
+ params = data or {}
601
+ event_types = params.get("event_types", [])
602
+ limit = min(params.get("limit", 100), len(self.event_history))
603
+
604
+ await self._send_event_history(sid, event_types=event_types, limit=limit)
605
+
606
+ @self.sio.event
607
+ async def request_history(sid, data=None):
608
+ """Handle legacy history request (for client compatibility)."""
609
+ # This handles the 'request.history' event that the client currently emits
610
+ params = data or {}
611
+ event_types = params.get("event_types", [])
612
+ limit = min(params.get("limit", 50), len(self.event_history))
613
+
614
+ await self._send_event_history(sid, event_types=event_types, limit=limit)
615
+
616
+ @self.sio.event
617
+ async def subscribe(sid, data=None):
618
+ """Handle subscription request."""
619
+ channels = data.get("channels", ["*"]) if data else ["*"]
620
+ await self.sio.emit('subscribed', {
621
+ "channels": channels
622
+ }, room=sid)
623
+
624
+ @self.sio.event
625
+ async def claude_event(sid, data):
626
+ """Handle events from client proxies."""
627
+ # Store in history
628
+ self.event_history.append(data)
629
+ self.logger.debug(f"📚 Event from client stored in history (total: {len(self.event_history)})")
630
+
631
+ # Re-broadcast to all other clients
632
+ await self.sio.emit('claude_event', data, skip_sid=sid)
633
+
634
+ async def _send_current_status(self, sid: str):
635
+ """Send current system status to a client."""
636
+ try:
637
+ status = {
638
+ "type": "system.status",
639
+ "timestamp": datetime.utcnow().isoformat() + "Z",
640
+ "data": {
641
+ "session_id": self.session_id,
642
+ "session_start": self.session_start,
643
+ "claude_status": self.claude_status,
644
+ "claude_pid": self.claude_pid,
645
+ "connected_clients": len(self.clients),
646
+ "websocket_port": self.port,
647
+ "instance_info": {
648
+ "port": self.port,
649
+ "host": self.host,
650
+ "working_dir": os.getcwd() if self.session_id else None
651
+ }
652
+ }
653
+ }
654
+ await self.sio.emit('claude_event', status, room=sid)
655
+ self.logger.debug("Sent status to client")
656
+ except Exception as e:
657
+ self.logger.error(f"Failed to send status to client: {e}")
658
+ raise
659
+
660
+ async def _send_event_history(self, sid: str, event_types: list = None, limit: int = 50):
661
+ """Send event history to a specific client.
662
+
663
+ WHY: When clients connect to the dashboard, they need context from recent events
664
+ to understand what's been happening. This sends the most recent events in
665
+ chronological order (oldest first) so the dashboard displays them properly.
666
+
667
+ Args:
668
+ sid: Socket.IO session ID of the client
669
+ event_types: Optional list of event types to filter by
670
+ limit: Maximum number of events to send (default: 50)
671
+ """
672
+ try:
673
+ if not self.event_history:
674
+ self.logger.debug(f"No event history to send to client {sid}")
675
+ return
676
+
677
+ # Limit to reasonable number to avoid overwhelming client
678
+ limit = min(limit, 100)
679
+
680
+ # Get the most recent events, filtered by type if specified
681
+ history = []
682
+ for event in reversed(self.event_history):
683
+ if not event_types or event.get("type") in event_types:
684
+ history.append(event)
685
+ if len(history) >= limit:
686
+ break
687
+
688
+ # Reverse to get chronological order (oldest first)
689
+ history = list(reversed(history))
690
+
691
+ if history:
692
+ # Send as 'history' event that the client expects
693
+ await self.sio.emit('history', {
694
+ "events": history,
695
+ "count": len(history),
696
+ "total_available": len(self.event_history)
697
+ }, room=sid)
698
+
699
+ self.logger.info(f"📚 Sent {len(history)} historical events to client {sid}")
700
+ else:
701
+ self.logger.debug(f"No matching events found for client {sid} with filters: {event_types}")
702
+
703
+ except Exception as e:
704
+ self.logger.error(f"❌ Failed to send event history to client {sid}: {e}")
705
+ import traceback
706
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
707
+
708
+ def broadcast_event(self, event_type: str, data: Dict[str, Any]):
709
+ """Broadcast an event to all connected clients."""
710
+ if not SOCKETIO_AVAILABLE:
711
+ self.logger.debug(f"⚠️ Socket.IO broadcast skipped - packages not available")
712
+ return
713
+
714
+ event = {
715
+ "type": event_type,
716
+ "timestamp": datetime.utcnow().isoformat() + "Z",
717
+ "data": data
718
+ }
719
+
720
+ self.logger.info(f"📤 BROADCASTING EVENT: {event_type}")
721
+ self.logger.debug(f"📄 Event data: {json.dumps(data, indent=2)[:200]}...")
722
+
723
+ # Store in history
724
+ self.event_history.append(event)
725
+ self.logger.debug(f"📚 Event stored in history (total: {len(self.event_history)})")
726
+
727
+ # Check if we have clients and event loop
728
+ if not self.clients:
729
+ self.logger.warning(f"⚠️ No Socket.IO clients connected - event will not be delivered")
730
+ return
731
+
732
+ if not self.loop or not self.sio:
733
+ self.logger.error(f"❌ No event loop or Socket.IO instance available - cannot broadcast event")
734
+ return
735
+
736
+ self.logger.info(f"🎯 Broadcasting to {len(self.clients)} clients via event loop")
737
+
738
+ # Broadcast to clients with timeout and error handling
739
+ try:
740
+ future = asyncio.run_coroutine_threadsafe(
741
+ self.sio.emit('claude_event', event),
742
+ self.loop
743
+ )
744
+ # Wait for completion with timeout to detect issues
745
+ try:
746
+ future.result(timeout=2.0) # 2 second timeout
747
+ self.logger.debug(f"📨 Successfully broadcasted {event_type} to {len(self.clients)} clients")
748
+ except asyncio.TimeoutError:
749
+ self.logger.warning(f"⏰ Broadcast timeout for event {event_type} - continuing anyway")
750
+ except Exception as emit_error:
751
+ self.logger.error(f"❌ Broadcast emit error for {event_type}: {emit_error}")
752
+ except Exception as e:
753
+ self.logger.error(f"❌ Failed to submit broadcast to event loop: {e}")
754
+ import traceback
755
+ self.logger.error(f"Stack trace: {traceback.format_exc()}")
756
+
757
+ # Convenience methods for common events (same interface as WebSocketServer)
758
+
759
+ def session_started(self, session_id: str, launch_method: str, working_dir: str):
760
+ """Notify that a session has started."""
761
+ self.session_id = session_id
762
+ self.session_start = datetime.utcnow().isoformat() + "Z"
763
+ self.broadcast_event("session.start", {
764
+ "session_id": session_id,
765
+ "start_time": self.session_start,
766
+ "launch_method": launch_method,
767
+ "working_directory": working_dir,
768
+ "websocket_port": self.port,
769
+ "instance_info": {
770
+ "port": self.port,
771
+ "host": self.host,
772
+ "working_dir": working_dir
773
+ }
774
+ })
775
+
776
+ def session_ended(self):
777
+ """Notify that a session has ended."""
778
+ if self.session_id:
779
+ duration = None
780
+ if self.session_start:
781
+ start = datetime.fromisoformat(self.session_start.replace("Z", "+00:00"))
782
+ duration = (datetime.utcnow() - start.replace(tzinfo=None)).total_seconds()
783
+
784
+ self.broadcast_event("session.end", {
785
+ "session_id": self.session_id,
786
+ "end_time": datetime.utcnow().isoformat() + "Z",
787
+ "duration_seconds": duration
788
+ })
789
+
790
+ self.session_id = None
791
+ self.session_start = None
792
+
793
+ def claude_status_changed(self, status: str, pid: Optional[int] = None, message: str = ""):
794
+ """Notify Claude status change."""
795
+ self.claude_status = status
796
+ self.claude_pid = pid
797
+ self.broadcast_event("claude.status", {
798
+ "status": status,
799
+ "pid": pid,
800
+ "message": message
801
+ })
802
+
803
+ def claude_output(self, content: str, stream: str = "stdout"):
804
+ """Broadcast Claude output."""
805
+ self.broadcast_event("claude.output", {
806
+ "content": content,
807
+ "stream": stream
808
+ })
809
+
810
+ def agent_delegated(self, agent: str, task: str, status: str = "started"):
811
+ """Notify agent delegation."""
812
+ self.broadcast_event("agent.delegation", {
813
+ "agent": agent,
814
+ "task": task,
815
+ "status": status,
816
+ "timestamp": datetime.utcnow().isoformat() + "Z"
817
+ })
818
+
819
+ def todo_updated(self, todos: List[Dict[str, Any]]):
820
+ """Notify todo list update."""
821
+ stats = {
822
+ "total": len(todos),
823
+ "completed": sum(1 for t in todos if t.get("status") == "completed"),
824
+ "in_progress": sum(1 for t in todos if t.get("status") == "in_progress"),
825
+ "pending": sum(1 for t in todos if t.get("status") == "pending")
826
+ }
827
+
828
+ self.broadcast_event("todo.update", {
829
+ "todos": todos,
830
+ "stats": stats
831
+ })
832
+
833
+ def ticket_created(self, ticket_id: str, title: str, priority: str = "medium"):
834
+ """Notify ticket creation."""
835
+ self.broadcast_event("ticket.created", {
836
+ "id": ticket_id,
837
+ "title": title,
838
+ "priority": priority,
839
+ "created_at": datetime.utcnow().isoformat() + "Z"
840
+ })
841
+
842
+ def memory_loaded(self, agent_id: str, memory_size: int, sections_count: int):
843
+ """Notify when agent memory is loaded from file."""
844
+ self.broadcast_event("memory:loaded", {
845
+ "agent_id": agent_id,
846
+ "memory_size": memory_size,
847
+ "sections_count": sections_count,
848
+ "timestamp": datetime.utcnow().isoformat() + "Z"
849
+ })
850
+
851
+ def memory_created(self, agent_id: str, template_type: str):
852
+ """Notify when new agent memory is created from template."""
853
+ self.broadcast_event("memory:created", {
854
+ "agent_id": agent_id,
855
+ "template_type": template_type,
856
+ "timestamp": datetime.utcnow().isoformat() + "Z"
857
+ })
858
+
859
+ def memory_updated(self, agent_id: str, learning_type: str, content: str, section: str):
860
+ """Notify when learning is added to agent memory."""
861
+ self.broadcast_event("memory:updated", {
862
+ "agent_id": agent_id,
863
+ "learning_type": learning_type,
864
+ "content": content,
865
+ "section": section,
866
+ "timestamp": datetime.utcnow().isoformat() + "Z"
867
+ })
868
+
869
+ def memory_injected(self, agent_id: str, context_size: int):
870
+ """Notify when agent memory is injected into context."""
871
+ self.broadcast_event("memory:injected", {
872
+ "agent_id": agent_id,
873
+ "context_size": context_size,
874
+ "timestamp": datetime.utcnow().isoformat() + "Z"
875
+ })
876
+
877
+
878
+ # Global instance for easy access
879
+ _socketio_server: Optional[SocketIOServer] = None
880
+
881
+
882
+ def get_socketio_server() -> SocketIOServer:
883
+ """Get or create the global Socket.IO server instance.
884
+
885
+ WHY: In exec mode, a persistent Socket.IO server may already be running
886
+ in a separate process. We need to detect this and create a client proxy
887
+ instead of trying to start another server.
888
+ """
889
+ global _socketio_server
890
+ if _socketio_server is None:
891
+ # Check if a Socket.IO server is already running on the default port
892
+ import socket
893
+ try:
894
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
895
+ s.settimeout(0.5)
896
+ result = s.connect_ex(('127.0.0.1', 8765))
897
+ if result == 0:
898
+ # Server is already running - create a client proxy
899
+ _socketio_server = SocketIOClientProxy(port=8765)
900
+ else:
901
+ # No server running - create a real server
902
+ _socketio_server = SocketIOServer()
903
+ except Exception:
904
+ # On any error, create a real server
905
+ _socketio_server = SocketIOServer()
906
+
907
+ return _socketio_server
908
+
909
+
910
+ def start_socketio_server():
911
+ """Start the global Socket.IO server."""
912
+ server = get_socketio_server()
913
+ server.start()
914
+ return server
915
+
916
+
917
+ def stop_socketio_server():
918
+ """Stop the global Socket.IO server."""
919
+ global _socketio_server
920
+ if _socketio_server:
921
+ _socketio_server.stop()
922
+ _socketio_server = None