claude-mpm 4.0.32__py3-none-any.whl → 4.1.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 (82) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/INSTRUCTIONS.md +70 -2
  3. claude_mpm/agents/OUTPUT_STYLE.md +0 -11
  4. claude_mpm/agents/WORKFLOW.md +14 -2
  5. claude_mpm/agents/templates/documentation.json +51 -34
  6. claude_mpm/agents/templates/research.json +0 -11
  7. claude_mpm/cli/__init__.py +111 -33
  8. claude_mpm/cli/commands/agent_manager.py +10 -8
  9. claude_mpm/cli/commands/agents.py +82 -0
  10. claude_mpm/cli/commands/cleanup_orphaned_agents.py +150 -0
  11. claude_mpm/cli/commands/mcp_pipx_config.py +199 -0
  12. claude_mpm/cli/parsers/agents_parser.py +27 -0
  13. claude_mpm/cli/parsers/base_parser.py +6 -0
  14. claude_mpm/cli/startup_logging.py +75 -0
  15. claude_mpm/core/framework_loader.py +173 -84
  16. claude_mpm/dashboard/static/css/dashboard.css +449 -0
  17. claude_mpm/dashboard/static/dist/components/agent-inference.js +1 -1
  18. claude_mpm/dashboard/static/dist/components/event-viewer.js +1 -1
  19. claude_mpm/dashboard/static/dist/components/file-tool-tracker.js +1 -1
  20. claude_mpm/dashboard/static/dist/components/module-viewer.js +1 -1
  21. claude_mpm/dashboard/static/dist/components/session-manager.js +1 -1
  22. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  23. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  24. claude_mpm/dashboard/static/js/components/agent-hierarchy.js +774 -0
  25. claude_mpm/dashboard/static/js/components/agent-inference.js +257 -3
  26. claude_mpm/dashboard/static/js/components/build-tracker.js +323 -0
  27. claude_mpm/dashboard/static/js/components/event-viewer.js +168 -39
  28. claude_mpm/dashboard/static/js/components/file-tool-tracker.js +17 -0
  29. claude_mpm/dashboard/static/js/components/session-manager.js +23 -3
  30. claude_mpm/dashboard/static/js/components/socket-manager.js +2 -0
  31. claude_mpm/dashboard/static/js/dashboard.js +207 -31
  32. claude_mpm/dashboard/static/js/socket-client.js +92 -11
  33. claude_mpm/dashboard/templates/index.html +1 -0
  34. claude_mpm/hooks/claude_hooks/connection_pool.py +25 -4
  35. claude_mpm/hooks/claude_hooks/event_handlers.py +81 -19
  36. claude_mpm/hooks/claude_hooks/hook_handler.py +125 -163
  37. claude_mpm/hooks/claude_hooks/hook_handler_eventbus.py +398 -0
  38. claude_mpm/hooks/claude_hooks/response_tracking.py +10 -0
  39. claude_mpm/services/agents/deployment/agent_deployment.py +34 -48
  40. claude_mpm/services/agents/deployment/agent_discovery_service.py +4 -1
  41. claude_mpm/services/agents/deployment/agent_template_builder.py +20 -11
  42. claude_mpm/services/agents/deployment/agent_version_manager.py +4 -1
  43. claude_mpm/services/agents/deployment/agents_directory_resolver.py +10 -25
  44. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +396 -13
  45. claude_mpm/services/agents/deployment/pipeline/steps/target_directory_step.py +3 -2
  46. claude_mpm/services/agents/deployment/strategies/system_strategy.py +10 -3
  47. claude_mpm/services/agents/deployment/strategies/user_strategy.py +10 -14
  48. claude_mpm/services/agents/deployment/system_instructions_deployer.py +8 -85
  49. claude_mpm/services/agents/memory/content_manager.py +98 -105
  50. claude_mpm/services/event_bus/__init__.py +18 -0
  51. claude_mpm/services/event_bus/config.py +165 -0
  52. claude_mpm/services/event_bus/event_bus.py +349 -0
  53. claude_mpm/services/event_bus/relay.py +297 -0
  54. claude_mpm/services/events/__init__.py +44 -0
  55. claude_mpm/services/events/consumers/__init__.py +18 -0
  56. claude_mpm/services/events/consumers/dead_letter.py +296 -0
  57. claude_mpm/services/events/consumers/logging.py +183 -0
  58. claude_mpm/services/events/consumers/metrics.py +242 -0
  59. claude_mpm/services/events/consumers/socketio.py +376 -0
  60. claude_mpm/services/events/core.py +470 -0
  61. claude_mpm/services/events/interfaces.py +230 -0
  62. claude_mpm/services/events/producers/__init__.py +14 -0
  63. claude_mpm/services/events/producers/hook.py +269 -0
  64. claude_mpm/services/events/producers/system.py +327 -0
  65. claude_mpm/services/mcp_gateway/auto_configure.py +372 -0
  66. claude_mpm/services/mcp_gateway/core/process_pool.py +411 -0
  67. claude_mpm/services/mcp_gateway/server/stdio_server.py +13 -0
  68. claude_mpm/services/monitor_build_service.py +345 -0
  69. claude_mpm/services/socketio/event_normalizer.py +667 -0
  70. claude_mpm/services/socketio/handlers/connection.py +81 -23
  71. claude_mpm/services/socketio/handlers/hook.py +14 -5
  72. claude_mpm/services/socketio/migration_utils.py +329 -0
  73. claude_mpm/services/socketio/server/broadcaster.py +26 -33
  74. claude_mpm/services/socketio/server/core.py +29 -5
  75. claude_mpm/services/socketio/server/eventbus_integration.py +189 -0
  76. claude_mpm/services/socketio/server/main.py +25 -0
  77. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/METADATA +28 -9
  78. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/RECORD +82 -56
  79. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/WHEEL +0 -0
  80. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/entry_points.txt +0 -0
  81. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/licenses/LICENSE +0 -0
  82. {claude_mpm-4.0.32.dist-info → claude_mpm-4.1.0.dist-info}/top_level.txt +0 -0
@@ -99,9 +99,9 @@ class ConnectionEventHandler(BaseEventHandler):
99
99
  # Connection health tracking
100
100
  self.connection_metrics = {}
101
101
  self.last_ping_times = {}
102
- self.ping_interval = 30 # seconds
103
- self.ping_timeout = 10 # seconds
104
- self.stale_check_interval = 60 # seconds
102
+ self.ping_interval = 45 # seconds - avoid conflict with Engine.IO pings
103
+ self.ping_timeout = 20 # seconds - more lenient timeout
104
+ self.stale_check_interval = 90 # seconds - less frequent checks
105
105
 
106
106
  # Health monitoring tasks (will be started after event registration)
107
107
  self.ping_task = None
@@ -161,8 +161,13 @@ class ConnectionEventHandler(BaseEventHandler):
161
161
 
162
162
  for sid in list(self.clients):
163
163
  try:
164
- # Send ping and record time
165
- await self.sio.emit('ping', {'timestamp': current_time}, room=sid)
164
+ # Send ping and record time (using new schema)
165
+ await self.sio.emit('ping', {
166
+ 'type': 'system',
167
+ 'subtype': 'ping',
168
+ 'timestamp': current_time,
169
+ 'source': 'server'
170
+ }, room=sid)
166
171
  self.last_ping_times[sid] = current_time
167
172
 
168
173
  # Update connection metrics
@@ -284,16 +289,35 @@ class ConnectionEventHandler(BaseEventHandler):
284
289
  self.logger.info(f"📱 User Agent: {user_agent[:100]}...")
285
290
  self.logger.info(f"📈 Total clients now: {len(self.clients)}")
286
291
 
287
- # Send initial status immediately with enhanced data
292
+ # Get monitor build info
293
+ monitor_build_info = {}
294
+ try:
295
+ from ....services.monitor_build_service import get_monitor_build_service
296
+ monitor_service = get_monitor_build_service()
297
+ monitor_build_info = monitor_service.get_build_info_sync()
298
+ except Exception as e:
299
+ self.logger.debug(f"Could not get monitor build info: {e}")
300
+ monitor_build_info = {
301
+ "monitor": {"version": "1.0.0", "build": 1, "formatted_build": "0001"},
302
+ "mpm": {"version": "unknown", "build": "unknown"}
303
+ }
304
+
305
+ # Send initial status immediately with enhanced data (using new schema)
288
306
  status_data = {
289
- "server": "claude-mpm-python-socketio",
307
+ "type": "connection",
308
+ "subtype": "status",
290
309
  "timestamp": datetime.utcnow().isoformat() + "Z",
291
- "clients_connected": len(self.clients),
310
+ "source": "server",
292
311
  "session_id": self.server.session_id,
293
- "claude_status": self.server.claude_status,
294
- "claude_pid": self.server.claude_pid,
295
- "server_version": "2.0.0",
296
- "client_id": sid,
312
+ "data": {
313
+ "server": "claude-mpm-python-socketio",
314
+ "clients_connected": len(self.clients),
315
+ "claude_status": self.server.claude_status,
316
+ "claude_pid": self.server.claude_pid,
317
+ "server_version": "2.0.0",
318
+ "client_id": sid,
319
+ "build_info": monitor_build_info,
320
+ }
297
321
  }
298
322
 
299
323
  try:
@@ -302,9 +326,17 @@ class ConnectionEventHandler(BaseEventHandler):
302
326
  sid,
303
327
  "welcome",
304
328
  {
305
- "message": "Connected to Claude MPM Socket.IO server",
306
- "client_id": sid,
307
- "server_time": datetime.utcnow().isoformat() + "Z",
329
+ "type": "connection",
330
+ "subtype": "welcome",
331
+ "timestamp": datetime.utcnow().isoformat() + "Z",
332
+ "source": "server",
333
+ "session_id": self.server.session_id,
334
+ "data": {
335
+ "message": "Connected to Claude MPM Socket.IO server",
336
+ "client_id": sid,
337
+ "server_time": datetime.utcnow().isoformat() + "Z",
338
+ "build_info": monitor_build_info,
339
+ }
308
340
  },
309
341
  )
310
342
 
@@ -349,12 +381,17 @@ class ConnectionEventHandler(BaseEventHandler):
349
381
  to update their UI or verify connection health.
350
382
  """
351
383
  status_data = {
352
- "server": "claude-mpm-python-socketio",
384
+ "type": "connection",
385
+ "subtype": "status",
353
386
  "timestamp": datetime.utcnow().isoformat() + "Z",
354
- "clients_connected": len(self.clients),
387
+ "source": "server",
355
388
  "session_id": self.server.session_id,
356
- "claude_status": self.server.claude_status,
357
- "claude_pid": self.server.claude_pid,
389
+ "data": {
390
+ "server": "claude-mpm-python-socketio",
391
+ "clients_connected": len(self.clients),
392
+ "claude_status": self.server.claude_status,
393
+ "claude_pid": self.server.claude_pid,
394
+ }
358
395
  }
359
396
  await self.emit_to_client(sid, "status", status_data)
360
397
 
@@ -395,7 +432,13 @@ class ConnectionEventHandler(BaseEventHandler):
395
432
  for filtered event streaming.
396
433
  """
397
434
  channels = data.get("channels", ["*"]) if data else ["*"]
398
- await self.emit_to_client(sid, "subscribed", {"channels": channels})
435
+ await self.emit_to_client(sid, "subscribed", {
436
+ "type": "connection",
437
+ "subtype": "subscribed",
438
+ "timestamp": datetime.utcnow().isoformat() + "Z",
439
+ "source": "server",
440
+ "data": {"channels": channels}
441
+ })
399
442
 
400
443
  @self.sio.event
401
444
  @timeout_handler(timeout_seconds=5.0)
@@ -409,10 +452,24 @@ class ConnectionEventHandler(BaseEventHandler):
409
452
  self.logger.info(f"🔵 Received claude_event from {sid}: {data}")
410
453
 
411
454
  # Check if this is a hook event and route to HookEventHandler
412
- # Hook events have types like "hook.user_prompt", "hook.pre_tool", etc.
455
+ # Hook events can have either:
456
+ # 1. Normalized format: type="hook", subtype="pre_tool"
457
+ # 2. Legacy format: type="hook.pre_tool"
413
458
  if isinstance(data, dict):
414
459
  event_type = data.get("type", "")
415
- if isinstance(event_type, str) and event_type.startswith("hook."):
460
+ event_subtype = data.get("subtype", "")
461
+
462
+ # Check for both normalized and legacy formats
463
+ is_hook_event = False
464
+ if isinstance(event_type, str):
465
+ # Legacy format: type="hook.something"
466
+ if event_type.startswith("hook."):
467
+ is_hook_event = True
468
+ # Normalized format: type="hook" with any subtype
469
+ elif event_type == "hook":
470
+ is_hook_event = True
471
+
472
+ if is_hook_event:
416
473
  # Get the hook handler if available
417
474
  hook_handler = None
418
475
  # Check if event_registry exists and has handlers
@@ -502,7 +559,8 @@ class ConnectionEventHandler(BaseEventHandler):
502
559
  elif event_name == 'ToolCall':
503
560
  normalized['type'] = 'tool'
504
561
  elif event_name == 'UserPrompt':
505
- normalized['type'] = 'hook.user_prompt'
562
+ normalized['type'] = 'hook'
563
+ normalized['subtype'] = 'user_prompt'
506
564
  else:
507
565
  # Default to system type for unknown events
508
566
  normalized['type'] = 'system'
@@ -41,14 +41,23 @@ class HookEventHandler(BaseEventHandler):
41
41
  return
42
42
 
43
43
  # Extract hook event details
44
- # Hook events come as: { type: "hook.user_prompt", timestamp: "...", data: {...} }
44
+ # Hook events can come in two formats:
45
+ # 1. Normalized: { type: "hook", subtype: "user_prompt", ... }
46
+ # 2. Legacy: { type: "hook.user_prompt", ... }
45
47
  event_type = data.get("type", "")
46
-
47
- # Extract the actual hook event name from the type (e.g., "hook.user_prompt" -> "user_prompt")
48
- if event_type.startswith("hook."):
48
+ event_subtype = data.get("subtype", "")
49
+
50
+ # Determine the actual hook event name
51
+ hook_event = ""
52
+ if event_type == "hook" and event_subtype:
53
+ # Normalized format: use subtype directly
54
+ hook_event = event_subtype
55
+ elif isinstance(event_type, str) and event_type.startswith("hook."):
56
+ # Legacy format: extract from dotted type
49
57
  hook_event = event_type[5:] # Remove "hook." prefix
50
58
  else:
51
- hook_event = data.get("event", "") # Fallback for legacy format
59
+ # Fallback: check for 'event' field (another legacy format)
60
+ hook_event = data.get("event", "")
52
61
 
53
62
  hook_data = data.get("data", {})
54
63
 
@@ -0,0 +1,329 @@
1
+ """
2
+ Migration utilities for Socket.IO event schema migration.
3
+
4
+ WHY: This module provides utilities to help migrate from the old event
5
+ formats to the new normalized schema, ensuring backward compatibility
6
+ during the transition period.
7
+
8
+ DESIGN DECISION: Provide both transformation and validation utilities
9
+ to help identify and fix inconsistent event formats across the codebase.
10
+ """
11
+
12
+ from datetime import datetime
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+ import json
15
+
16
+ from ...core.logging_config import get_logger
17
+
18
+
19
+ class EventMigrationHelper:
20
+ """Helper class for migrating events to the new schema.
21
+
22
+ WHY: Provides utilities to identify old format events and
23
+ transform them to the new normalized schema.
24
+ """
25
+
26
+ def __init__(self):
27
+ self.logger = get_logger(self.__class__.__name__)
28
+ self.migration_stats = {
29
+ "old_format_detected": 0,
30
+ "transformed": 0,
31
+ "validation_failed": 0,
32
+ "already_normalized": 0
33
+ }
34
+
35
+ def is_old_format(self, event_data: Any) -> bool:
36
+ """Check if an event is in the old format.
37
+
38
+ WHY: Need to identify which events need migration.
39
+ """
40
+ if not isinstance(event_data, dict):
41
+ return True # Non-dict events are definitely old format
42
+
43
+ # Check for new format fields
44
+ required_new_fields = {"event", "type", "subtype", "timestamp", "data"}
45
+ has_all_new_fields = all(field in event_data for field in required_new_fields)
46
+
47
+ if has_all_new_fields:
48
+ self.migration_stats["already_normalized"] += 1
49
+ return False
50
+
51
+ # Old format indicators
52
+ old_format_indicators = [
53
+ # Hook format with "hook." prefix in type
54
+ "type" in event_data and isinstance(event_data.get("type"), str) and event_data["type"].startswith("hook."),
55
+ # Missing subtype field
56
+ "type" in event_data and "subtype" not in event_data,
57
+ # Event field used differently
58
+ "event" in event_data and event_data.get("event") != "claude_event",
59
+ ]
60
+
61
+ if any(old_format_indicators):
62
+ self.migration_stats["old_format_detected"] += 1
63
+ return True
64
+
65
+ return False
66
+
67
+ def transform_to_new_format(self, event_data: Any) -> Dict[str, Any]:
68
+ """Transform an old format event to the new schema.
69
+
70
+ WHY: Provides a migration path from old to new format.
71
+ """
72
+ # Import here to avoid circular dependency
73
+ from .event_normalizer import EventNormalizer
74
+
75
+ normalizer = EventNormalizer()
76
+ normalized = normalizer.normalize(event_data)
77
+
78
+ self.migration_stats["transformed"] += 1
79
+ return normalized.to_dict()
80
+
81
+ def validate_event_schema(self, event_data: Dict[str, Any]) -> Tuple[bool, List[str]]:
82
+ """Validate an event against the new schema.
83
+
84
+ WHY: Ensures events conform to the expected structure
85
+ before being sent to clients.
86
+
87
+ Returns:
88
+ Tuple of (is_valid, list_of_errors)
89
+ """
90
+ errors = []
91
+
92
+ # Check required fields
93
+ required_fields = {"event", "type", "subtype", "timestamp", "data"}
94
+ missing_fields = required_fields - set(event_data.keys())
95
+ if missing_fields:
96
+ errors.append(f"Missing required fields: {missing_fields}")
97
+
98
+ # Validate field types
99
+ if "event" in event_data and event_data["event"] != "claude_event":
100
+ errors.append(f"Invalid event name: {event_data['event']} (should be 'claude_event')")
101
+
102
+ if "type" in event_data and not isinstance(event_data["type"], str):
103
+ errors.append(f"Invalid type field: should be string, got {type(event_data['type'])}")
104
+
105
+ if "subtype" in event_data and not isinstance(event_data["subtype"], str):
106
+ errors.append(f"Invalid subtype field: should be string, got {type(event_data['subtype'])}")
107
+
108
+ if "timestamp" in event_data:
109
+ timestamp = event_data["timestamp"]
110
+ if not isinstance(timestamp, str) or "T" not in timestamp:
111
+ errors.append(f"Invalid timestamp format: {timestamp} (should be ISO format)")
112
+
113
+ if "data" in event_data and not isinstance(event_data["data"], dict):
114
+ errors.append(f"Invalid data field: should be dict, got {type(event_data['data'])}")
115
+
116
+ if errors:
117
+ self.migration_stats["validation_failed"] += 1
118
+ return False, errors
119
+
120
+ return True, []
121
+
122
+ def get_migration_report(self) -> str:
123
+ """Generate a report of migration statistics.
124
+
125
+ WHY: Helps track migration progress and identify issues.
126
+ """
127
+ report = "Event Migration Report\n"
128
+ report += "=" * 50 + "\n"
129
+ report += f"Old format detected: {self.migration_stats['old_format_detected']}\n"
130
+ report += f"Events transformed: {self.migration_stats['transformed']}\n"
131
+ report += f"Validation failures: {self.migration_stats['validation_failed']}\n"
132
+ report += f"Already normalized: {self.migration_stats['already_normalized']}\n"
133
+ return report
134
+
135
+ def reset_stats(self):
136
+ """Reset migration statistics."""
137
+ self.migration_stats = {
138
+ "old_format_detected": 0,
139
+ "transformed": 0,
140
+ "validation_failed": 0,
141
+ "already_normalized": 0
142
+ }
143
+
144
+
145
+ class EventTypeMapper:
146
+ """Maps old event types to new type/subtype categories.
147
+
148
+ WHY: Provides a consistent mapping from legacy event names
149
+ to the new categorized structure.
150
+ """
151
+
152
+ # Comprehensive mapping of old event formats to new categories
153
+ TYPE_MAPPINGS = {
154
+ # Hook events with "hook." prefix
155
+ "hook.pre_tool": ("hook", "pre_tool"),
156
+ "hook.post_tool": ("hook", "post_tool"),
157
+ "hook.pre_response": ("hook", "pre_response"),
158
+ "hook.post_response": ("hook", "post_response"),
159
+ "hook.start": ("hook", "start"),
160
+ "hook.stop": ("hook", "stop"),
161
+ "hook.subagent_start": ("hook", "subagent_start"),
162
+ "hook.subagent_stop": ("hook", "subagent_stop"),
163
+
164
+ # Hook events without prefix
165
+ "pre_tool": ("hook", "pre_tool"),
166
+ "post_tool": ("hook", "post_tool"),
167
+ "pre_response": ("hook", "pre_response"),
168
+ "post_response": ("hook", "post_response"),
169
+
170
+ # System events
171
+ "system_heartbeat": ("system", "heartbeat"),
172
+ "heartbeat": ("system", "heartbeat"),
173
+ "system_status": ("system", "status"),
174
+
175
+ # Session events
176
+ "session_started": ("session", "started"),
177
+ "session_ended": ("session", "ended"),
178
+
179
+ # File events
180
+ "file_changed": ("file", "changed"),
181
+ "file_created": ("file", "created"),
182
+ "file_deleted": ("file", "deleted"),
183
+
184
+ # Connection events
185
+ "client_connected": ("connection", "connected"),
186
+ "client_disconnected": ("connection", "disconnected"),
187
+
188
+ # Memory events
189
+ "memory_loaded": ("memory", "loaded"),
190
+ "memory_created": ("memory", "created"),
191
+ "memory_updated": ("memory", "updated"),
192
+ "memory_injected": ("memory", "injected"),
193
+
194
+ # Git events
195
+ "git_operation": ("git", "operation"),
196
+ "git_commit": ("git", "commit"),
197
+ "git_push": ("git", "push"),
198
+
199
+ # Todo events
200
+ "todo_updated": ("todo", "updated"),
201
+ "todo_created": ("todo", "created"),
202
+
203
+ # Ticket events
204
+ "ticket_created": ("ticket", "created"),
205
+ "ticket_updated": ("ticket", "updated"),
206
+
207
+ # Agent events
208
+ "agent_delegated": ("agent", "delegated"),
209
+ "agent_completed": ("agent", "completed"),
210
+
211
+ # Claude events
212
+ "claude_status": ("claude", "status"),
213
+ "claude_output": ("claude", "output"),
214
+
215
+ # Error events
216
+ "error": ("error", "general"),
217
+ "error_occurred": ("error", "occurred"),
218
+
219
+ # Performance events
220
+ "performance": ("performance", "metric"),
221
+ "performance_metric": ("performance", "metric"),
222
+ }
223
+
224
+ @classmethod
225
+ def map_event_type(cls, old_type: str) -> Tuple[str, str]:
226
+ """Map an old event type to new type/subtype.
227
+
228
+ WHY: Provides consistent categorization for all events.
229
+
230
+ Args:
231
+ old_type: The old event type string
232
+
233
+ Returns:
234
+ Tuple of (type, subtype)
235
+ """
236
+ # Direct mapping
237
+ if old_type in cls.TYPE_MAPPINGS:
238
+ return cls.TYPE_MAPPINGS[old_type]
239
+
240
+ # Try to infer from patterns
241
+ old_lower = old_type.lower()
242
+
243
+ # Hook events
244
+ if "hook" in old_lower or old_lower.startswith("pre_") or old_lower.startswith("post_"):
245
+ # Remove "hook." prefix if present
246
+ clean_type = old_type.replace("hook.", "")
247
+ return "hook", clean_type
248
+
249
+ # System events
250
+ if "system" in old_lower or "heartbeat" in old_lower:
251
+ return "system", old_type.replace("system_", "")
252
+
253
+ # Session events
254
+ if "session" in old_lower:
255
+ if "start" in old_lower:
256
+ return "session", "started"
257
+ elif "end" in old_lower:
258
+ return "session", "ended"
259
+ return "session", "generic"
260
+
261
+ # File events
262
+ if "file" in old_lower:
263
+ if "create" in old_lower:
264
+ return "file", "created"
265
+ elif "delete" in old_lower:
266
+ return "file", "deleted"
267
+ elif "change" in old_lower or "modify" in old_lower:
268
+ return "file", "changed"
269
+ return "file", "generic"
270
+
271
+ # Default mapping
272
+ return "unknown", old_type
273
+
274
+ @classmethod
275
+ def get_event_category(cls, event_type: str, event_subtype: str) -> str:
276
+ """Get a human-readable category for an event.
277
+
278
+ WHY: Helps with filtering and display in UIs.
279
+ """
280
+ categories = {
281
+ "hook": "Claude Hooks",
282
+ "system": "System Status",
283
+ "session": "Session Management",
284
+ "file": "File Operations",
285
+ "connection": "Client Connections",
286
+ "memory": "Memory System",
287
+ "git": "Git Operations",
288
+ "todo": "Todo Management",
289
+ "ticket": "Ticket System",
290
+ "agent": "Agent Delegation",
291
+ "claude": "Claude Process",
292
+ "error": "Errors",
293
+ "performance": "Performance Metrics",
294
+ "unknown": "Uncategorized"
295
+ }
296
+ return categories.get(event_type, "Other")
297
+
298
+
299
+ def create_backward_compatible_event(normalized_event: Dict[str, Any]) -> Dict[str, Any]:
300
+ """Create a backward-compatible version of a normalized event.
301
+
302
+ WHY: During migration, some clients may still expect the old format.
303
+ This creates an event that works with both old and new clients.
304
+
305
+ Args:
306
+ normalized_event: Event in the new normalized format
307
+
308
+ Returns:
309
+ Event that includes both old and new format fields
310
+ """
311
+ # Start with the normalized event
312
+ compat_event = normalized_event.copy()
313
+
314
+ # Add old format fields based on type/subtype
315
+ event_type = normalized_event.get("type", "")
316
+ event_subtype = normalized_event.get("subtype", "")
317
+
318
+ # For hook events, add the old "hook." prefix format
319
+ if event_type == "hook":
320
+ compat_event["type_legacy"] = f"hook.{event_subtype}"
321
+
322
+ # For other events, use the old naming convention
323
+ elif event_type in ["session", "file", "memory", "git", "todo", "ticket", "agent", "claude"]:
324
+ compat_event["type_legacy"] = f"{event_type}_{event_subtype}"
325
+
326
+ # Add event_type field for really old clients
327
+ compat_event["event_type"] = compat_event.get("type_legacy", f"{event_type}_{event_subtype}")
328
+
329
+ return compat_event
@@ -17,6 +17,7 @@ from datetime import datetime
17
17
  from typing import Any, Deque, Dict, List, Optional, Set
18
18
 
19
19
  from ....core.logging_config import get_logger
20
+ from ..event_normalizer import EventNormalizer
20
21
 
21
22
 
22
23
  @dataclass
@@ -178,6 +179,9 @@ class SocketIOEventBroadcaster:
178
179
  self.retry_queue = RetryQueue(max_size=1000)
179
180
  self.retry_task = None
180
181
  self.retry_interval = 2.0 # Process retry queue every 2 seconds
182
+
183
+ # Initialize event normalizer for consistent schema
184
+ self.normalizer = EventNormalizer()
181
185
 
182
186
  def start_retry_processor(self):
183
187
  """Start the background retry processor.
@@ -274,21 +278,25 @@ class SocketIOEventBroadcaster:
274
278
  """Retry broadcasting a failed event.
275
279
 
276
280
  WHY: Isolated retry logic allows for special handling
277
- and metrics tracking of retry attempts.
281
+ and metrics tracking of retry attempts. Uses normalizer
282
+ to ensure consistent schema.
278
283
  """
279
284
  try:
280
285
  self.logger.debug(
281
286
  f"🔄 Retrying {event.event_type} (attempt {event.attempt_count + 1}/{event.max_retries})"
282
287
  )
283
288
 
284
- # Reconstruct the full event
285
- full_event = {
289
+ # Reconstruct the raw event
290
+ raw_event = {
286
291
  "type": event.event_type,
287
292
  "timestamp": datetime.now().isoformat(),
288
- "data": event.data,
289
- "retry_attempt": event.attempt_count + 1
293
+ "data": {**event.data, "retry_attempt": event.attempt_count + 1},
290
294
  }
291
295
 
296
+ # Normalize the event
297
+ normalized = self.normalizer.normalize(raw_event)
298
+ full_event = normalized.to_dict()
299
+
292
300
  # Attempt broadcast
293
301
  if event.skip_sid:
294
302
  await self.sio.emit("claude_event", full_event, skip_sid=event.skip_sid)
@@ -309,16 +317,22 @@ class SocketIOEventBroadcaster:
309
317
  """Broadcast an event to all connected clients with retry support.
310
318
 
311
319
  WHY: Enhanced with retry queue to ensure reliable delivery
312
- even during transient network issues.
320
+ even during transient network issues. Now uses EventNormalizer
321
+ to ensure consistent event schema.
313
322
  """
314
323
  if not self.sio:
315
324
  return
316
325
 
317
- event = {
326
+ # Create raw event for normalization
327
+ raw_event = {
318
328
  "type": event_type,
319
329
  "timestamp": datetime.now().isoformat(),
320
330
  "data": data,
321
331
  }
332
+
333
+ # Normalize the event to consistent schema
334
+ normalized = self.normalizer.normalize(raw_event)
335
+ event = normalized.to_dict()
322
336
 
323
337
  # Buffer the event for reliability AND add to event history for new clients
324
338
  with self.buffer_lock:
@@ -329,7 +343,7 @@ class SocketIOEventBroadcaster:
329
343
  # Access through server reference to maintain single history source
330
344
  if hasattr(self, 'server') and hasattr(self.server, 'event_history'):
331
345
  self.server.event_history.append(event)
332
- self.logger.debug(f"Added {event_type} to history (total: {len(self.server.event_history)})")
346
+ self.logger.debug(f"Added {event['type']}/{event['subtype']} to history (total: {len(self.server.event_history)})")
333
347
 
334
348
  # Broadcast to all connected clients
335
349
  broadcast_success = False
@@ -521,29 +535,8 @@ class SocketIOEventBroadcaster:
521
535
 
522
536
  WHY: System events are separate from hook events to provide
523
537
  server health monitoring independent of Claude activity.
538
+ Now uses broadcast_event for consistency with buffering and normalization.
524
539
  """
525
- if not self.sio:
526
- return
527
-
528
- # Create system event with consistent format
529
- event = {
530
- "type": "system",
531
- "event": "heartbeat",
532
- "timestamp": datetime.now().isoformat(),
533
- "data": heartbeat_data,
534
- }
535
-
536
- # Broadcast to all connected clients
537
- try:
538
- if self.loop and not self.loop.is_closed():
539
- future = asyncio.run_coroutine_threadsafe(
540
- self.sio.emit("system_event", event), self.loop
541
- )
542
- self.logger.debug(
543
- f"Broadcasted system heartbeat - clients: {len(self.connected_clients)}, "
544
- f"uptime: {heartbeat_data.get('uptime_seconds', 0)}s"
545
- )
546
- else:
547
- self.logger.warning("Cannot broadcast heartbeat: server loop not available")
548
- except Exception as e:
549
- self.logger.error(f"Failed to broadcast system heartbeat: {e}")
540
+ # Use the standard broadcast_event method which handles normalization,
541
+ # buffering, and retry logic
542
+ self.broadcast_event("heartbeat", heartbeat_data)