claude-mpm 4.2.12__py3-none-any.whl → 4.2.14__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 (36) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/commands/mpm-agents.md +44 -8
  3. claude_mpm/core/constants.py +65 -0
  4. claude_mpm/core/error_handler.py +625 -0
  5. claude_mpm/core/file_utils.py +770 -0
  6. claude_mpm/core/logging_utils.py +502 -0
  7. claude_mpm/dashboard/static/dist/components/code-tree.js +1 -1
  8. claude_mpm/dashboard/static/dist/components/file-viewer.js +1 -1
  9. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  10. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  11. claude_mpm/dashboard/static/js/components/code-simple.js +44 -1
  12. claude_mpm/dashboard/static/js/components/code-tree/tree-breadcrumb.js +353 -0
  13. claude_mpm/dashboard/static/js/components/code-tree/tree-constants.js +235 -0
  14. claude_mpm/dashboard/static/js/components/code-tree/tree-search.js +409 -0
  15. claude_mpm/dashboard/static/js/components/code-tree/tree-utils.js +435 -0
  16. claude_mpm/dashboard/static/js/components/code-tree.js +29 -5
  17. claude_mpm/dashboard/static/js/components/file-viewer.js +69 -27
  18. claude_mpm/dashboard/static/js/components/session-manager.js +1 -1
  19. claude_mpm/dashboard/static/js/components/working-directory.js +18 -9
  20. claude_mpm/dashboard/static/js/dashboard.js +55 -9
  21. claude_mpm/dashboard/static/js/shared/dom-helpers.js +396 -0
  22. claude_mpm/dashboard/static/js/shared/event-bus.js +330 -0
  23. claude_mpm/dashboard/static/js/shared/logger.js +385 -0
  24. claude_mpm/dashboard/static/js/shared/tooltip-service.js +253 -0
  25. claude_mpm/dashboard/static/js/socket-client.js +18 -0
  26. claude_mpm/dashboard/templates/index.html +21 -8
  27. claude_mpm/services/monitor/handlers/__init__.py +2 -1
  28. claude_mpm/services/monitor/handlers/file.py +263 -0
  29. claude_mpm/services/monitor/server.py +81 -1
  30. claude_mpm/services/socketio/handlers/file.py +40 -5
  31. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/METADATA +1 -1
  32. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/RECORD +36 -24
  33. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/WHEEL +0 -0
  34. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/entry_points.txt +0 -0
  35. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/licenses/LICENSE +0 -0
  36. {claude_mpm-4.2.12.dist-info → claude_mpm-4.2.14.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Unified Tooltip Service
3
+ *
4
+ * Provides a consistent tooltip implementation for all dashboard components.
5
+ * Supports different tooltip types and behaviors.
6
+ *
7
+ * @module tooltip-service
8
+ */
9
+
10
+ class TooltipService {
11
+ constructor() {
12
+ this.tooltips = new Map();
13
+ this.defaultOptions = {
14
+ className: 'dashboard-tooltip',
15
+ duration: 200,
16
+ offset: { x: 10, y: -28 },
17
+ hideDelay: 500
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Create a new tooltip instance
23
+ * @param {string} id - Unique identifier for the tooltip
24
+ * @param {Object} options - Configuration options
25
+ * @returns {Object} Tooltip instance
26
+ */
27
+ create(id, options = {}) {
28
+ const config = { ...this.defaultOptions, ...options };
29
+
30
+ // Check if tooltip already exists
31
+ if (this.tooltips.has(id)) {
32
+ return this.tooltips.get(id);
33
+ }
34
+
35
+ // Create tooltip element
36
+ const tooltip = d3.select('body').append('div')
37
+ .attr('class', config.className)
38
+ .style('opacity', 0)
39
+ .style('position', 'absolute')
40
+ .style('pointer-events', 'none')
41
+ .style('background', 'rgba(0, 0, 0, 0.8)')
42
+ .style('color', '#fff')
43
+ .style('padding', '8px 12px')
44
+ .style('border-radius', '4px')
45
+ .style('font-size', '12px')
46
+ .style('font-family', 'system-ui, -apple-system, sans-serif')
47
+ .style('z-index', '10000');
48
+
49
+ const instance = {
50
+ element: tooltip,
51
+ config,
52
+ show: (event, content) => this.show(tooltip, event, content, config),
53
+ hide: () => this.hide(tooltip, config),
54
+ update: (content) => this.update(tooltip, content),
55
+ destroy: () => this.destroy(id)
56
+ };
57
+
58
+ this.tooltips.set(id, instance);
59
+ return instance;
60
+ }
61
+
62
+ /**
63
+ * Show a tooltip
64
+ * @param {Object} tooltip - D3 selection of tooltip element
65
+ * @param {Event} event - Mouse event
66
+ * @param {string|Array|Object} content - Content to display
67
+ * @param {Object} config - Configuration options
68
+ */
69
+ show(tooltip, event, content, config) {
70
+ // Cancel any pending hide transition
71
+ tooltip.interrupt();
72
+
73
+ // Format content
74
+ const html = this.formatContent(content);
75
+
76
+ tooltip.html(html)
77
+ .style('left', (event.pageX + config.offset.x) + 'px')
78
+ .style('top', (event.pageY + config.offset.y) + 'px');
79
+
80
+ tooltip.transition()
81
+ .duration(config.duration)
82
+ .style('opacity', 0.9);
83
+
84
+ // Ensure tooltip stays within viewport
85
+ this.adjustPosition(tooltip, event, config);
86
+ }
87
+
88
+ /**
89
+ * Hide a tooltip
90
+ * @param {Object} tooltip - D3 selection of tooltip element
91
+ * @param {Object} config - Configuration options
92
+ */
93
+ hide(tooltip, config) {
94
+ tooltip.transition()
95
+ .duration(config.hideDelay)
96
+ .style('opacity', 0);
97
+ }
98
+
99
+ /**
100
+ * Update tooltip content without changing position
101
+ * @param {Object} tooltip - D3 selection of tooltip element
102
+ * @param {string|Array|Object} content - New content
103
+ */
104
+ update(tooltip, content) {
105
+ const html = this.formatContent(content);
106
+ tooltip.html(html);
107
+ }
108
+
109
+ /**
110
+ * Format content for display
111
+ * @param {string|Array|Object} content - Content to format
112
+ * @returns {string} HTML string
113
+ */
114
+ formatContent(content) {
115
+ if (typeof content === 'string') {
116
+ return content;
117
+ }
118
+
119
+ if (Array.isArray(content)) {
120
+ return content.join('<br>');
121
+ }
122
+
123
+ if (typeof content === 'object' && content !== null) {
124
+ const lines = [];
125
+
126
+ // Handle title
127
+ if (content.title) {
128
+ lines.push(`<strong>${this.escapeHtml(content.title)}</strong>`);
129
+ }
130
+
131
+ // Handle fields
132
+ if (content.fields) {
133
+ for (const [key, value] of Object.entries(content.fields)) {
134
+ if (value !== undefined && value !== null) {
135
+ lines.push(`${this.escapeHtml(key)}: ${this.escapeHtml(String(value))}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Handle description
141
+ if (content.description) {
142
+ lines.push(`<em>${this.escapeHtml(content.description)}</em>`);
143
+ }
144
+
145
+ // Handle raw HTML (trusted content only)
146
+ if (content.html) {
147
+ lines.push(content.html);
148
+ }
149
+
150
+ return lines.join('<br>');
151
+ }
152
+
153
+ return String(content);
154
+ }
155
+
156
+ /**
157
+ * Adjust tooltip position to stay within viewport
158
+ * @param {Object} tooltip - D3 selection of tooltip element
159
+ * @param {Event} event - Mouse event
160
+ * @param {Object} config - Configuration options
161
+ */
162
+ adjustPosition(tooltip, event, config) {
163
+ const node = tooltip.node();
164
+ if (!node) return;
165
+
166
+ const rect = node.getBoundingClientRect();
167
+ const viewportWidth = window.innerWidth;
168
+ const viewportHeight = window.innerHeight;
169
+
170
+ let left = event.pageX + config.offset.x;
171
+ let top = event.pageY + config.offset.y;
172
+
173
+ // Adjust horizontal position
174
+ if (rect.right > viewportWidth) {
175
+ left = event.pageX - rect.width - config.offset.x;
176
+ }
177
+
178
+ // Adjust vertical position
179
+ if (rect.bottom > viewportHeight) {
180
+ top = event.pageY - rect.height - Math.abs(config.offset.y);
181
+ }
182
+
183
+ tooltip
184
+ .style('left', left + 'px')
185
+ .style('top', top + 'px');
186
+ }
187
+
188
+ /**
189
+ * Destroy a tooltip instance
190
+ * @param {string} id - Tooltip identifier
191
+ */
192
+ destroy(id) {
193
+ const instance = this.tooltips.get(id);
194
+ if (instance) {
195
+ instance.element.remove();
196
+ this.tooltips.delete(id);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Destroy all tooltips
202
+ */
203
+ destroyAll() {
204
+ for (const [id] of this.tooltips) {
205
+ this.destroy(id);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Escape HTML special characters
211
+ * @param {string} text - Text to escape
212
+ * @returns {string} Escaped text
213
+ */
214
+ escapeHtml(text) {
215
+ const div = document.createElement('div');
216
+ div.textContent = text;
217
+ return div.innerHTML;
218
+ }
219
+
220
+ /**
221
+ * Create a simple tooltip helper for basic use cases
222
+ * @param {string} selector - CSS selector for target elements
223
+ * @param {Function} contentFn - Function to generate content from data
224
+ * @param {Object} options - Configuration options
225
+ */
226
+ attachToElements(selector, contentFn, options = {}) {
227
+ const id = `tooltip-${selector.replace(/[^a-zA-Z0-9]/g, '-')}`;
228
+ const tooltip = this.create(id, options);
229
+
230
+ d3.selectAll(selector)
231
+ .on('mouseenter', function(event, d) {
232
+ const content = contentFn(d, this);
233
+ tooltip.show(event, content);
234
+ })
235
+ .on('mouseleave', function() {
236
+ tooltip.hide();
237
+ });
238
+
239
+ return tooltip;
240
+ }
241
+ }
242
+
243
+ // Export as singleton
244
+ const tooltipService = new TooltipService();
245
+
246
+ // Support both module and global usage
247
+ if (typeof module !== 'undefined' && module.exports) {
248
+ module.exports = tooltipService;
249
+ } else if (typeof define === 'function' && define.amd) {
250
+ define([], () => tooltipService);
251
+ } else {
252
+ window.tooltipService = tooltipService;
253
+ }
@@ -505,6 +505,24 @@ class SocketClient {
505
505
  // console.log('Received pong from server');
506
506
  });
507
507
 
508
+ // Listen for heartbeat events from server (every 3 minutes)
509
+ this.socket.on('heartbeat', (data) => {
510
+ console.log('🫀 Received server heartbeat:', data);
511
+ // Add heartbeat to event list for visibility
512
+ this.addEvent({
513
+ type: 'system',
514
+ subtype: 'heartbeat',
515
+ timestamp: data.timestamp || new Date().toISOString(),
516
+ data: data
517
+ });
518
+
519
+ // Update last ping time to indicate server is alive
520
+ this.lastPingTime = Date.now();
521
+
522
+ // Log to console for debugging
523
+ console.log(`Server heartbeat #${data.heartbeat_number}: ${data.server_uptime_formatted} uptime, ${data.connected_clients} clients connected`);
524
+ });
525
+
508
526
  // Session and event handlers (legacy/fallback)
509
527
  this.socket.on('session.started', (data) => {
510
528
  this.addEvent({ type: 'session', subtype: 'started', timestamp: new Date().toISOString(), data });
@@ -530,14 +530,27 @@
530
530
  });
531
531
  };
532
532
 
533
- // Load dashboard.js first, then other components
534
- Promise.all([
535
- loadModule('/static/dist/dashboard.js'),
536
- loadModule('/static/dist/components/activity-tree.js'),
537
- loadModule('/static/js/components/code-tree.js'), // TEMPORARY: Direct source for debugging
538
- loadModule('/static/dist/components/code-viewer.js'),
539
- loadModule('/static/dist/components/file-viewer.js') // File viewer for viewing file contents
540
- ]).then(() => {
533
+ // Load shared services first, then tree modules, then components
534
+ // Load services sequentially to ensure dependencies are available
535
+ loadModule('/static/js/shared/tooltip-service.js')
536
+ .then(() => loadModule('/static/js/shared/dom-helpers.js'))
537
+ .then(() => loadModule('/static/js/shared/event-bus.js'))
538
+ .then(() => loadModule('/static/js/shared/logger.js'))
539
+ .then(() => loadModule('/static/js/components/code-tree/tree-utils.js'))
540
+ .then(() => loadModule('/static/js/components/code-tree/tree-constants.js'))
541
+ .then(() => loadModule('/static/js/components/code-tree/tree-search.js'))
542
+ .then(() => loadModule('/static/js/components/code-tree/tree-breadcrumb.js'))
543
+ .then(() => {
544
+ // Now load main components in parallel
545
+ return Promise.all([
546
+ loadModule('/static/dist/dashboard.js'),
547
+ loadModule('/static/dist/components/activity-tree.js'),
548
+ loadModule('/static/js/components/code-tree.js'), // TEMPORARY: Direct source for debugging
549
+ loadModule('/static/dist/components/code-viewer.js'),
550
+ loadModule('/static/dist/components/file-viewer.js') // File viewer for viewing file contents
551
+ ]);
552
+ })
553
+ .then(() => {
541
554
  console.log('All dashboard modules loaded successfully');
542
555
 
543
556
  // Give modules time to execute and initialize dashboard
@@ -15,6 +15,7 @@ DESIGN DECISIONS:
15
15
 
16
16
  from .code_analysis import CodeAnalysisHandler
17
17
  from .dashboard import DashboardHandler
18
+ from .file import FileHandler
18
19
  from .hooks import HookHandler
19
20
 
20
- __all__ = ["CodeAnalysisHandler", "DashboardHandler", "HookHandler"]
21
+ __all__ = ["CodeAnalysisHandler", "DashboardHandler", "FileHandler", "HookHandler"]
@@ -0,0 +1,263 @@
1
+ """
2
+ File Handler for Unified Monitor Server
3
+ ========================================
4
+
5
+ WHY: Provides file reading capabilities for the dashboard file viewer.
6
+ This handler allows the dashboard to display file contents when users
7
+ click on files in the code tree or other file references.
8
+
9
+ DESIGN DECISIONS:
10
+ - Secure file reading with path validation
11
+ - Size limits to prevent memory issues
12
+ - Compatible with UnifiedMonitorServer architecture
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Optional
18
+
19
+ import socketio
20
+
21
+ from ....core.logging_config import get_logger
22
+ from ....core.unified_paths import get_project_root
23
+
24
+
25
+ class FileHandler:
26
+ """Socket.IO handler for file operations in the unified monitor server.
27
+
28
+ WHY: The dashboard needs to display file contents when users click on files,
29
+ but we must ensure secure file access with proper validation and size limits.
30
+ """
31
+
32
+ def __init__(self, sio: socketio.AsyncServer):
33
+ """Initialize the file handler.
34
+
35
+ Args:
36
+ sio: Socket.IO server instance
37
+ """
38
+ self.sio = sio
39
+ self.logger = get_logger(__name__)
40
+
41
+ def register(self):
42
+ """Register Socket.IO event handlers for file operations."""
43
+ self.logger.info("[FileHandler] Registering file event handlers")
44
+
45
+ @self.sio.event
46
+ async def read_file(sid, data):
47
+ """Handle file read requests from dashboard clients.
48
+
49
+ WHY: The dashboard needs to display file contents when users
50
+ click on files, but we must ensure secure file access with
51
+ proper validation and size limits.
52
+ """
53
+ self.logger.info(
54
+ f"[FileHandler] Received read_file event from {sid} with data: {data}"
55
+ )
56
+
57
+ try:
58
+ file_path = data.get("file_path")
59
+ working_dir = data.get("working_dir", os.getcwd())
60
+ max_size = data.get("max_size", 1024 * 1024) # 1MB default limit
61
+
62
+ if not file_path:
63
+ self.logger.warning(
64
+ f"[FileHandler] Missing file_path in request from {sid}"
65
+ )
66
+ await self.sio.emit(
67
+ "file_content_response",
68
+ {
69
+ "success": False,
70
+ "error": "file_path is required",
71
+ "file_path": file_path,
72
+ },
73
+ room=sid,
74
+ )
75
+ return
76
+
77
+ # Read the file safely
78
+ self.logger.info(
79
+ f"[FileHandler] Reading file: {file_path} from working_dir: {working_dir}"
80
+ )
81
+ result = await self._read_file_safely(file_path, working_dir, max_size)
82
+
83
+ # Send the result back to the client
84
+ self.logger.info(
85
+ f"[FileHandler] Sending file_content_response to {sid}, success: {result.get('success', False)}"
86
+ )
87
+ await self.sio.emit("file_content_response", result, room=sid)
88
+ self.logger.info(f"[FileHandler] Response sent successfully to {sid}")
89
+
90
+ except Exception as e:
91
+ self.logger.error(
92
+ f"[FileHandler] Exception in read_file handler: {e}", exc_info=True
93
+ )
94
+ await self.sio.emit(
95
+ "file_content_response",
96
+ {
97
+ "success": False,
98
+ "error": str(e),
99
+ "file_path": data.get("file_path", "unknown"),
100
+ },
101
+ room=sid,
102
+ )
103
+ self.logger.info(f"[FileHandler] Error response sent to {sid}")
104
+
105
+ self.logger.info("[FileHandler] File event handlers registered successfully")
106
+
107
+ async def _read_file_safely(
108
+ self,
109
+ file_path: str,
110
+ working_dir: Optional[str] = None,
111
+ max_size: int = 1024 * 1024,
112
+ ) -> Dict[str, Any]:
113
+ """Safely read file content with security checks.
114
+
115
+ WHY: File reading must be secure to prevent directory traversal attacks
116
+ and resource exhaustion. This method centralizes all security checks
117
+ and provides consistent error handling.
118
+
119
+ Args:
120
+ file_path: Path to the file to read
121
+ working_dir: Working directory (defaults to current directory)
122
+ max_size: Maximum file size in bytes
123
+
124
+ Returns:
125
+ dict: Response with success status, content, and metadata
126
+ """
127
+ try:
128
+ if working_dir is None:
129
+ working_dir = os.getcwd()
130
+
131
+ # Resolve absolute path based on working directory
132
+ file_path_obj = Path(file_path)
133
+ if not file_path_obj.is_absolute():
134
+ full_path = Path(working_dir) / file_path
135
+ else:
136
+ full_path = file_path_obj
137
+
138
+ # Security check: ensure file is within working directory or project
139
+ try:
140
+ real_path = full_path.resolve()
141
+ real_working_dir = Path(working_dir).resolve()
142
+
143
+ # Allow access to files within working directory or the project root
144
+ project_root = Path(get_project_root()).resolve()
145
+ allowed_paths = [real_working_dir, project_root]
146
+
147
+ is_allowed = any(
148
+ str(real_path).startswith(str(allowed_path))
149
+ for allowed_path in allowed_paths
150
+ )
151
+
152
+ if not is_allowed:
153
+ return {
154
+ "success": False,
155
+ "error": "Access denied: file is outside allowed directories",
156
+ "file_path": file_path,
157
+ }
158
+
159
+ except Exception as path_error:
160
+ self.logger.error(f"Path validation error: {path_error}")
161
+ return {
162
+ "success": False,
163
+ "error": "Invalid file path",
164
+ "file_path": file_path,
165
+ }
166
+
167
+ # Check if file exists
168
+ if not real_path.exists():
169
+ return {
170
+ "success": False,
171
+ "error": "File does not exist",
172
+ "file_path": file_path,
173
+ }
174
+
175
+ # Check if it's a file (not directory)
176
+ if not real_path.is_file():
177
+ return {
178
+ "success": False,
179
+ "error": "Path is not a file",
180
+ "file_path": file_path,
181
+ }
182
+
183
+ # Check file size
184
+ file_size = real_path.stat().st_size
185
+ if file_size > max_size:
186
+ return {
187
+ "success": False,
188
+ "error": f"File too large ({file_size} bytes). Maximum allowed: {max_size} bytes",
189
+ "file_path": file_path,
190
+ "file_size": file_size,
191
+ }
192
+
193
+ # Read file content
194
+ try:
195
+ with open(real_path, encoding="utf-8") as f:
196
+ content = f.read()
197
+
198
+ # Get file extension for syntax highlighting hint
199
+ ext = real_path.suffix
200
+
201
+ return {
202
+ "success": True,
203
+ "file_path": file_path,
204
+ "content": content,
205
+ "file_size": file_size,
206
+ "extension": ext.lower(),
207
+ "encoding": "utf-8",
208
+ }
209
+
210
+ except UnicodeDecodeError:
211
+ # Try reading as binary if UTF-8 fails
212
+ return self._read_binary_file(real_path, file_path, file_size)
213
+
214
+ except Exception as e:
215
+ self.logger.error(f"Error in _read_file_safely: {e}")
216
+ return {"success": False, "error": str(e), "file_path": file_path}
217
+
218
+ def _read_binary_file(
219
+ self, real_path: Path, file_path: str, file_size: int
220
+ ) -> Dict[str, Any]:
221
+ """Handle binary or non-UTF8 files.
222
+
223
+ WHY: Not all files are UTF-8 encoded. We need to handle other
224
+ encodings gracefully and detect binary files that shouldn't
225
+ be displayed as text.
226
+ """
227
+ try:
228
+ with open(real_path, "rb") as f:
229
+ binary_content = f.read()
230
+
231
+ # Check if it's a text file by looking for common text patterns
232
+ try:
233
+ text_content = binary_content.decode("latin-1")
234
+ if "\x00" in text_content:
235
+ # Binary file
236
+ return {
237
+ "success": False,
238
+ "error": "File appears to be binary and cannot be displayed as text",
239
+ "file_path": file_path,
240
+ "file_size": file_size,
241
+ }
242
+ # Text file with different encoding
243
+ ext = real_path.suffix
244
+ return {
245
+ "success": True,
246
+ "file_path": file_path,
247
+ "content": text_content,
248
+ "file_size": file_size,
249
+ "extension": ext.lower(),
250
+ "encoding": "latin-1",
251
+ }
252
+ except Exception:
253
+ return {
254
+ "success": False,
255
+ "error": "File encoding not supported",
256
+ "file_path": file_path,
257
+ }
258
+ except Exception as read_error:
259
+ return {
260
+ "success": False,
261
+ "error": f"Failed to read file: {read_error!s}",
262
+ "file_path": file_path,
263
+ }