claude-mpm 3.3.2__py3-none-any.whl → 3.4.2__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 (48) hide show
  1. claude_mpm/cli/commands/memory.py +192 -14
  2. claude_mpm/cli/parser.py +13 -1
  3. claude_mpm/constants.py +1 -0
  4. claude_mpm/core/claude_runner.py +61 -0
  5. claude_mpm/core/config.py +161 -1
  6. claude_mpm/core/simple_runner.py +61 -0
  7. claude_mpm/hooks/builtin/mpm_command_hook.py +5 -5
  8. claude_mpm/hooks/claude_hooks/hook_handler.py +211 -4
  9. claude_mpm/hooks/claude_hooks/hook_wrapper.sh +10 -3
  10. claude_mpm/hooks/memory_integration_hook.py +51 -5
  11. claude_mpm/scripts/socketio_daemon.py +49 -9
  12. claude_mpm/scripts/socketio_server_manager.py +370 -45
  13. claude_mpm/services/__init__.py +41 -5
  14. claude_mpm/services/agent_memory_manager.py +541 -51
  15. claude_mpm/services/exceptions.py +677 -0
  16. claude_mpm/services/health_monitor.py +892 -0
  17. claude_mpm/services/memory_builder.py +341 -7
  18. claude_mpm/services/memory_optimizer.py +6 -2
  19. claude_mpm/services/project_analyzer.py +771 -0
  20. claude_mpm/services/recovery_manager.py +670 -0
  21. claude_mpm/services/socketio_server.py +653 -36
  22. claude_mpm/services/standalone_socketio_server.py +703 -34
  23. claude_mpm/services/version_control/git_operations.py +26 -0
  24. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.dist-info}/METADATA +34 -10
  25. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.dist-info}/RECORD +30 -44
  26. claude_mpm/agents/agent-template.yaml +0 -83
  27. claude_mpm/agents/test_fix_deployment/.claude-pm/config/project.json +0 -6
  28. claude_mpm/cli/README.md +0 -109
  29. claude_mpm/cli_module/refactoring_guide.md +0 -253
  30. claude_mpm/core/agent_registry.py.bak +0 -312
  31. claude_mpm/core/base_service.py.bak +0 -406
  32. claude_mpm/hooks/README.md +0 -97
  33. claude_mpm/orchestration/SUBPROCESS_DESIGN.md +0 -66
  34. claude_mpm/schemas/README_SECURITY.md +0 -92
  35. claude_mpm/schemas/agent_schema.json +0 -395
  36. claude_mpm/schemas/agent_schema_documentation.md +0 -181
  37. claude_mpm/schemas/agent_schema_security_notes.md +0 -165
  38. claude_mpm/schemas/examples/standard_workflow.json +0 -505
  39. claude_mpm/schemas/ticket_workflow_documentation.md +0 -482
  40. claude_mpm/schemas/ticket_workflow_schema.json +0 -590
  41. claude_mpm/services/framework_claude_md_generator/README.md +0 -92
  42. claude_mpm/services/parent_directory_manager/README.md +0 -83
  43. claude_mpm/services/version_control/VERSION +0 -1
  44. /claude_mpm/{web → dashboard}/open_dashboard.py +0 -0
  45. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.dist-info}/WHEEL +0 -0
  46. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.dist-info}/entry_points.txt +0 -0
  47. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.dist-info}/licenses/LICENSE +0 -0
  48. {claude_mpm-3.3.2.dist-info → claude_mpm-3.4.2.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."""
@@ -270,13 +286,15 @@ class SocketIOServer:
270
286
  self.app.router.add_get('/status', self._handle_health)
271
287
  self.app.router.add_get('/api/git-diff', self._handle_git_diff)
272
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)
273
291
 
274
292
  # Add dashboard routes
275
293
  self.app.router.add_get('/', self._handle_dashboard)
276
294
  self.app.router.add_get('/dashboard', self._handle_dashboard)
277
295
 
278
296
  # Add static file serving for web assets
279
- static_path = get_project_root() / 'src' / 'claude_mpm' / 'web' / 'static'
297
+ static_path = get_project_root() / 'src' / 'claude_mpm' / 'dashboard' / 'static'
280
298
  if static_path.exists():
281
299
  self.app.router.add_static('/static/', path=str(static_path), name='static')
282
300
 
@@ -330,7 +348,7 @@ class SocketIOServer:
330
348
 
331
349
  async def _handle_dashboard(self, request):
332
350
  """Serve the dashboard HTML file."""
333
- dashboard_path = get_project_root() / 'src' / 'claude_mpm' / 'web' / 'templates' / 'index.html'
351
+ dashboard_path = get_project_root() / 'src' / 'claude_mpm' / 'dashboard' / 'templates' / 'index.html'
334
352
  self.logger.info(f"Dashboard requested, looking for: {dashboard_path}")
335
353
  self.logger.info(f"Path exists: {dashboard_path.exists()}")
336
354
  if dashboard_path.exists():
@@ -365,6 +383,7 @@ class SocketIOServer:
365
383
  working_dir = request.query.get('working_dir', os.getcwd())
366
384
 
367
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}")
368
387
 
369
388
  if not file_path:
370
389
  self.logger.warning("Git diff request missing file parameter")
@@ -403,6 +422,198 @@ class SocketIOServer:
403
422
  'Access-Control-Allow-Headers': 'Content-Type, Accept'
404
423
  })
405
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
+ }
616
+
406
617
  async def _generate_git_diff(self, file_path: str, timestamp: Optional[str] = None, working_dir: str = None):
407
618
  """Generate git diff for a specific file operation.
408
619
 
@@ -437,8 +648,13 @@ class SocketIOServer:
437
648
  working_dir = file_dir
438
649
  self.logger.info(f"No git repo found, using file's directory: {working_dir}")
439
650
 
440
- if working_dir is None:
651
+ # Handle case where working_dir is None, empty string, or 'Unknown'
652
+ original_working_dir = working_dir
653
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '':
441
654
  working_dir = os.getcwd()
655
+ self.logger.info(f"[GIT-DIFF-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
656
+ else:
657
+ self.logger.info(f"[GIT-DIFF-DEBUG] Using provided working_dir: {working_dir}")
442
658
 
443
659
  # For read-only git operations, we can work from any directory
444
660
  # by passing the -C flag to git commands instead of changing directories
@@ -611,10 +827,34 @@ class SocketIOServer:
611
827
  "timestamp": timestamp
612
828
  }
613
829
 
614
- # Check if file is from a different repository
830
+ # Check if file is tracked by git
831
+ status_proc = await asyncio.create_subprocess_exec(
832
+ 'git', '-C', working_dir, 'ls-files', '--', file_path,
833
+ stdout=asyncio.subprocess.PIPE,
834
+ stderr=asyncio.subprocess.PIPE
835
+ )
836
+ status_output, _ = await status_proc.communicate()
837
+
838
+ is_tracked = status_proc.returncode == 0 and status_output.decode().strip()
839
+
840
+ if not is_tracked:
841
+ # File is not tracked by git
842
+ return {
843
+ "success": False,
844
+ "error": "This file is not tracked by git",
845
+ "file_path": file_path,
846
+ "working_dir": working_dir,
847
+ "suggestions": [
848
+ "This file has not been added to git yet",
849
+ "Use 'git add' to track this file before viewing its diff",
850
+ "Git diff can only show changes for files that are tracked by git"
851
+ ]
852
+ }
853
+
854
+ # File is tracked but has no changes to show
615
855
  suggestions = [
616
- "The file may not be tracked by git",
617
- "The file may not have any committed changes",
856
+ "The file may not have any committed changes yet",
857
+ "The file may have been added but not committed",
618
858
  "The timestamp may be outside the git history range"
619
859
  ]
620
860
 
@@ -762,8 +1002,53 @@ class SocketIOServer:
762
1002
  """Get the current git branch for a directory"""
763
1003
  import subprocess
764
1004
  try:
765
- if not working_dir:
1005
+ self.logger.info(f"[GIT-BRANCH-DEBUG] get_git_branch called with working_dir: {repr(working_dir)} (type: {type(working_dir)})")
1006
+
1007
+ # Handle case where working_dir is None, empty string, or common invalid states
1008
+ original_working_dir = working_dir
1009
+ invalid_states = [
1010
+ None, '', 'Unknown', 'Loading...', 'Loading', 'undefined', 'null',
1011
+ 'Not Connected', 'Invalid Directory', 'No Directory'
1012
+ ]
1013
+
1014
+ if working_dir in invalid_states or (isinstance(working_dir, str) and working_dir.strip() == ''):
766
1015
  working_dir = os.getcwd()
1016
+ self.logger.info(f"[GIT-BRANCH-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1017
+ else:
1018
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Using provided working_dir: {working_dir}")
1019
+
1020
+ # Additional validation for obviously invalid paths
1021
+ if isinstance(working_dir, str):
1022
+ working_dir = working_dir.strip()
1023
+ # Check for null bytes or other invalid characters
1024
+ if '\x00' in working_dir:
1025
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] working_dir contains null bytes, using cwd instead")
1026
+ working_dir = os.getcwd()
1027
+
1028
+ # Validate that the directory exists and is a valid path
1029
+ if not os.path.exists(working_dir):
1030
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Directory does not exist: {working_dir} - responding gracefully")
1031
+ await self.sio.emit('git_branch_response', {
1032
+ 'success': False,
1033
+ 'error': f'Directory not found',
1034
+ 'working_dir': working_dir,
1035
+ 'original_working_dir': original_working_dir,
1036
+ 'detail': f'Path does not exist: {working_dir}'
1037
+ }, room=sid)
1038
+ return
1039
+
1040
+ if not os.path.isdir(working_dir):
1041
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Path is not a directory: {working_dir} - responding gracefully")
1042
+ await self.sio.emit('git_branch_response', {
1043
+ 'success': False,
1044
+ 'error': f'Not a directory',
1045
+ 'working_dir': working_dir,
1046
+ 'original_working_dir': original_working_dir,
1047
+ 'detail': f'Path is not a directory: {working_dir}'
1048
+ }, room=sid)
1049
+ return
1050
+
1051
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Running git command in directory: {working_dir}")
767
1052
 
768
1053
  # Run git command to get current branch
769
1054
  result = subprocess.run(
@@ -773,26 +1058,354 @@ class SocketIOServer:
773
1058
  text=True
774
1059
  )
775
1060
 
1061
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Git command result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1062
+
776
1063
  if result.returncode == 0:
777
1064
  branch = result.stdout.strip()
1065
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Successfully got git branch: {branch}")
778
1066
  await self.sio.emit('git_branch_response', {
779
1067
  'success': True,
780
1068
  'branch': branch,
781
- 'working_dir': working_dir
1069
+ 'working_dir': working_dir,
1070
+ 'original_working_dir': original_working_dir
782
1071
  }, room=sid)
783
1072
  else:
1073
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Git command failed: {result.stderr}")
784
1074
  await self.sio.emit('git_branch_response', {
785
1075
  'success': False,
786
1076
  'error': 'Not a git repository',
787
- 'working_dir': working_dir
1077
+ 'working_dir': working_dir,
1078
+ 'original_working_dir': original_working_dir,
1079
+ 'git_error': result.stderr
788
1080
  }, room=sid)
789
1081
 
790
1082
  except Exception as e:
791
- self.logger.error(f"Error getting git branch: {e}")
1083
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Exception in get_git_branch: {e}")
1084
+ import traceback
1085
+ self.logger.error(f"[GIT-BRANCH-DEBUG] Stack trace: {traceback.format_exc()}")
792
1086
  await self.sio.emit('git_branch_response', {
793
1087
  'success': False,
794
1088
  'error': str(e),
795
- 'working_dir': working_dir
1089
+ 'working_dir': working_dir,
1090
+ 'original_working_dir': original_working_dir
1091
+ }, room=sid)
1092
+
1093
+ @self.sio.event
1094
+ async def check_file_tracked(sid, data):
1095
+ """Check if a file is tracked by git"""
1096
+ import subprocess
1097
+ try:
1098
+ file_path = data.get('file_path')
1099
+ working_dir = data.get('working_dir', os.getcwd())
1100
+
1101
+ if not file_path:
1102
+ await self.sio.emit('file_tracked_response', {
1103
+ 'success': False,
1104
+ 'error': 'file_path is required',
1105
+ 'file_path': file_path
1106
+ }, room=sid)
1107
+ return
1108
+
1109
+ # Use git ls-files to check if file is tracked
1110
+ result = subprocess.run(
1111
+ ["git", "-C", working_dir, "ls-files", "--", file_path],
1112
+ capture_output=True,
1113
+ text=True
1114
+ )
1115
+
1116
+ is_tracked = result.returncode == 0 and result.stdout.strip()
1117
+
1118
+ await self.sio.emit('file_tracked_response', {
1119
+ 'success': True,
1120
+ 'file_path': file_path,
1121
+ 'working_dir': working_dir,
1122
+ 'is_tracked': bool(is_tracked)
1123
+ }, room=sid)
1124
+
1125
+ except Exception as e:
1126
+ self.logger.error(f"Error checking file tracked status: {e}")
1127
+ await self.sio.emit('file_tracked_response', {
1128
+ 'success': False,
1129
+ 'error': str(e),
1130
+ 'file_path': data.get('file_path', 'unknown')
1131
+ }, room=sid)
1132
+
1133
+ @self.sio.event
1134
+ async def read_file(sid, data):
1135
+ """Read file contents safely"""
1136
+ try:
1137
+ file_path = data.get('file_path')
1138
+ working_dir = data.get('working_dir', os.getcwd())
1139
+ max_size = data.get('max_size', 1024 * 1024) # 1MB default limit
1140
+
1141
+ if not file_path:
1142
+ await self.sio.emit('file_content_response', {
1143
+ 'success': False,
1144
+ 'error': 'file_path is required',
1145
+ 'file_path': file_path
1146
+ }, room=sid)
1147
+ return
1148
+
1149
+ # Use the shared file reading logic
1150
+ result = await self._read_file_safely(file_path, working_dir, max_size)
1151
+
1152
+ # Send the result back to the client
1153
+ await self.sio.emit('file_content_response', result, room=sid)
1154
+
1155
+ except Exception as e:
1156
+ self.logger.error(f"Error reading file: {e}")
1157
+ await self.sio.emit('file_content_response', {
1158
+ 'success': False,
1159
+ 'error': str(e),
1160
+ 'file_path': data.get('file_path', 'unknown')
1161
+ }, room=sid)
1162
+
1163
+ @self.sio.event
1164
+ async def check_git_status(sid, data):
1165
+ """Check git status for a file to determine if git diff icons should be shown"""
1166
+ import subprocess
1167
+ try:
1168
+ file_path = data.get('file_path')
1169
+ working_dir = data.get('working_dir', os.getcwd())
1170
+
1171
+ self.logger.info(f"[GIT-STATUS-DEBUG] check_git_status called with file_path: {repr(file_path)}, working_dir: {repr(working_dir)}")
1172
+
1173
+ if not file_path:
1174
+ await self.sio.emit('git_status_response', {
1175
+ 'success': False,
1176
+ 'error': 'file_path is required',
1177
+ 'file_path': file_path
1178
+ }, room=sid)
1179
+ return
1180
+
1181
+ # Validate and sanitize working_dir
1182
+ original_working_dir = working_dir
1183
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '' or working_dir == '.':
1184
+ working_dir = os.getcwd()
1185
+ self.logger.info(f"[GIT-STATUS-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1186
+ else:
1187
+ self.logger.info(f"[GIT-STATUS-DEBUG] Using provided working_dir: {working_dir}")
1188
+
1189
+ # Check if the working directory exists and is a directory
1190
+ if not os.path.exists(working_dir):
1191
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Directory does not exist: {working_dir}")
1192
+ await self.sio.emit('git_status_response', {
1193
+ 'success': False,
1194
+ 'error': f'Directory does not exist: {working_dir}',
1195
+ 'file_path': file_path,
1196
+ 'working_dir': working_dir,
1197
+ 'original_working_dir': original_working_dir
1198
+ }, room=sid)
1199
+ return
1200
+
1201
+ if not os.path.isdir(working_dir):
1202
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Path is not a directory: {working_dir}")
1203
+ await self.sio.emit('git_status_response', {
1204
+ 'success': False,
1205
+ 'error': f'Path is not a directory: {working_dir}',
1206
+ 'file_path': file_path,
1207
+ 'working_dir': working_dir,
1208
+ 'original_working_dir': original_working_dir
1209
+ }, room=sid)
1210
+ return
1211
+
1212
+ # Check if this is a git repository
1213
+ self.logger.info(f"[GIT-STATUS-DEBUG] Checking if {working_dir} is a git repository")
1214
+ git_check = subprocess.run(
1215
+ ["git", "-C", working_dir, "rev-parse", "--git-dir"],
1216
+ capture_output=True,
1217
+ text=True
1218
+ )
1219
+
1220
+ if git_check.returncode != 0:
1221
+ self.logger.info(f"[GIT-STATUS-DEBUG] Not a git repository: {working_dir}")
1222
+ await self.sio.emit('git_status_response', {
1223
+ 'success': False,
1224
+ 'error': 'Not a git repository',
1225
+ 'file_path': file_path,
1226
+ 'working_dir': working_dir,
1227
+ 'original_working_dir': original_working_dir
1228
+ }, room=sid)
1229
+ return
1230
+
1231
+ # Determine if the file path should be made relative to git root
1232
+ file_path_for_git = file_path
1233
+ if os.path.isabs(file_path):
1234
+ # Get git root to make path relative if needed
1235
+ git_root_result = subprocess.run(
1236
+ ["git", "-C", working_dir, "rev-parse", "--show-toplevel"],
1237
+ capture_output=True,
1238
+ text=True
1239
+ )
1240
+
1241
+ if git_root_result.returncode == 0:
1242
+ git_root = git_root_result.stdout.strip()
1243
+ try:
1244
+ file_path_for_git = os.path.relpath(file_path, git_root)
1245
+ self.logger.info(f"[GIT-STATUS-DEBUG] Made file path relative to git root: {file_path_for_git}")
1246
+ except ValueError:
1247
+ # File is not under git root - keep original path
1248
+ self.logger.info(f"[GIT-STATUS-DEBUG] File not under git root, keeping original path: {file_path}")
1249
+ pass
1250
+
1251
+ # Check if the file exists
1252
+ full_path = file_path if os.path.isabs(file_path) else os.path.join(working_dir, file_path)
1253
+ if not os.path.exists(full_path):
1254
+ self.logger.warning(f"[GIT-STATUS-DEBUG] File does not exist: {full_path}")
1255
+ await self.sio.emit('git_status_response', {
1256
+ 'success': False,
1257
+ 'error': f'File does not exist: {file_path}',
1258
+ 'file_path': file_path,
1259
+ 'working_dir': working_dir,
1260
+ 'original_working_dir': original_working_dir
1261
+ }, room=sid)
1262
+ return
1263
+
1264
+ # Check git status for the file - this succeeds if git knows about the file
1265
+ # (either tracked, modified, staged, etc.)
1266
+ self.logger.info(f"[GIT-STATUS-DEBUG] Checking git status for file: {file_path_for_git}")
1267
+ git_status_result = subprocess.run(
1268
+ ["git", "-C", working_dir, "status", "--porcelain", file_path_for_git],
1269
+ capture_output=True,
1270
+ text=True
1271
+ )
1272
+
1273
+ self.logger.info(f"[GIT-STATUS-DEBUG] Git status result: returncode={git_status_result.returncode}, stdout={repr(git_status_result.stdout)}, stderr={repr(git_status_result.stderr)}")
1274
+
1275
+ # Also check if file is tracked by git (alternative approach)
1276
+ ls_files_result = subprocess.run(
1277
+ ["git", "-C", working_dir, "ls-files", file_path_for_git],
1278
+ capture_output=True,
1279
+ text=True
1280
+ )
1281
+
1282
+ is_tracked = ls_files_result.returncode == 0 and ls_files_result.stdout.strip()
1283
+ has_status = git_status_result.returncode == 0
1284
+
1285
+ self.logger.info(f"[GIT-STATUS-DEBUG] File tracking status: is_tracked={is_tracked}, has_status={has_status}")
1286
+
1287
+ # Success if git knows about the file (either tracked or has status changes)
1288
+ if is_tracked or has_status:
1289
+ self.logger.info(f"[GIT-STATUS-DEBUG] Git status check successful for {file_path}")
1290
+ await self.sio.emit('git_status_response', {
1291
+ 'success': True,
1292
+ 'file_path': file_path,
1293
+ 'working_dir': working_dir,
1294
+ 'original_working_dir': original_working_dir,
1295
+ 'is_tracked': is_tracked,
1296
+ 'has_changes': bool(git_status_result.stdout.strip()) if has_status else False
1297
+ }, room=sid)
1298
+ else:
1299
+ self.logger.info(f"[GIT-STATUS-DEBUG] File {file_path} is not tracked by git")
1300
+ await self.sio.emit('git_status_response', {
1301
+ 'success': False,
1302
+ 'error': 'File is not tracked by git',
1303
+ 'file_path': file_path,
1304
+ 'working_dir': working_dir,
1305
+ 'original_working_dir': original_working_dir,
1306
+ 'is_tracked': False
1307
+ }, room=sid)
1308
+
1309
+ except Exception as e:
1310
+ self.logger.error(f"[GIT-STATUS-DEBUG] Exception in check_git_status: {e}")
1311
+ import traceback
1312
+ self.logger.error(f"[GIT-STATUS-DEBUG] Stack trace: {traceback.format_exc()}")
1313
+ await self.sio.emit('git_status_response', {
1314
+ 'success': False,
1315
+ 'error': str(e),
1316
+ 'file_path': data.get('file_path', 'unknown'),
1317
+ 'working_dir': data.get('working_dir', 'unknown')
1318
+ }, room=sid)
1319
+
1320
+ @self.sio.event
1321
+ async def git_add_file(sid, data):
1322
+ """Add file to git tracking"""
1323
+ import subprocess
1324
+ try:
1325
+ file_path = data.get('file_path')
1326
+ working_dir = data.get('working_dir', os.getcwd())
1327
+
1328
+ 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)})")
1329
+
1330
+ if not file_path:
1331
+ await self.sio.emit('git_add_response', {
1332
+ 'success': False,
1333
+ 'error': 'file_path is required',
1334
+ 'file_path': file_path
1335
+ }, room=sid)
1336
+ return
1337
+
1338
+ # Validate and sanitize working_dir
1339
+ original_working_dir = working_dir
1340
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '' or working_dir == '.':
1341
+ working_dir = os.getcwd()
1342
+ self.logger.info(f"[GIT-ADD-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
1343
+ else:
1344
+ self.logger.info(f"[GIT-ADD-DEBUG] Using provided working_dir: {working_dir}")
1345
+
1346
+ # Validate that the directory exists and is a valid path
1347
+ if not os.path.exists(working_dir):
1348
+ self.logger.warning(f"[GIT-ADD-DEBUG] Directory does not exist: {working_dir}")
1349
+ await self.sio.emit('git_add_response', {
1350
+ 'success': False,
1351
+ 'error': f'Directory does not exist: {working_dir}',
1352
+ 'file_path': file_path,
1353
+ 'working_dir': working_dir,
1354
+ 'original_working_dir': original_working_dir
1355
+ }, room=sid)
1356
+ return
1357
+
1358
+ if not os.path.isdir(working_dir):
1359
+ self.logger.warning(f"[GIT-ADD-DEBUG] Path is not a directory: {working_dir}")
1360
+ await self.sio.emit('git_add_response', {
1361
+ 'success': False,
1362
+ 'error': f'Path is not a directory: {working_dir}',
1363
+ 'file_path': file_path,
1364
+ 'working_dir': working_dir,
1365
+ 'original_working_dir': original_working_dir
1366
+ }, room=sid)
1367
+ return
1368
+
1369
+ self.logger.info(f"[GIT-ADD-DEBUG] Running git add command in directory: {working_dir}")
1370
+
1371
+ # Use git add to track the file
1372
+ result = subprocess.run(
1373
+ ["git", "-C", working_dir, "add", file_path],
1374
+ capture_output=True,
1375
+ text=True
1376
+ )
1377
+
1378
+ self.logger.info(f"[GIT-ADD-DEBUG] Git add result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
1379
+
1380
+ if result.returncode == 0:
1381
+ self.logger.info(f"[GIT-ADD-DEBUG] Successfully added {file_path} to git in {working_dir}")
1382
+ await self.sio.emit('git_add_response', {
1383
+ 'success': True,
1384
+ 'file_path': file_path,
1385
+ 'working_dir': working_dir,
1386
+ 'original_working_dir': original_working_dir,
1387
+ 'message': 'File successfully added to git tracking'
1388
+ }, room=sid)
1389
+ else:
1390
+ error_message = result.stderr.strip() or 'Unknown git error'
1391
+ self.logger.warning(f"[GIT-ADD-DEBUG] Git add failed: {error_message}")
1392
+ await self.sio.emit('git_add_response', {
1393
+ 'success': False,
1394
+ 'error': f'Git add failed: {error_message}',
1395
+ 'file_path': file_path,
1396
+ 'working_dir': working_dir,
1397
+ 'original_working_dir': original_working_dir
1398
+ }, room=sid)
1399
+
1400
+ except Exception as e:
1401
+ self.logger.error(f"[GIT-ADD-DEBUG] Exception in git_add_file: {e}")
1402
+ import traceback
1403
+ self.logger.error(f"[GIT-ADD-DEBUG] Stack trace: {traceback.format_exc()}")
1404
+ await self.sio.emit('git_add_response', {
1405
+ 'success': False,
1406
+ 'error': str(e),
1407
+ 'file_path': data.get('file_path', 'unknown'),
1408
+ 'working_dir': data.get('working_dir', 'unknown')
796
1409
  }, room=sid)
797
1410
 
798
1411
  async def _send_current_status(self, sid: str):
@@ -901,18 +1514,22 @@ class SocketIOServer:
901
1514
 
902
1515
  # Broadcast to clients with timeout and error handling
903
1516
  try:
904
- future = asyncio.run_coroutine_threadsafe(
905
- self.sio.emit('claude_event', event),
906
- self.loop
907
- )
908
- # Wait for completion with timeout to detect issues
909
- try:
910
- future.result(timeout=2.0) # 2 second timeout
911
- self.logger.debug(f"📨 Successfully broadcasted {event_type} to {len(self.clients)} clients")
912
- except asyncio.TimeoutError:
913
- self.logger.warning(f" Broadcast timeout for event {event_type} - continuing anyway")
914
- except Exception as emit_error:
915
- self.logger.error(f" Broadcast emit error for {event_type}: {emit_error}")
1517
+ # Check if the event loop is still running and not closed
1518
+ if self.loop and not self.loop.is_closed() and self.loop.is_running():
1519
+ future = asyncio.run_coroutine_threadsafe(
1520
+ self.sio.emit('claude_event', event),
1521
+ self.loop
1522
+ )
1523
+ # Wait for completion with timeout to detect issues
1524
+ try:
1525
+ future.result(timeout=2.0) # 2 second timeout
1526
+ self.logger.debug(f"📨 Successfully broadcasted {event_type} to {len(self.clients)} clients")
1527
+ except asyncio.TimeoutError:
1528
+ self.logger.warning(f" Broadcast timeout for event {event_type} - continuing anyway")
1529
+ except Exception as emit_error:
1530
+ self.logger.error(f"❌ Broadcast emit error for {event_type}: {emit_error}")
1531
+ else:
1532
+ self.logger.warning(f"⚠️ Event loop not available for broadcast of {event_type} - event loop closed or not running")
916
1533
  except Exception as e:
917
1534
  self.logger.error(f"❌ Failed to submit broadcast to event loop: {e}")
918
1535
  import traceback