claude-mpm 4.1.10__py3-none-any.whl → 4.1.12__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 (56) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/INSTRUCTIONS.md +8 -0
  3. claude_mpm/cli/__init__.py +11 -0
  4. claude_mpm/cli/commands/analyze.py +2 -1
  5. claude_mpm/cli/commands/configure.py +9 -8
  6. claude_mpm/cli/commands/configure_tui.py +3 -1
  7. claude_mpm/cli/commands/dashboard.py +288 -0
  8. claude_mpm/cli/commands/debug.py +0 -1
  9. claude_mpm/cli/commands/mpm_init.py +442 -0
  10. claude_mpm/cli/commands/mpm_init_handler.py +84 -0
  11. claude_mpm/cli/parsers/base_parser.py +15 -0
  12. claude_mpm/cli/parsers/dashboard_parser.py +113 -0
  13. claude_mpm/cli/parsers/mpm_init_parser.py +128 -0
  14. claude_mpm/constants.py +10 -0
  15. claude_mpm/core/config.py +18 -0
  16. claude_mpm/core/instruction_reinforcement_hook.py +266 -0
  17. claude_mpm/core/pm_hook_interceptor.py +105 -8
  18. claude_mpm/dashboard/analysis_runner.py +52 -25
  19. claude_mpm/dashboard/static/built/components/activity-tree.js +1 -1
  20. claude_mpm/dashboard/static/built/components/code-tree.js +2 -0
  21. claude_mpm/dashboard/static/built/components/code-viewer.js +2 -0
  22. claude_mpm/dashboard/static/built/components/event-viewer.js +1 -1
  23. claude_mpm/dashboard/static/built/dashboard.js +1 -1
  24. claude_mpm/dashboard/static/built/socket-client.js +1 -1
  25. claude_mpm/dashboard/static/css/code-tree.css +330 -1
  26. claude_mpm/dashboard/static/dist/components/activity-tree.js +1 -1
  27. claude_mpm/dashboard/static/dist/components/code-tree.js +2593 -2
  28. claude_mpm/dashboard/static/dist/components/event-viewer.js +1 -1
  29. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  30. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  31. claude_mpm/dashboard/static/js/components/activity-tree.js +212 -13
  32. claude_mpm/dashboard/static/js/components/build-tracker.js +15 -13
  33. claude_mpm/dashboard/static/js/components/code-tree.js +2503 -917
  34. claude_mpm/dashboard/static/js/components/event-viewer.js +58 -19
  35. claude_mpm/dashboard/static/js/dashboard.js +46 -44
  36. claude_mpm/dashboard/static/js/socket-client.js +74 -32
  37. claude_mpm/dashboard/templates/index.html +25 -20
  38. claude_mpm/services/agents/deployment/agent_template_builder.py +11 -7
  39. claude_mpm/services/agents/memory/memory_format_service.py +3 -1
  40. claude_mpm/services/cli/agent_cleanup_service.py +1 -4
  41. claude_mpm/services/cli/socketio_manager.py +39 -8
  42. claude_mpm/services/cli/startup_checker.py +0 -1
  43. claude_mpm/services/core/cache_manager.py +0 -1
  44. claude_mpm/services/infrastructure/monitoring.py +1 -1
  45. claude_mpm/services/socketio/event_normalizer.py +64 -0
  46. claude_mpm/services/socketio/handlers/code_analysis.py +449 -0
  47. claude_mpm/services/socketio/server/connection_manager.py +3 -1
  48. claude_mpm/tools/code_tree_analyzer.py +930 -24
  49. claude_mpm/tools/code_tree_builder.py +0 -1
  50. claude_mpm/tools/code_tree_events.py +113 -15
  51. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/METADATA +2 -1
  52. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/RECORD +56 -48
  53. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/WHEEL +0 -0
  54. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/entry_points.txt +0 -0
  55. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/licenses/LICENSE +0 -0
  56. {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/top_level.txt +0 -0
@@ -62,7 +62,9 @@ class MemoryFormatService:
62
62
  line = line.strip()
63
63
  # Skip headers, empty lines, and metadata
64
64
  if (
65
- not line or line.startswith(("#", "Last Updated:", "**")) or line == "---"
65
+ not line
66
+ or line.startswith(("#", "Last Updated:", "**"))
67
+ or line == "---"
66
68
  ):
67
69
  continue
68
70
 
@@ -233,10 +233,7 @@ class AgentCleanupService(IAgentCleanupService):
233
233
  all_agents = multi_source_service.discover_agents_from_all_sources()
234
234
 
235
235
  # Detect orphaned agents
236
- return multi_source_service.detect_orphaned_agents(
237
- agents_dir, all_agents
238
- )
239
-
236
+ return multi_source_service.detect_orphaned_agents(agents_dir, all_agents)
240
237
 
241
238
  except Exception as e:
242
239
  self.logger.error(f"Error finding orphaned agents: {e}", exc_info=True)
@@ -172,10 +172,18 @@ class SocketIOManager(ISocketIOManager):
172
172
 
173
173
  # Check if server already running on this port
174
174
  if self.is_server_running(target_port):
175
- self.logger.info(
176
- f"Socket.IO server already running on port {target_port}"
175
+ # Verify the server is healthy and responding
176
+ if self.wait_for_server(target_port, timeout=2):
177
+ self.logger.info(
178
+ f"Healthy Socket.IO server already running on port {target_port}"
179
+ )
180
+ return True, self.get_server_info(target_port)
181
+ # Server exists but not responding, try to clean it up
182
+ self.logger.warning(
183
+ f"Socket.IO server on port {target_port} not responding, attempting cleanup"
177
184
  )
178
- return True, self.get_server_info(target_port)
185
+ self.stop_server(port=target_port, timeout=5)
186
+ # Continue with starting a new server
179
187
 
180
188
  # Ensure dependencies are available
181
189
  deps_ok, error_msg = self.ensure_dependencies()
@@ -459,14 +467,37 @@ class SocketIOManager(ISocketIOManager):
459
467
  Returns:
460
468
  Available port number
461
469
  """
462
- # Try preferred port first
470
+ # First check if our Socket.IO server is already running on the preferred port
471
+ if self.is_server_running(preferred_port):
472
+ # Check if it's healthy
473
+ if self.wait_for_server(preferred_port, timeout=2):
474
+ self.logger.info(
475
+ f"Healthy Socket.IO server already running on port {preferred_port}"
476
+ )
477
+ return preferred_port
478
+ self.logger.warning(
479
+ f"Socket.IO server on port {preferred_port} not responding, will try to restart"
480
+ )
481
+
482
+ # Try preferred port first if available
463
483
  if self.port_manager.is_port_available(preferred_port):
464
484
  return preferred_port
465
485
 
466
- # Find alternative port
467
- available_port = self.port_manager.get_available_port(preferred_port)
468
- self.logger.info(f"Port {preferred_port} unavailable, using {available_port}")
469
- return available_port
486
+ # Find alternative port using the correct method name
487
+ available_port = self.port_manager.find_available_port(
488
+ preferred_port=preferred_port, reclaim=True
489
+ )
490
+
491
+ if available_port:
492
+ self.logger.info(
493
+ f"Port {preferred_port} unavailable, using {available_port}"
494
+ )
495
+ return available_port
496
+ # If no port found, raise an error
497
+ raise RuntimeError(
498
+ f"No available ports in range {self.port_manager.PORT_RANGE.start}-"
499
+ f"{self.port_manager.PORT_RANGE.stop-1}"
500
+ )
470
501
 
471
502
  def ensure_dependencies(self) -> Tuple[bool, Optional[str]]:
472
503
  """
@@ -177,7 +177,6 @@ class StartupCheckerService(IStartupChecker):
177
177
  try:
178
178
  # Check Python version
179
179
 
180
-
181
180
  # Check for common missing directories
182
181
  warnings.extend(self._check_required_directories())
183
182
 
@@ -307,4 +307,3 @@ class CacheManager(ICacheManager):
307
307
  },
308
308
  "fs_cache": self._fs_cache.get_stats() if self._fs_cache else {},
309
309
  }
310
-
@@ -30,7 +30,7 @@ For backward compatibility, legacy classes are still available:
30
30
  """
31
31
 
32
32
  # Re-export all components from the modular implementation
33
- from .monitoring import ( # noqa: F401; New service-based API; Base components; Legacy compatibility
33
+ from .monitoring import ( # noqa: F401
34
34
  AdvancedHealthMonitor,
35
35
  HealthChecker,
36
36
  HealthCheckResult,
@@ -59,6 +59,7 @@ class EventType(Enum):
59
59
  PERFORMANCE = "performance" # Performance metrics
60
60
  CLAUDE = "claude" # Claude process events
61
61
  TEST = "test" # Test events
62
+ CODE = "code" # Code analysis events
62
63
  TOOL = "tool" # Tool events
63
64
  SUBAGENT = "subagent" # Subagent events
64
65
 
@@ -351,6 +352,41 @@ class EventNormalizer:
351
352
  event_type.value if isinstance(event_type, EventType) else event_type
352
353
  ), subtype
353
354
 
355
+ # Handle colon-separated event names (e.g., "code:analysis:queued", "code:progress")
356
+ # These are commonly used by the code analysis system
357
+ if ":" in event_name:
358
+ parts = event_name.split(":", 2) # Split into max 3 parts
359
+ if len(parts) >= 2:
360
+ type_part = parts[0].lower()
361
+ # For events like "code:analysis:queued", combine the last parts as subtype
362
+ # Replace colons with underscores for clean subtypes
363
+ if len(parts) == 3:
364
+ subtype_part = f"{parts[1]}_{parts[2]}"
365
+ else:
366
+ subtype_part = parts[1].replace(":", "_")
367
+
368
+ # Map the type part to known types
369
+ if type_part in [
370
+ "code", # Code analysis events
371
+ "hook",
372
+ "session",
373
+ "file",
374
+ "system",
375
+ "connection",
376
+ "memory",
377
+ "git",
378
+ "todo",
379
+ "ticket",
380
+ "agent",
381
+ "claude",
382
+ "error",
383
+ "performance",
384
+ "test",
385
+ "tool",
386
+ "subagent",
387
+ ]:
388
+ return type_part, subtype_part
389
+
354
390
  # Handle dotted event names (e.g., "connection.status", "session.started")
355
391
  if "." in event_name:
356
392
  parts = event_name.split(".", 1)
@@ -443,6 +479,34 @@ class EventNormalizer:
443
479
  return EventType.MEMORY.value, "injected"
444
480
  return EventType.MEMORY.value, "generic"
445
481
 
482
+ # Code analysis events - using underscores for clean subtypes
483
+ if "code" in event_lower:
484
+ if "analysis" in event_lower:
485
+ if "queue" in event_lower:
486
+ return EventType.CODE.value, "analysis_queued"
487
+ if "start" in event_lower:
488
+ return EventType.CODE.value, "analysis_start"
489
+ if "complete" in event_lower:
490
+ return EventType.CODE.value, "analysis_complete"
491
+ if "error" in event_lower:
492
+ return EventType.CODE.value, "analysis_error"
493
+ if "cancel" in event_lower:
494
+ return EventType.CODE.value, "analysis_cancelled"
495
+ return EventType.CODE.value, "analysis_generic"
496
+ if "progress" in event_lower:
497
+ return EventType.CODE.value, "progress"
498
+ if "file" in event_lower:
499
+ if "discovered" in event_lower:
500
+ return EventType.CODE.value, "file_discovered"
501
+ if "analyzed" in event_lower:
502
+ return EventType.CODE.value, "file_analyzed"
503
+ return EventType.CODE.value, "file_complete"
504
+ if "directory" in event_lower:
505
+ return EventType.CODE.value, "directory_discovered"
506
+ if "node" in event_lower:
507
+ return EventType.CODE.value, "node_found"
508
+ return EventType.CODE.value, "generic"
509
+
446
510
  # Default to unknown with lowercase subtype
447
511
  return "unknown", event_name.lower() if event_name else ""
448
512
 
@@ -12,11 +12,15 @@ DESIGN DECISIONS:
12
12
  - Stream events in real-time to all connected clients
13
13
  """
14
14
 
15
+ import asyncio
15
16
  import uuid
17
+ from pathlib import Path
16
18
  from typing import Any, Dict
17
19
 
18
20
  from ....core.logging_config import get_logger
19
21
  from ....dashboard.analysis_runner import CodeAnalysisRunner
22
+ from ....tools.code_tree_analyzer import CodeTreeAnalyzer
23
+ from ....tools.code_tree_events import CodeTreeEventEmitter
20
24
  from .base import BaseEventHandler
21
25
 
22
26
 
@@ -36,6 +40,7 @@ class CodeAnalysisEventHandler(BaseEventHandler):
36
40
  super().__init__(server)
37
41
  self.logger = get_logger(__name__)
38
42
  self.analysis_runner = None
43
+ self.code_analyzer = None # For lazy loading operations
39
44
 
40
45
  def initialize(self):
41
46
  """Initialize the analysis runner."""
@@ -58,9 +63,14 @@ class CodeAnalysisEventHandler(BaseEventHandler):
58
63
  Dictionary mapping event names to handler methods
59
64
  """
60
65
  return {
66
+ # Legacy full analysis
61
67
  "code:analyze:request": self.handle_analyze_request,
62
68
  "code:analyze:cancel": self.handle_cancel_request,
63
69
  "code:analyze:status": self.handle_status_request,
70
+ # Lazy loading operations
71
+ "code:discover:top_level": self.handle_discover_top_level,
72
+ "code:discover:directory": self.handle_discover_directory,
73
+ "code:analyze:file": self.handle_analyze_file,
64
74
  }
65
75
 
66
76
  def register_events(self) -> None:
@@ -168,3 +178,442 @@ class CodeAnalysisEventHandler(BaseEventHandler):
168
178
 
169
179
  # Send status to requesting client
170
180
  await self.server.sio.emit("code:analysis:status", status, room=sid)
181
+
182
+ async def handle_discover_top_level(self, sid: str, data: Dict[str, Any]):
183
+ """Handle top-level directory discovery request for lazy loading.
184
+
185
+ Args:
186
+ sid: Socket ID of the requesting client
187
+ data: Request data containing path and options
188
+ """
189
+ self.logger.info(f"Top-level discovery requested from {sid}: {data}")
190
+
191
+ # Get path - this MUST be an absolute path from the frontend
192
+ path = data.get("path")
193
+ if not path:
194
+ await self.server.core.sio.emit(
195
+ "code:analysis:error",
196
+ {
197
+ "error": "Path is required for top-level discovery",
198
+ "request_id": data.get("request_id"),
199
+ },
200
+ room=sid,
201
+ )
202
+ return
203
+
204
+ # CRITICAL: Never use "." or allow relative paths
205
+ # The frontend must send the absolute working directory
206
+ if path in (".", "..", "/") or not Path(path).is_absolute():
207
+ self.logger.warning(f"Invalid path for discovery: {path}")
208
+ await self.server.core.sio.emit(
209
+ "code:analysis:error",
210
+ {
211
+ "error": f"Invalid path for discovery: {path}. Must be an absolute path.",
212
+ "request_id": data.get("request_id"),
213
+ "path": path,
214
+ },
215
+ room=sid,
216
+ )
217
+ return
218
+
219
+ # SECURITY: Validate the requested path
220
+ # Allow access to the explicitly chosen working directory and its subdirectories
221
+ requested_path = Path(path).absolute()
222
+
223
+ # For now, we trust the frontend to send valid paths
224
+ # In production, you might want to maintain a server-side list of allowed directories
225
+ # or implement a more sophisticated permission system
226
+
227
+ # Basic sanity checks are done below after creating the Path object
228
+
229
+ ignore_patterns = data.get("ignore_patterns", [])
230
+ request_id = data.get("request_id")
231
+
232
+ try:
233
+ # Create analyzer if needed
234
+ if not self.code_analyzer:
235
+ # Create a custom emitter that sends to Socket.IO
236
+ emitter = CodeTreeEventEmitter(use_stdout=False)
237
+ # Override emit method to send to Socket.IO
238
+ original_emit = emitter.emit
239
+
240
+ def socket_emit(
241
+ event_type: str, event_data: Dict[str, Any], batch: bool = False
242
+ ):
243
+ # Keep the original event format with colons - frontend expects this!
244
+ # The frontend listens for 'code:directory:discovered' not 'code.directory.discovered'
245
+
246
+ # Special handling for 'info' events - they should be passed through directly
247
+ if event_type == "info":
248
+ # INFO events for granular tracking
249
+ loop = asyncio.get_event_loop()
250
+ loop.create_task(
251
+ self.server.core.sio.emit(
252
+ "info", {"request_id": request_id, **event_data}
253
+ )
254
+ )
255
+ else:
256
+ # Regular code analysis events
257
+ loop = asyncio.get_event_loop()
258
+ loop.create_task(
259
+ self.server.core.sio.emit(
260
+ event_type, {"request_id": request_id, **event_data}
261
+ )
262
+ )
263
+ # Call original for stats tracking
264
+ original_emit(event_type, event_data, batch)
265
+
266
+ emitter.emit = socket_emit
267
+ # Initialize CodeTreeAnalyzer with emitter keyword argument
268
+ self.logger.info("Creating CodeTreeAnalyzer")
269
+ self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
270
+
271
+ # Use the provided path as-is - the frontend sends the absolute path
272
+ # Make sure we're using an absolute path
273
+ directory = Path(path)
274
+
275
+ # Validate that the path exists and is a directory
276
+ if not directory.exists():
277
+ await self.server.core.sio.emit(
278
+ "code:analysis:error",
279
+ {
280
+ "request_id": request_id,
281
+ "path": path,
282
+ "error": f"Directory does not exist: {path}",
283
+ },
284
+ room=sid,
285
+ )
286
+ return
287
+
288
+ if not directory.is_dir():
289
+ await self.server.core.sio.emit(
290
+ "code:analysis:error",
291
+ {
292
+ "request_id": request_id,
293
+ "path": path,
294
+ "error": f"Path is not a directory: {path}",
295
+ },
296
+ room=sid,
297
+ )
298
+ return
299
+
300
+ # Log what we're actually discovering
301
+ self.logger.info(
302
+ f"Discovering top-level contents of: {directory.absolute()}"
303
+ )
304
+
305
+ result = self.code_analyzer.discover_top_level(directory, ignore_patterns)
306
+
307
+ # Send result to client with correct event name for top level discovery
308
+ await self.server.core.sio.emit(
309
+ "code:top_level:discovered",
310
+ {
311
+ "request_id": request_id,
312
+ "path": str(directory),
313
+ "items": result.get("children", []),
314
+ "stats": {
315
+ "files": len(
316
+ [
317
+ c
318
+ for c in result.get("children", [])
319
+ if c.get("type") == "file"
320
+ ]
321
+ ),
322
+ "directories": len(
323
+ [
324
+ c
325
+ for c in result.get("children", [])
326
+ if c.get("type") == "directory"
327
+ ]
328
+ ),
329
+ },
330
+ },
331
+ room=sid,
332
+ )
333
+
334
+ except Exception as e:
335
+ self.logger.error(f"Error discovering top level: {e}")
336
+ await self.server.core.sio.emit(
337
+ "code:analysis:error",
338
+ {
339
+ "request_id": request_id,
340
+ "path": path,
341
+ "error": str(e),
342
+ },
343
+ room=sid,
344
+ )
345
+
346
+ async def handle_discover_directory(self, sid: str, data: Dict[str, Any]):
347
+ """Handle directory discovery request for lazy loading.
348
+
349
+ Args:
350
+ sid: Socket ID of the requesting client
351
+ data: Request data containing directory path
352
+ """
353
+ self.logger.info(f"Directory discovery requested from {sid}: {data}")
354
+
355
+ path = data.get("path")
356
+ ignore_patterns = data.get("ignore_patterns", [])
357
+ request_id = data.get("request_id")
358
+
359
+ if not path:
360
+ await self.server.core.sio.emit(
361
+ "code:analysis:error",
362
+ {
363
+ "request_id": request_id,
364
+ "error": "Path is required",
365
+ },
366
+ room=sid,
367
+ )
368
+ return
369
+
370
+ # CRITICAL SECURITY FIX: Add path validation to prevent filesystem traversal
371
+ # The same validation logic as handle_discover_top_level
372
+ if path in (".", "..", "/") or not Path(path).is_absolute():
373
+ self.logger.warning(f"Invalid path for directory discovery: {path}")
374
+ await self.server.core.sio.emit(
375
+ "code:analysis:error",
376
+ {
377
+ "error": f"Invalid path for discovery: {path}. Must be an absolute path.",
378
+ "request_id": request_id,
379
+ "path": path,
380
+ },
381
+ room=sid,
382
+ )
383
+ return
384
+
385
+ # SECURITY: Validate the requested path
386
+ # Allow access to the explicitly chosen working directory and its subdirectories
387
+ requested_path = Path(path).absolute()
388
+
389
+ # For now, we trust the frontend to send valid paths
390
+ # In production, you might want to maintain a server-side list of allowed directories
391
+ # or implement a more sophisticated permission system
392
+
393
+ # Basic sanity checks
394
+ if not requested_path.exists():
395
+ self.logger.warning(f"Path does not exist: {path}")
396
+ await self.server.core.sio.emit(
397
+ "code:analysis:error",
398
+ {
399
+ "error": f"Path does not exist: {path}",
400
+ "request_id": request_id,
401
+ "path": path,
402
+ },
403
+ room=sid,
404
+ )
405
+ return
406
+
407
+ if not requested_path.is_dir():
408
+ self.logger.warning(f"Path is not a directory: {path}")
409
+ await self.server.core.sio.emit(
410
+ "code:analysis:error",
411
+ {
412
+ "error": f"Path is not a directory: {path}",
413
+ "request_id": request_id,
414
+ "path": path,
415
+ },
416
+ room=sid,
417
+ )
418
+ return
419
+
420
+ try:
421
+ # Ensure analyzer exists
422
+ if not self.code_analyzer:
423
+ emitter = CodeTreeEventEmitter(use_stdout=False)
424
+ # Override emit method to send to Socket.IO
425
+ original_emit = emitter.emit
426
+
427
+ def socket_emit(
428
+ event_type: str, event_data: Dict[str, Any], batch: bool = False
429
+ ):
430
+ # Keep the original event format with colons - frontend expects this!
431
+ # The frontend listens for 'code:directory:discovered' not 'code.directory.discovered'
432
+
433
+ # Special handling for 'info' events - they should be passed through directly
434
+ if event_type == "info":
435
+ # INFO events for granular tracking
436
+ loop = asyncio.get_event_loop()
437
+ loop.create_task(
438
+ self.server.core.sio.emit(
439
+ "info", {"request_id": request_id, **event_data}
440
+ )
441
+ )
442
+ else:
443
+ # Regular code analysis events
444
+ loop = asyncio.get_event_loop()
445
+ loop.create_task(
446
+ self.server.core.sio.emit(
447
+ event_type, {"request_id": request_id, **event_data}
448
+ )
449
+ )
450
+ original_emit(event_type, event_data, batch)
451
+
452
+ emitter.emit = socket_emit
453
+ # Initialize CodeTreeAnalyzer with emitter keyword argument
454
+ self.logger.info("Creating CodeTreeAnalyzer")
455
+ self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
456
+
457
+ # Discover directory
458
+ result = self.code_analyzer.discover_directory(path, ignore_patterns)
459
+
460
+ # Log what we're sending
461
+ self.logger.info(
462
+ f"Discovery result for {path}: {len(result.get('children', []))} children found"
463
+ )
464
+ self.logger.debug(f"Full result: {result}")
465
+
466
+ # Send result with correct event name (using colons, not dots!)
467
+ await self.server.core.sio.emit(
468
+ "code:directory:discovered",
469
+ {
470
+ "request_id": request_id,
471
+ "path": path,
472
+ **result,
473
+ },
474
+ room=sid,
475
+ )
476
+
477
+ except Exception as e:
478
+ self.logger.error(f"Error discovering directory {path}: {e}")
479
+ await self.server.core.sio.emit(
480
+ "code:analysis:error",
481
+ {
482
+ "request_id": request_id,
483
+ "path": path,
484
+ "error": str(e),
485
+ },
486
+ room=sid,
487
+ )
488
+
489
+ async def handle_analyze_file(self, sid: str, data: Dict[str, Any]):
490
+ """Handle file analysis request for lazy loading.
491
+
492
+ Args:
493
+ sid: Socket ID of the requesting client
494
+ data: Request data containing file path
495
+ """
496
+ self.logger.info(f"File analysis requested from {sid}: {data}")
497
+
498
+ path = data.get("path")
499
+ request_id = data.get("request_id")
500
+ show_hidden_files = data.get("show_hidden_files", False)
501
+
502
+ if not path:
503
+ await self.server.core.sio.emit(
504
+ "code:analysis:error",
505
+ {
506
+ "request_id": request_id,
507
+ "error": "Path is required",
508
+ },
509
+ room=sid,
510
+ )
511
+ return
512
+
513
+ # CRITICAL SECURITY FIX: Add path validation to prevent filesystem traversal
514
+ if path in (".", "..", "/") or not Path(path).is_absolute():
515
+ self.logger.warning(f"Invalid path for file analysis: {path}")
516
+ await self.server.core.sio.emit(
517
+ "code:analysis:error",
518
+ {
519
+ "error": f"Invalid path for analysis: {path}. Must be an absolute path.",
520
+ "request_id": request_id,
521
+ "path": path,
522
+ },
523
+ room=sid,
524
+ )
525
+ return
526
+
527
+ # SECURITY: Validate the requested file path
528
+ # Allow access to files within the explicitly chosen working directory
529
+ requested_path = Path(path).absolute()
530
+
531
+ # Basic sanity checks
532
+ if not requested_path.exists():
533
+ self.logger.warning(f"File does not exist: {path}")
534
+ await self.server.core.sio.emit(
535
+ "code:analysis:error",
536
+ {
537
+ "error": f"File does not exist: {path}",
538
+ "request_id": request_id,
539
+ "path": path,
540
+ },
541
+ room=sid,
542
+ )
543
+ return
544
+
545
+ if not requested_path.is_file():
546
+ self.logger.warning(f"Path is not a file: {path}")
547
+ await self.server.core.sio.emit(
548
+ "code:analysis:error",
549
+ {
550
+ "error": f"Path is not a file: {path}",
551
+ "request_id": request_id,
552
+ "path": path,
553
+ },
554
+ room=sid,
555
+ )
556
+ return
557
+
558
+ try:
559
+ # Ensure analyzer exists
560
+ if not self.code_analyzer:
561
+ emitter = CodeTreeEventEmitter(use_stdout=False)
562
+ # Override emit method to send to Socket.IO
563
+ original_emit = emitter.emit
564
+
565
+ def socket_emit(
566
+ event_type: str, event_data: Dict[str, Any], batch: bool = False
567
+ ):
568
+ # Keep the original event format with colons - frontend expects this!
569
+ # The frontend listens for 'code:file:analyzed' not 'code.file.analyzed'
570
+
571
+ # Special handling for 'info' events - they should be passed through directly
572
+ if event_type == "info":
573
+ # INFO events for granular tracking
574
+ loop = asyncio.get_event_loop()
575
+ loop.create_task(
576
+ self.server.core.sio.emit(
577
+ "info", {"request_id": request_id, **event_data}
578
+ )
579
+ )
580
+ else:
581
+ # Regular code analysis events
582
+ loop = asyncio.get_event_loop()
583
+ loop.create_task(
584
+ self.server.core.sio.emit(
585
+ event_type, {"request_id": request_id, **event_data}
586
+ )
587
+ )
588
+ original_emit(event_type, event_data, batch)
589
+
590
+ emitter.emit = socket_emit
591
+ # Initialize CodeTreeAnalyzer with emitter keyword argument
592
+ self.logger.info("Creating CodeTreeAnalyzer")
593
+ self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
594
+
595
+ # Analyze file
596
+ result = self.code_analyzer.analyze_file(path)
597
+
598
+ # Send result with correct event name (using colons, not dots!)
599
+ await self.server.core.sio.emit(
600
+ "code:file:analyzed",
601
+ {
602
+ "request_id": request_id,
603
+ "path": path,
604
+ **result,
605
+ },
606
+ room=sid,
607
+ )
608
+
609
+ except Exception as e:
610
+ self.logger.error(f"Error analyzing file {path}: {e}")
611
+ await self.server.core.sio.emit(
612
+ "code:analysis:error",
613
+ {
614
+ "request_id": request_id,
615
+ "path": path,
616
+ "error": str(e),
617
+ },
618
+ room=sid,
619
+ )