claude-mpm 3.3.0__py3-none-any.whl → 3.4.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 (58) hide show
  1. claude_mpm/agents/templates/data_engineer.json +1 -1
  2. claude_mpm/agents/templates/documentation.json +1 -1
  3. claude_mpm/agents/templates/engineer.json +1 -1
  4. claude_mpm/agents/templates/ops.json +1 -1
  5. claude_mpm/agents/templates/pm.json +1 -1
  6. claude_mpm/agents/templates/qa.json +1 -1
  7. claude_mpm/agents/templates/research.json +1 -1
  8. claude_mpm/agents/templates/security.json +1 -1
  9. claude_mpm/agents/templates/test_integration.json +112 -0
  10. claude_mpm/agents/templates/version_control.json +1 -1
  11. claude_mpm/cli/commands/memory.py +749 -26
  12. claude_mpm/cli/commands/run.py +115 -14
  13. claude_mpm/cli/parser.py +89 -1
  14. claude_mpm/constants.py +6 -0
  15. claude_mpm/core/claude_runner.py +74 -11
  16. claude_mpm/core/config.py +1 -1
  17. claude_mpm/core/session_manager.py +46 -0
  18. claude_mpm/core/simple_runner.py +74 -11
  19. claude_mpm/hooks/builtin/mpm_command_hook.py +5 -5
  20. claude_mpm/hooks/claude_hooks/hook_handler.py +213 -30
  21. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +9 -2
  22. claude_mpm/hooks/memory_integration_hook.py +51 -5
  23. claude_mpm/services/__init__.py +23 -5
  24. claude_mpm/services/agent_memory_manager.py +800 -71
  25. claude_mpm/services/memory_builder.py +823 -0
  26. claude_mpm/services/memory_optimizer.py +619 -0
  27. claude_mpm/services/memory_router.py +445 -0
  28. claude_mpm/services/project_analyzer.py +771 -0
  29. claude_mpm/services/socketio_server.py +649 -45
  30. claude_mpm/services/version_control/git_operations.py +26 -0
  31. claude_mpm-3.4.0.dist-info/METADATA +183 -0
  32. {claude_mpm-3.3.0.dist-info → claude_mpm-3.4.0.dist-info}/RECORD +36 -52
  33. claude_mpm/agents/agent-template.yaml +0 -83
  34. claude_mpm/agents/templates/test-integration-agent.md +0 -34
  35. claude_mpm/agents/test_fix_deployment/.claude-pm/config/project.json +0 -6
  36. claude_mpm/cli/README.md +0 -109
  37. claude_mpm/cli_module/refactoring_guide.md +0 -253
  38. claude_mpm/core/agent_registry.py.bak +0 -312
  39. claude_mpm/core/base_service.py.bak +0 -406
  40. claude_mpm/core/websocket_handler.py +0 -233
  41. claude_mpm/hooks/README.md +0 -97
  42. claude_mpm/orchestration/SUBPROCESS_DESIGN.md +0 -66
  43. claude_mpm/schemas/README_SECURITY.md +0 -92
  44. claude_mpm/schemas/agent_schema.json +0 -395
  45. claude_mpm/schemas/agent_schema_documentation.md +0 -181
  46. claude_mpm/schemas/agent_schema_security_notes.md +0 -165
  47. claude_mpm/schemas/examples/standard_workflow.json +0 -505
  48. claude_mpm/schemas/ticket_workflow_documentation.md +0 -482
  49. claude_mpm/schemas/ticket_workflow_schema.json +0 -590
  50. claude_mpm/services/framework_claude_md_generator/README.md +0 -92
  51. claude_mpm/services/parent_directory_manager/README.md +0 -83
  52. claude_mpm/services/version_control/VERSION +0 -1
  53. claude_mpm/services/websocket_server.py +0 -376
  54. claude_mpm-3.3.0.dist-info/METADATA +0 -432
  55. {claude_mpm-3.3.0.dist-info → claude_mpm-3.4.0.dist-info}/WHEEL +0 -0
  56. {claude_mpm-3.3.0.dist-info → claude_mpm-3.4.0.dist-info}/entry_points.txt +0 -0
  57. {claude_mpm-3.3.0.dist-info → claude_mpm-3.4.0.dist-info}/licenses/LICENSE +0 -0
  58. {claude_mpm-3.3.0.dist-info → claude_mpm-3.4.0.dist-info}/top_level.txt +0 -0
@@ -21,17 +21,14 @@ try:
21
21
  import aiohttp
22
22
  from aiohttp import web
23
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)")
24
+ # Don't print at module level - this causes output during imports
25
+ # Version will be logged when server is actually started
29
26
  except ImportError:
30
27
  SOCKETIO_AVAILABLE = False
31
28
  socketio = None
32
29
  aiohttp = None
33
30
  web = None
34
- print("WARNING: python-socketio or aiohttp package not available")
31
+ # Don't print warnings at module level
35
32
 
36
33
  from ..core.logger import get_logger
37
34
  from ..deployment_paths import get_project_root, get_scripts_dir
@@ -53,6 +50,7 @@ class SocketIOClientProxy:
53
50
  self.running = True # Always "running" for compatibility
54
51
  self._sio_client = None
55
52
  self._client_thread = None
53
+ self._client_loop = None
56
54
 
57
55
  def start(self):
58
56
  """Start the Socket.IO client connection to the persistent server."""
@@ -69,12 +67,14 @@ class SocketIOClientProxy:
69
67
  def _start_client(self):
70
68
  """Start Socket.IO client in a background thread."""
71
69
  def run_client():
72
- loop = asyncio.new_event_loop()
73
- asyncio.set_event_loop(loop)
70
+ self._client_loop = asyncio.new_event_loop()
71
+ asyncio.set_event_loop(self._client_loop)
74
72
  try:
75
- loop.run_until_complete(self._connect_and_run())
73
+ self._client_loop.run_until_complete(self._connect_and_run())
76
74
  except Exception as e:
77
75
  self.logger.error(f"SocketIOClientProxy client thread error: {e}")
76
+ finally:
77
+ self._client_loop.close()
78
78
 
79
79
  self._client_thread = threading.Thread(target=run_client, daemon=True)
80
80
  self._client_thread.start()
@@ -123,10 +123,19 @@ class SocketIOClientProxy:
123
123
  "data": data
124
124
  }
125
125
 
126
- # Send asynchronously using emit
127
- asyncio.create_task(
128
- self._sio_client.emit('claude_event', event)
129
- )
126
+ # Send event safely using run_coroutine_threadsafe
127
+ if hasattr(self, '_client_loop') and self._client_loop and not self._client_loop.is_closed():
128
+ try:
129
+ future = asyncio.run_coroutine_threadsafe(
130
+ self._sio_client.emit('claude_event', event),
131
+ self._client_loop
132
+ )
133
+ # Don't wait for the result to avoid blocking
134
+ self.logger.debug(f"SocketIOClientProxy: Scheduled emit for {event_type}")
135
+ except Exception as e:
136
+ self.logger.error(f"SocketIOClientProxy: Failed to schedule emit for {event_type}: {e}")
137
+ else:
138
+ self.logger.warning(f"SocketIOClientProxy: Client event loop not available for {event_type}")
130
139
 
131
140
  self.logger.debug(f"SocketIOClientProxy: Sent event {event_type}")
132
141
  except Exception as e:
@@ -181,6 +190,13 @@ class SocketIOServer:
181
190
 
182
191
  if not SOCKETIO_AVAILABLE:
183
192
  self.logger.warning("Socket.IO support not available. Install 'python-socketio' and 'aiohttp' packages to enable.")
193
+ else:
194
+ # Log version info when server is actually created
195
+ try:
196
+ version = getattr(socketio, '__version__', 'unknown')
197
+ self.logger.info(f"Socket.IO server using python-socketio v{version}")
198
+ except:
199
+ self.logger.info("Socket.IO server using python-socketio (version unavailable)")
184
200
 
185
201
  def start(self):
186
202
  """Start the Socket.IO server in a background thread."""
@@ -254,10 +270,24 @@ class SocketIOServer:
254
270
  self.app = web.Application()
255
271
  self.sio.attach(self.app)
256
272
 
273
+ # Add CORS middleware
274
+ import aiohttp_cors
275
+ cors = aiohttp_cors.setup(self.app, defaults={
276
+ "*": aiohttp_cors.ResourceOptions(
277
+ allow_credentials=True,
278
+ expose_headers="*",
279
+ allow_headers="*",
280
+ allow_methods="*"
281
+ )
282
+ })
283
+
257
284
  # Add HTTP routes
258
285
  self.app.router.add_get('/health', self._handle_health)
259
286
  self.app.router.add_get('/status', self._handle_health)
260
287
  self.app.router.add_get('/api/git-diff', self._handle_git_diff)
288
+ self.app.router.add_options('/api/git-diff', self._handle_cors_preflight)
289
+ self.app.router.add_get('/api/file-content', self._handle_file_content)
290
+ self.app.router.add_options('/api/file-content', self._handle_cors_preflight)
261
291
 
262
292
  # Add dashboard routes
263
293
  self.app.router.add_get('/', self._handle_dashboard)
@@ -310,6 +340,10 @@ class SocketIOServer:
310
340
  "port": self.port,
311
341
  "host": self.host,
312
342
  "clients_connected": len(self.clients)
343
+ }, headers={
344
+ 'Access-Control-Allow-Origin': '*',
345
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
346
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
313
347
  })
314
348
 
315
349
  async def _handle_dashboard(self, request):
@@ -321,6 +355,18 @@ class SocketIOServer:
321
355
  return web.FileResponse(str(dashboard_path))
322
356
  else:
323
357
  return web.Response(text=f"Dashboard not found at: {dashboard_path}", status=404)
358
+
359
+ async def _handle_cors_preflight(self, request):
360
+ """Handle CORS preflight requests."""
361
+ return web.Response(
362
+ status=200,
363
+ headers={
364
+ 'Access-Control-Allow-Origin': '*',
365
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
366
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',
367
+ 'Access-Control-Max-Age': '86400'
368
+ }
369
+ )
324
370
 
325
371
  async def _handle_git_diff(self, request):
326
372
  """Handle git diff requests for file operations.
@@ -336,23 +382,237 @@ class SocketIOServer:
336
382
  timestamp = request.query.get('timestamp')
337
383
  working_dir = request.query.get('working_dir', os.getcwd())
338
384
 
385
+ self.logger.info(f"Git diff API request: file={file_path}, timestamp={timestamp}, working_dir={working_dir}")
386
+ 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}")
387
+
339
388
  if not file_path:
389
+ self.logger.warning("Git diff request missing file parameter")
340
390
  return web.json_response({
391
+ "success": False,
341
392
  "error": "Missing required parameter: file"
342
- }, status=400)
393
+ }, status=400, headers={
394
+ 'Access-Control-Allow-Origin': '*',
395
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
396
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
397
+ })
343
398
 
344
399
  self.logger.debug(f"Git diff requested for file: {file_path}, timestamp: {timestamp}")
345
400
 
346
401
  # Generate git diff using the _generate_git_diff helper
347
402
  diff_result = await self._generate_git_diff(file_path, timestamp, working_dir)
348
403
 
349
- return web.json_response(diff_result)
404
+ self.logger.info(f"Git diff result: success={diff_result.get('success', False)}, method={diff_result.get('method', 'unknown')}")
405
+
406
+ return web.json_response(diff_result, headers={
407
+ 'Access-Control-Allow-Origin': '*',
408
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
409
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
410
+ })
350
411
 
351
412
  except Exception as e:
352
413
  self.logger.error(f"Error generating git diff: {e}")
414
+ import traceback
415
+ self.logger.error(f"Git diff error traceback: {traceback.format_exc()}")
353
416
  return web.json_response({
417
+ "success": False,
354
418
  "error": f"Failed to generate git diff: {str(e)}"
355
- }, status=500)
419
+ }, status=500, headers={
420
+ 'Access-Control-Allow-Origin': '*',
421
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
422
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
423
+ })
424
+
425
+ async def _handle_file_content(self, request):
426
+ """Handle file content requests via HTTP API.
427
+
428
+ Expected query parameters:
429
+ - file_path: The file path to read
430
+ - working_dir: Working directory for file operations (optional)
431
+ - max_size: Maximum file size in bytes (optional, default 1MB)
432
+ """
433
+ try:
434
+ # Extract query parameters
435
+ file_path = request.query.get('file_path')
436
+ working_dir = request.query.get('working_dir', os.getcwd())
437
+ max_size = int(request.query.get('max_size', 1024 * 1024)) # 1MB default
438
+
439
+ self.logger.info(f"File content API request: file_path={file_path}, working_dir={working_dir}")
440
+
441
+ if not file_path:
442
+ self.logger.warning("File content request missing file_path parameter")
443
+ return web.json_response({
444
+ "success": False,
445
+ "error": "Missing required parameter: file_path"
446
+ }, status=400, headers={
447
+ 'Access-Control-Allow-Origin': '*',
448
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
449
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
450
+ })
451
+
452
+ # Use the same file reading logic as the Socket.IO handler
453
+ result = await self._read_file_safely(file_path, working_dir, max_size)
454
+
455
+ status_code = 200 if result.get('success') else 400
456
+ return web.json_response(result, status=status_code, headers={
457
+ 'Access-Control-Allow-Origin': '*',
458
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
459
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
460
+ })
461
+
462
+ except Exception as e:
463
+ self.logger.error(f"Error reading file content: {e}")
464
+ import traceback
465
+ self.logger.error(f"File content error traceback: {traceback.format_exc()}")
466
+ return web.json_response({
467
+ "success": False,
468
+ "error": f"Failed to read file: {str(e)}"
469
+ }, status=500, headers={
470
+ 'Access-Control-Allow-Origin': '*',
471
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
472
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept'
473
+ })
474
+
475
+ async def _read_file_safely(self, file_path: str, working_dir: str = None, max_size: int = 1024 * 1024):
476
+ """Safely read file content with security checks.
477
+
478
+ This method contains the core file reading logic that can be used by both
479
+ HTTP API endpoints and Socket.IO event handlers.
480
+
481
+ Args:
482
+ file_path: Path to the file to read
483
+ working_dir: Working directory (defaults to current directory)
484
+ max_size: Maximum file size in bytes
485
+
486
+ Returns:
487
+ dict: Response with success status, content, and metadata
488
+ """
489
+ try:
490
+ if working_dir is None:
491
+ working_dir = os.getcwd()
492
+
493
+ # Resolve absolute path based on working directory
494
+ if not os.path.isabs(file_path):
495
+ full_path = os.path.join(working_dir, file_path)
496
+ else:
497
+ full_path = file_path
498
+
499
+ # Security check: ensure file is within working directory or project
500
+ try:
501
+ real_path = os.path.realpath(full_path)
502
+ real_working_dir = os.path.realpath(working_dir)
503
+
504
+ # Allow access to files within working directory or the project root
505
+ project_root = os.path.realpath(get_project_root())
506
+ allowed_paths = [real_working_dir, project_root]
507
+
508
+ is_allowed = any(real_path.startswith(allowed_path) for allowed_path in allowed_paths)
509
+
510
+ if not is_allowed:
511
+ return {
512
+ 'success': False,
513
+ 'error': 'Access denied: file is outside allowed directories',
514
+ 'file_path': file_path
515
+ }
516
+
517
+ except Exception as path_error:
518
+ self.logger.error(f"Path validation error: {path_error}")
519
+ return {
520
+ 'success': False,
521
+ 'error': 'Invalid file path',
522
+ 'file_path': file_path
523
+ }
524
+
525
+ # Check if file exists
526
+ if not os.path.exists(real_path):
527
+ return {
528
+ 'success': False,
529
+ 'error': 'File does not exist',
530
+ 'file_path': file_path
531
+ }
532
+
533
+ # Check if it's a file (not directory)
534
+ if not os.path.isfile(real_path):
535
+ return {
536
+ 'success': False,
537
+ 'error': 'Path is not a file',
538
+ 'file_path': file_path
539
+ }
540
+
541
+ # Check file size
542
+ file_size = os.path.getsize(real_path)
543
+ if file_size > max_size:
544
+ return {
545
+ 'success': False,
546
+ 'error': f'File too large ({file_size} bytes). Maximum allowed: {max_size} bytes',
547
+ 'file_path': file_path,
548
+ 'file_size': file_size
549
+ }
550
+
551
+ # Read file content
552
+ try:
553
+ with open(real_path, 'r', encoding='utf-8') as f:
554
+ content = f.read()
555
+
556
+ # Get file extension for syntax highlighting hint
557
+ _, ext = os.path.splitext(real_path)
558
+
559
+ return {
560
+ 'success': True,
561
+ 'file_path': file_path,
562
+ 'content': content,
563
+ 'file_size': file_size,
564
+ 'extension': ext.lower(),
565
+ 'encoding': 'utf-8'
566
+ }
567
+
568
+ except UnicodeDecodeError:
569
+ # Try reading as binary if UTF-8 fails
570
+ try:
571
+ with open(real_path, 'rb') as f:
572
+ binary_content = f.read()
573
+
574
+ # Check if it's a text file by looking for common text patterns
575
+ try:
576
+ text_content = binary_content.decode('latin-1')
577
+ if '\x00' in text_content:
578
+ # Binary file
579
+ return {
580
+ 'success': False,
581
+ 'error': 'File appears to be binary and cannot be displayed as text',
582
+ 'file_path': file_path,
583
+ 'file_size': file_size
584
+ }
585
+ else:
586
+ # Text file with different encoding
587
+ _, ext = os.path.splitext(real_path)
588
+ return {
589
+ 'success': True,
590
+ 'file_path': file_path,
591
+ 'content': text_content,
592
+ 'file_size': file_size,
593
+ 'extension': ext.lower(),
594
+ 'encoding': 'latin-1'
595
+ }
596
+ except Exception:
597
+ return {
598
+ 'success': False,
599
+ 'error': 'File encoding not supported',
600
+ 'file_path': file_path
601
+ }
602
+ except Exception as read_error:
603
+ return {
604
+ 'success': False,
605
+ 'error': f'Failed to read file: {str(read_error)}',
606
+ 'file_path': file_path
607
+ }
608
+
609
+ except Exception as e:
610
+ self.logger.error(f"Error in _read_file_safely: {e}")
611
+ return {
612
+ 'success': False,
613
+ 'error': str(e),
614
+ 'file_path': file_path
615
+ }
356
616
 
357
617
  async def _generate_git_diff(self, file_path: str, timestamp: Optional[str] = None, working_dir: str = None):
358
618
  """Generate git diff for a specific file operation.
@@ -370,17 +630,36 @@ class SocketIOServer:
370
630
  dict: Contains diff content, metadata, and status information
371
631
  """
372
632
  try:
633
+ # If file_path is absolute, determine its git repository
634
+ if os.path.isabs(file_path):
635
+ # Find the directory containing the file
636
+ file_dir = os.path.dirname(file_path)
637
+ if os.path.exists(file_dir):
638
+ # Try to find the git root from the file's directory
639
+ current_dir = file_dir
640
+ while current_dir != "/" and current_dir:
641
+ if os.path.exists(os.path.join(current_dir, ".git")):
642
+ working_dir = current_dir
643
+ self.logger.info(f"Found git repository at: {working_dir}")
644
+ break
645
+ current_dir = os.path.dirname(current_dir)
646
+ else:
647
+ # If no git repo found, use the file's directory
648
+ working_dir = file_dir
649
+ self.logger.info(f"No git repo found, using file's directory: {working_dir}")
650
+
373
651
  if working_dir is None:
374
652
  working_dir = os.getcwd()
375
653
 
376
- # Change to the working directory
654
+ # For read-only git operations, we can work from any directory
655
+ # by passing the -C flag to git commands instead of changing directories
377
656
  original_cwd = os.getcwd()
378
657
  try:
379
- os.chdir(working_dir)
658
+ # We'll use git -C <working_dir> for all commands instead of chdir
380
659
 
381
660
  # Check if this is a git repository
382
661
  git_check = await asyncio.create_subprocess_exec(
383
- 'git', 'rev-parse', '--git-dir',
662
+ 'git', '-C', working_dir, 'rev-parse', '--git-dir',
384
663
  stdout=asyncio.subprocess.PIPE,
385
664
  stderr=asyncio.subprocess.PIPE
386
665
  )
@@ -388,6 +667,7 @@ class SocketIOServer:
388
667
 
389
668
  if git_check.returncode != 0:
390
669
  return {
670
+ "success": False,
391
671
  "error": "Not a git repository",
392
672
  "file_path": file_path,
393
673
  "working_dir": working_dir
@@ -395,14 +675,14 @@ class SocketIOServer:
395
675
 
396
676
  # Get the absolute path of the file relative to git root
397
677
  git_root_proc = await asyncio.create_subprocess_exec(
398
- 'git', 'rev-parse', '--show-toplevel',
678
+ 'git', '-C', working_dir, 'rev-parse', '--show-toplevel',
399
679
  stdout=asyncio.subprocess.PIPE,
400
680
  stderr=asyncio.subprocess.PIPE
401
681
  )
402
682
  git_root_output, _ = await git_root_proc.communicate()
403
683
 
404
684
  if git_root_proc.returncode != 0:
405
- return {"error": "Failed to determine git root directory"}
685
+ return {"success": False, "error": "Failed to determine git root directory"}
406
686
 
407
687
  git_root = git_root_output.decode().strip()
408
688
 
@@ -424,7 +704,7 @@ class SocketIOServer:
424
704
 
425
705
  # Find commits that modified this file around the timestamp
426
706
  log_proc = await asyncio.create_subprocess_exec(
427
- 'git', 'log', '--oneline', '--since', git_since,
707
+ 'git', '-C', working_dir, 'log', '--oneline', '--since', git_since,
428
708
  '--until', f'{git_since} +1 hour', '--', file_path,
429
709
  stdout=asyncio.subprocess.PIPE,
430
710
  stderr=asyncio.subprocess.PIPE
@@ -439,7 +719,7 @@ class SocketIOServer:
439
719
 
440
720
  # Get the diff for this specific commit
441
721
  diff_proc = await asyncio.create_subprocess_exec(
442
- 'git', 'show', '--format=fuller', commit_hash, '--', file_path,
722
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
443
723
  stdout=asyncio.subprocess.PIPE,
444
724
  stderr=asyncio.subprocess.PIPE
445
725
  )
@@ -459,7 +739,7 @@ class SocketIOServer:
459
739
 
460
740
  # Fallback: Get the most recent change to the file
461
741
  log_proc = await asyncio.create_subprocess_exec(
462
- 'git', 'log', '-1', '--oneline', '--', file_path,
742
+ 'git', '-C', working_dir, 'log', '-1', '--oneline', '--', file_path,
463
743
  stdout=asyncio.subprocess.PIPE,
464
744
  stderr=asyncio.subprocess.PIPE
465
745
  )
@@ -470,7 +750,7 @@ class SocketIOServer:
470
750
 
471
751
  # Get the diff for the most recent commit
472
752
  diff_proc = await asyncio.create_subprocess_exec(
473
- 'git', 'show', '--format=fuller', commit_hash, '--', file_path,
753
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
474
754
  stdout=asyncio.subprocess.PIPE,
475
755
  stderr=asyncio.subprocess.PIPE
476
756
  )
@@ -486,9 +766,45 @@ class SocketIOServer:
486
766
  "timestamp": timestamp
487
767
  }
488
768
 
489
- # Final fallback: Show current working directory changes
769
+ # Try to show unstaged changes first
490
770
  diff_proc = await asyncio.create_subprocess_exec(
491
- 'git', 'diff', 'HEAD', '--', file_path,
771
+ 'git', '-C', working_dir, 'diff', '--', file_path,
772
+ stdout=asyncio.subprocess.PIPE,
773
+ stderr=asyncio.subprocess.PIPE
774
+ )
775
+ diff_output, _ = await diff_proc.communicate()
776
+
777
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
778
+ return {
779
+ "success": True,
780
+ "diff": diff_output.decode(),
781
+ "commit_hash": "unstaged_changes",
782
+ "file_path": file_path,
783
+ "method": "unstaged_changes",
784
+ "timestamp": timestamp
785
+ }
786
+
787
+ # Then try staged changes
788
+ diff_proc = await asyncio.create_subprocess_exec(
789
+ 'git', '-C', working_dir, 'diff', '--cached', '--', file_path,
790
+ stdout=asyncio.subprocess.PIPE,
791
+ stderr=asyncio.subprocess.PIPE
792
+ )
793
+ diff_output, _ = await diff_proc.communicate()
794
+
795
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
796
+ return {
797
+ "success": True,
798
+ "diff": diff_output.decode(),
799
+ "commit_hash": "staged_changes",
800
+ "file_path": file_path,
801
+ "method": "staged_changes",
802
+ "timestamp": timestamp
803
+ }
804
+
805
+ # Final fallback: Show changes against HEAD
806
+ diff_proc = await asyncio.create_subprocess_exec(
807
+ 'git', '-C', working_dir, 'diff', 'HEAD', '--', file_path,
492
808
  stdout=asyncio.subprocess.PIPE,
493
809
  stderr=asyncio.subprocess.PIPE
494
810
  )
@@ -506,14 +822,58 @@ class SocketIOServer:
506
822
  "timestamp": timestamp
507
823
  }
508
824
 
825
+ # Check if file is tracked by git
826
+ status_proc = await asyncio.create_subprocess_exec(
827
+ 'git', '-C', working_dir, 'ls-files', '--', file_path,
828
+ stdout=asyncio.subprocess.PIPE,
829
+ stderr=asyncio.subprocess.PIPE
830
+ )
831
+ status_output, _ = await status_proc.communicate()
832
+
833
+ is_tracked = status_proc.returncode == 0 and status_output.decode().strip()
834
+
835
+ if not is_tracked:
836
+ # File is not tracked by git
837
+ return {
838
+ "success": False,
839
+ "error": "This file is not tracked by git",
840
+ "file_path": file_path,
841
+ "working_dir": working_dir,
842
+ "suggestions": [
843
+ "This file has not been added to git yet",
844
+ "Use 'git add' to track this file before viewing its diff",
845
+ "Git diff can only show changes for files that are tracked by git"
846
+ ]
847
+ }
848
+
849
+ # File is tracked but has no changes to show
850
+ suggestions = [
851
+ "The file may not have any committed changes yet",
852
+ "The file may have been added but not committed",
853
+ "The timestamp may be outside the git history range"
854
+ ]
855
+
856
+ if os.path.isabs(file_path) and not file_path.startswith(os.getcwd()):
857
+ current_repo = os.path.basename(os.getcwd())
858
+ file_repo = "unknown"
859
+ # Try to extract repository name from path
860
+ path_parts = file_path.split("/")
861
+ if "Projects" in path_parts:
862
+ idx = path_parts.index("Projects")
863
+ if idx + 1 < len(path_parts):
864
+ file_repo = path_parts[idx + 1]
865
+
866
+ suggestions.clear()
867
+ suggestions.append(f"This file is from the '{file_repo}' repository")
868
+ suggestions.append(f"The git diff viewer is running from the '{current_repo}' repository")
869
+ suggestions.append("Git diff can only show changes for files in the current repository")
870
+ suggestions.append("To view changes for this file, run the monitoring dashboard from its repository")
871
+
509
872
  return {
873
+ "success": False,
510
874
  "error": "No git history found for this file",
511
875
  "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
- ]
876
+ "suggestions": suggestions
517
877
  }
518
878
 
519
879
  finally:
@@ -522,6 +882,7 @@ class SocketIOServer:
522
882
  except Exception as e:
523
883
  self.logger.error(f"Error in _generate_git_diff: {e}")
524
884
  return {
885
+ "success": False,
525
886
  "error": f"Git diff generation failed: {str(e)}",
526
887
  "file_path": file_path
527
888
  }
@@ -630,6 +991,245 @@ class SocketIOServer:
630
991
 
631
992
  # Re-broadcast to all other clients
632
993
  await self.sio.emit('claude_event', data, skip_sid=sid)
994
+
995
+ @self.sio.event
996
+ async def get_git_branch(sid, working_dir=None):
997
+ """Get the current git branch for a directory"""
998
+ import subprocess
999
+ try:
1000
+ self.logger.info(f"[GIT-BRANCH-DEBUG] get_git_branch called with working_dir: {repr(working_dir)} (type: {type(working_dir)})")
1001
+
1002
+ # Handle case where working_dir is None, empty string, or 'Unknown'
1003
+ original_working_dir = working_dir
1004
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '':
1005
+ working_dir = os.getcwd()
1006
+ self.logger.info(f"[GIT-BRANCH-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1007
+ else:
1008
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Using provided working_dir: {working_dir}")
1009
+
1010
+ # Validate that the directory exists and is a valid path
1011
+ if not os.path.exists(working_dir):
1012
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Directory does not exist: {working_dir}")
1013
+ await self.sio.emit('git_branch_response', {
1014
+ 'success': False,
1015
+ 'error': f'Directory does not exist: {working_dir}',
1016
+ 'working_dir': working_dir,
1017
+ 'original_working_dir': original_working_dir
1018
+ }, room=sid)
1019
+ return
1020
+
1021
+ if not os.path.isdir(working_dir):
1022
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Path is not a directory: {working_dir}")
1023
+ await self.sio.emit('git_branch_response', {
1024
+ 'success': False,
1025
+ 'error': f'Path is not a directory: {working_dir}',
1026
+ 'working_dir': working_dir,
1027
+ 'original_working_dir': original_working_dir
1028
+ }, room=sid)
1029
+ return
1030
+
1031
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Running git command in directory: {working_dir}")
1032
+
1033
+ # Run git command to get current branch
1034
+ result = subprocess.run(
1035
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1036
+ cwd=working_dir,
1037
+ capture_output=True,
1038
+ text=True
1039
+ )
1040
+
1041
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Git command result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1042
+
1043
+ if result.returncode == 0:
1044
+ branch = result.stdout.strip()
1045
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Successfully got git branch: {branch}")
1046
+ await self.sio.emit('git_branch_response', {
1047
+ 'success': True,
1048
+ 'branch': branch,
1049
+ 'working_dir': working_dir,
1050
+ 'original_working_dir': original_working_dir
1051
+ }, room=sid)
1052
+ else:
1053
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Git command failed: {result.stderr}")
1054
+ await self.sio.emit('git_branch_response', {
1055
+ 'success': False,
1056
+ 'error': 'Not a git repository',
1057
+ 'working_dir': working_dir,
1058
+ 'original_working_dir': original_working_dir,
1059
+ 'git_error': result.stderr
1060
+ }, room=sid)
1061
+
1062
+ except Exception as e:
1063
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Exception in get_git_branch: {e}")
1064
+ import traceback
1065
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Stack trace: {traceback.format_exc()}")
1066
+ await self.sio.emit('git_branch_response', {
1067
+ 'success': False,
1068
+ 'error': str(e),
1069
+ 'working_dir': working_dir,
1070
+ 'original_working_dir': original_working_dir
1071
+ }, room=sid)
1072
+
1073
+ @self.sio.event
1074
+ async def check_file_tracked(sid, data):
1075
+ """Check if a file is tracked by git"""
1076
+ import subprocess
1077
+ try:
1078
+ file_path = data.get('file_path')
1079
+ working_dir = data.get('working_dir', os.getcwd())
1080
+
1081
+ if not file_path:
1082
+ await self.sio.emit('file_tracked_response', {
1083
+ 'success': False,
1084
+ 'error': 'file_path is required',
1085
+ 'file_path': file_path
1086
+ }, room=sid)
1087
+ return
1088
+
1089
+ # Use git ls-files to check if file is tracked
1090
+ result = subprocess.run(
1091
+ ["git", "-C", working_dir, "ls-files", "--", file_path],
1092
+ capture_output=True,
1093
+ text=True
1094
+ )
1095
+
1096
+ is_tracked = result.returncode == 0 and result.stdout.strip()
1097
+
1098
+ await self.sio.emit('file_tracked_response', {
1099
+ 'success': True,
1100
+ 'file_path': file_path,
1101
+ 'working_dir': working_dir,
1102
+ 'is_tracked': bool(is_tracked)
1103
+ }, room=sid)
1104
+
1105
+ except Exception as e:
1106
+ self.logger.error(f"Error checking file tracked status: {e}")
1107
+ await self.sio.emit('file_tracked_response', {
1108
+ 'success': False,
1109
+ 'error': str(e),
1110
+ 'file_path': data.get('file_path', 'unknown')
1111
+ }, room=sid)
1112
+
1113
+ @self.sio.event
1114
+ async def read_file(sid, data):
1115
+ """Read file contents safely"""
1116
+ try:
1117
+ file_path = data.get('file_path')
1118
+ working_dir = data.get('working_dir', os.getcwd())
1119
+ max_size = data.get('max_size', 1024 * 1024) # 1MB default limit
1120
+
1121
+ if not file_path:
1122
+ await self.sio.emit('file_content_response', {
1123
+ 'success': False,
1124
+ 'error': 'file_path is required',
1125
+ 'file_path': file_path
1126
+ }, room=sid)
1127
+ return
1128
+
1129
+ # Use the shared file reading logic
1130
+ result = await self._read_file_safely(file_path, working_dir, max_size)
1131
+
1132
+ # Send the result back to the client
1133
+ await self.sio.emit('file_content_response', result, room=sid)
1134
+
1135
+ except Exception as e:
1136
+ self.logger.error(f"Error reading file: {e}")
1137
+ await self.sio.emit('file_content_response', {
1138
+ 'success': False,
1139
+ 'error': str(e),
1140
+ 'file_path': data.get('file_path', 'unknown')
1141
+ }, room=sid)
1142
+
1143
+ @self.sio.event
1144
+ async def git_add_file(sid, data):
1145
+ """Add file to git tracking"""
1146
+ import subprocess
1147
+ try:
1148
+ file_path = data.get('file_path')
1149
+ working_dir = data.get('working_dir', os.getcwd())
1150
+
1151
+ 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)})")
1152
+
1153
+ if not file_path:
1154
+ await self.sio.emit('git_add_response', {
1155
+ 'success': False,
1156
+ 'error': 'file_path is required',
1157
+ 'file_path': file_path
1158
+ }, room=sid)
1159
+ return
1160
+
1161
+ # Validate and sanitize working_dir
1162
+ original_working_dir = working_dir
1163
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '' or working_dir == '.':
1164
+ working_dir = os.getcwd()
1165
+ self.logger.info(f"[GIT-ADD-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1166
+ else:
1167
+ self.logger.info(f"[GIT-ADD-DEBUG] Using provided working_dir: {working_dir}")
1168
+
1169
+ # Validate that the directory exists and is a valid path
1170
+ if not os.path.exists(working_dir):
1171
+ self.logger.warning(f"[GIT-ADD-DEBUG] Directory does not exist: {working_dir}")
1172
+ await self.sio.emit('git_add_response', {
1173
+ 'success': False,
1174
+ 'error': f'Directory does not exist: {working_dir}',
1175
+ 'file_path': file_path,
1176
+ 'working_dir': working_dir,
1177
+ 'original_working_dir': original_working_dir
1178
+ }, room=sid)
1179
+ return
1180
+
1181
+ if not os.path.isdir(working_dir):
1182
+ self.logger.warning(f"[GIT-ADD-DEBUG] Path is not a directory: {working_dir}")
1183
+ await self.sio.emit('git_add_response', {
1184
+ 'success': False,
1185
+ 'error': f'Path is not a directory: {working_dir}',
1186
+ 'file_path': file_path,
1187
+ 'working_dir': working_dir,
1188
+ 'original_working_dir': original_working_dir
1189
+ }, room=sid)
1190
+ return
1191
+
1192
+ self.logger.info(f"[GIT-ADD-DEBUG] Running git add command in directory: {working_dir}")
1193
+
1194
+ # Use git add to track the file
1195
+ result = subprocess.run(
1196
+ ["git", "-C", working_dir, "add", file_path],
1197
+ capture_output=True,
1198
+ text=True
1199
+ )
1200
+
1201
+ self.logger.info(f"[GIT-ADD-DEBUG] Git add result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1202
+
1203
+ if result.returncode == 0:
1204
+ self.logger.info(f"[GIT-ADD-DEBUG] Successfully added {file_path} to git in {working_dir}")
1205
+ await self.sio.emit('git_add_response', {
1206
+ 'success': True,
1207
+ 'file_path': file_path,
1208
+ 'working_dir': working_dir,
1209
+ 'original_working_dir': original_working_dir,
1210
+ 'message': 'File successfully added to git tracking'
1211
+ }, room=sid)
1212
+ else:
1213
+ error_message = result.stderr.strip() or 'Unknown git error'
1214
+ self.logger.warning(f"[GIT-ADD-DEBUG] Git add failed: {error_message}")
1215
+ await self.sio.emit('git_add_response', {
1216
+ 'success': False,
1217
+ 'error': f'Git add failed: {error_message}',
1218
+ 'file_path': file_path,
1219
+ 'working_dir': working_dir,
1220
+ 'original_working_dir': original_working_dir
1221
+ }, room=sid)
1222
+
1223
+ except Exception as e:
1224
+ self.logger.error(f"[GIT-ADD-DEBUG] Exception in git_add_file: {e}")
1225
+ import traceback
1226
+ self.logger.error(f"[GIT-ADD-DEBUG] Stack trace: {traceback.format_exc()}")
1227
+ await self.sio.emit('git_add_response', {
1228
+ 'success': False,
1229
+ 'error': str(e),
1230
+ 'file_path': data.get('file_path', 'unknown'),
1231
+ 'working_dir': data.get('working_dir', 'unknown')
1232
+ }, room=sid)
633
1233
 
634
1234
  async def _send_current_status(self, sid: str):
635
1235
  """Send current system status to a client."""
@@ -737,18 +1337,22 @@ class SocketIOServer:
737
1337
 
738
1338
  # Broadcast to clients with timeout and error handling
739
1339
  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}")
1340
+ # Check if the event loop is still running and not closed
1341
+ if self.loop and not self.loop.is_closed() and self.loop.is_running():
1342
+ future = asyncio.run_coroutine_threadsafe(
1343
+ self.sio.emit('claude_event', event),
1344
+ self.loop
1345
+ )
1346
+ # Wait for completion with timeout to detect issues
1347
+ try:
1348
+ future.result(timeout=2.0) # 2 second timeout
1349
+ self.logger.debug(f"📨 Successfully broadcasted {event_type} to {len(self.clients)} clients")
1350
+ except asyncio.TimeoutError:
1351
+ self.logger.warning(f" Broadcast timeout for event {event_type} - continuing anyway")
1352
+ except Exception as emit_error:
1353
+ self.logger.error(f"❌ Broadcast emit error for {event_type}: {emit_error}")
1354
+ else:
1355
+ self.logger.warning(f"⚠️ Event loop not available for broadcast of {event_type} - event loop closed or not running")
752
1356
  except Exception as e:
753
1357
  self.logger.error(f"❌ Failed to submit broadcast to event loop: {e}")
754
1358
  import traceback
@@ -919,4 +1523,4 @@ def stop_socketio_server():
919
1523
  global _socketio_server
920
1524
  if _socketio_server:
921
1525
  _socketio_server.stop()
922
- _socketio_server = None
1526
+ _socketio_server = None