claude-mpm 5.1.9__py3-none-any.whl → 5.4.48__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.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (248) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/__init__.py +4 -0
  3. claude_mpm/agents/BASE_AGENT.md +164 -0
  4. claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +1 -1
  5. claude_mpm/agents/MEMORY.md +1 -1
  6. claude_mpm/agents/PM_INSTRUCTIONS.md +843 -900
  7. claude_mpm/agents/WORKFLOW.md +5 -254
  8. claude_mpm/agents/agent_loader.py +13 -44
  9. claude_mpm/agents/base_agent.json +1 -1
  10. claude_mpm/agents/frontmatter_validator.py +2 -2
  11. claude_mpm/agents/templates/circuit-breakers.md +138 -1
  12. claude_mpm/cli/__main__.py +4 -0
  13. claude_mpm/cli/chrome_devtools_installer.py +175 -0
  14. claude_mpm/cli/commands/agent_state_manager.py +18 -27
  15. claude_mpm/cli/commands/agents.py +9 -40
  16. claude_mpm/cli/commands/auto_configure.py +210 -25
  17. claude_mpm/cli/commands/config.py +88 -2
  18. claude_mpm/cli/commands/configure.py +1098 -159
  19. claude_mpm/cli/commands/configure_agent_display.py +25 -6
  20. claude_mpm/cli/commands/mpm_init/core.py +225 -46
  21. claude_mpm/cli/commands/mpm_init/knowledge_extractor.py +481 -0
  22. claude_mpm/cli/commands/mpm_init/prompts.py +280 -0
  23. claude_mpm/cli/commands/postmortem.py +1 -1
  24. claude_mpm/cli/commands/profile.py +277 -0
  25. claude_mpm/cli/commands/skills.py +218 -197
  26. claude_mpm/cli/commands/summarize.py +413 -0
  27. claude_mpm/cli/executor.py +21 -3
  28. claude_mpm/cli/interactive/agent_wizard.py +2 -2
  29. claude_mpm/cli/parsers/agents_parser.py +0 -9
  30. claude_mpm/cli/parsers/auto_configure_parser.py +0 -138
  31. claude_mpm/cli/parsers/base_parser.py +12 -0
  32. claude_mpm/cli/parsers/config_parser.py +153 -83
  33. claude_mpm/cli/parsers/profile_parser.py +148 -0
  34. claude_mpm/cli/parsers/skills_parser.py +0 -5
  35. claude_mpm/cli/startup.py +876 -149
  36. claude_mpm/commands/mpm-config.md +28 -0
  37. claude_mpm/commands/mpm-doctor.md +9 -22
  38. claude_mpm/commands/mpm-help.md +5 -287
  39. claude_mpm/commands/mpm-init.md +81 -507
  40. claude_mpm/commands/mpm-monitor.md +15 -402
  41. claude_mpm/commands/mpm-organize.md +120 -0
  42. claude_mpm/commands/mpm-postmortem.md +6 -108
  43. claude_mpm/commands/mpm-session-resume.md +12 -363
  44. claude_mpm/commands/mpm-status.md +5 -69
  45. claude_mpm/commands/mpm-ticket-view.md +52 -495
  46. claude_mpm/commands/mpm-version.md +5 -107
  47. claude_mpm/config/agent_sources.py +27 -0
  48. claude_mpm/core/config.py +2 -4
  49. claude_mpm/core/framework/formatters/content_formatter.py +3 -13
  50. claude_mpm/core/framework/loaders/agent_loader.py +8 -5
  51. claude_mpm/core/framework/loaders/instruction_loader.py +52 -11
  52. claude_mpm/core/framework_loader.py +4 -2
  53. claude_mpm/core/logger.py +13 -0
  54. claude_mpm/core/optimized_startup.py +59 -0
  55. claude_mpm/core/shared/config_loader.py +1 -1
  56. claude_mpm/core/socketio_pool.py +3 -3
  57. claude_mpm/core/unified_agent_registry.py +5 -15
  58. claude_mpm/dashboard/static/svelte-build/_app/env.js +1 -0
  59. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +1 -0
  60. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +1 -0
  61. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +1 -0
  62. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +1 -0
  63. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWc5urbQ.js +1 -0
  64. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +2 -0
  65. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DjhvlsAc.js +1 -0
  66. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/N4qtv3Hx.js +2 -0
  67. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/uj46x2Wr.js +1 -0
  68. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +2 -0
  69. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +1 -0
  70. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/0.CAGBuiOw.js +1 -0
  71. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +1 -0
  72. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +10 -0
  73. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -0
  74. claude_mpm/dashboard/static/svelte-build/favicon.svg +7 -0
  75. claude_mpm/dashboard/static/svelte-build/index.html +36 -0
  76. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  77. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  78. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  79. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  80. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  81. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  82. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  83. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  84. claude_mpm/hooks/claude_hooks/correlation_manager.py +60 -0
  85. claude_mpm/hooks/claude_hooks/event_handlers.py +211 -78
  86. claude_mpm/hooks/claude_hooks/hook_handler.py +155 -1
  87. claude_mpm/hooks/claude_hooks/installer.py +33 -10
  88. claude_mpm/hooks/claude_hooks/memory_integration.py +26 -9
  89. claude_mpm/hooks/claude_hooks/response_tracking.py +2 -3
  90. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  91. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  92. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  93. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  94. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  95. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  96. claude_mpm/hooks/claude_hooks/services/connection_manager.py +30 -6
  97. claude_mpm/hooks/kuzu_memory_hook.py +5 -5
  98. claude_mpm/hooks/memory_integration_hook.py +46 -1
  99. claude_mpm/init.py +63 -19
  100. claude_mpm/models/git_repository.py +3 -3
  101. claude_mpm/scripts/claude-hook-handler.sh +58 -18
  102. claude_mpm/scripts/launch_monitor.py +93 -13
  103. claude_mpm/services/agents/agent_builder.py +3 -3
  104. claude_mpm/services/agents/agent_recommendation_service.py +278 -0
  105. claude_mpm/services/agents/agent_review_service.py +280 -0
  106. claude_mpm/services/agents/cache_git_manager.py +6 -6
  107. claude_mpm/services/agents/deployment/agent_deployment.py +29 -7
  108. claude_mpm/services/agents/deployment/agent_discovery_service.py +4 -5
  109. claude_mpm/services/agents/deployment/agent_format_converter.py +23 -13
  110. claude_mpm/services/agents/deployment/agent_template_builder.py +32 -20
  111. claude_mpm/services/agents/deployment/agents_directory_resolver.py +2 -2
  112. claude_mpm/services/agents/deployment/async_agent_deployment.py +31 -27
  113. claude_mpm/services/agents/deployment/local_template_deployment.py +3 -1
  114. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +247 -35
  115. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +392 -87
  116. claude_mpm/services/agents/git_source_manager.py +53 -4
  117. claude_mpm/services/agents/loading/base_agent_manager.py +1 -13
  118. claude_mpm/services/agents/recommender.py +5 -3
  119. claude_mpm/services/agents/single_tier_deployment_service.py +2 -2
  120. claude_mpm/services/agents/sources/git_source_sync_service.py +120 -7
  121. claude_mpm/services/agents/startup_sync.py +22 -2
  122. claude_mpm/services/agents/toolchain_detector.py +10 -6
  123. claude_mpm/services/analysis/__init__.py +11 -1
  124. claude_mpm/services/analysis/clone_detector.py +1030 -0
  125. claude_mpm/services/command_deployment_service.py +81 -10
  126. claude_mpm/services/diagnostics/checks/agent_check.py +2 -2
  127. claude_mpm/services/diagnostics/checks/agent_sources_check.py +1 -1
  128. claude_mpm/services/event_bus/config.py +3 -1
  129. claude_mpm/services/git/git_operations_service.py +101 -16
  130. claude_mpm/services/monitor/daemon.py +9 -2
  131. claude_mpm/services/monitor/daemon_manager.py +39 -3
  132. claude_mpm/services/monitor/management/lifecycle.py +8 -1
  133. claude_mpm/services/monitor/server.py +698 -22
  134. claude_mpm/services/pm_skills_deployer.py +711 -0
  135. claude_mpm/services/profile_manager.py +331 -0
  136. claude_mpm/services/self_upgrade_service.py +120 -12
  137. claude_mpm/services/skills/__init__.py +3 -0
  138. claude_mpm/services/skills/git_skill_source_manager.py +130 -2
  139. claude_mpm/services/skills/selective_skill_deployer.py +704 -0
  140. claude_mpm/services/skills/skill_to_agent_mapper.py +406 -0
  141. claude_mpm/services/skills_deployer.py +127 -9
  142. claude_mpm/services/socketio/dashboard_server.py +1 -0
  143. claude_mpm/services/socketio/event_normalizer.py +51 -6
  144. claude_mpm/services/socketio/server/core.py +386 -108
  145. claude_mpm/services/version_control/git_operations.py +103 -0
  146. claude_mpm/skills/skill_manager.py +92 -3
  147. claude_mpm/utils/agent_dependency_loader.py +14 -2
  148. claude_mpm/utils/agent_filters.py +17 -44
  149. claude_mpm/utils/migration.py +4 -4
  150. claude_mpm/utils/robust_installer.py +47 -3
  151. {claude_mpm-5.1.9.dist-info → claude_mpm-5.4.48.dist-info}/METADATA +53 -87
  152. {claude_mpm-5.1.9.dist-info → claude_mpm-5.4.48.dist-info}/RECORD +157 -197
  153. claude_mpm-5.4.48.dist-info/entry_points.txt +5 -0
  154. claude_mpm-5.4.48.dist-info/licenses/LICENSE +94 -0
  155. claude_mpm-5.4.48.dist-info/licenses/LICENSE-FAQ.md +153 -0
  156. claude_mpm/agents/BASE_AGENT_TEMPLATE.md +0 -292
  157. claude_mpm/agents/BASE_DOCUMENTATION.md +0 -53
  158. claude_mpm/agents/BASE_OPS.md +0 -219
  159. claude_mpm/agents/BASE_PM.md +0 -480
  160. claude_mpm/agents/BASE_PROMPT_ENGINEER.md +0 -787
  161. claude_mpm/agents/BASE_QA.md +0 -167
  162. claude_mpm/agents/BASE_RESEARCH.md +0 -53
  163. claude_mpm/agents/base_agent_loader.py +0 -601
  164. claude_mpm/cli/commands/agents_detect.py +0 -380
  165. claude_mpm/cli/commands/agents_recommend.py +0 -309
  166. claude_mpm/cli/ticket_cli.py +0 -35
  167. claude_mpm/commands/mpm-agents-auto-configure.md +0 -278
  168. claude_mpm/commands/mpm-agents-detect.md +0 -177
  169. claude_mpm/commands/mpm-agents-list.md +0 -131
  170. claude_mpm/commands/mpm-agents-recommend.md +0 -223
  171. claude_mpm/commands/mpm-config-view.md +0 -150
  172. claude_mpm/commands/mpm-ticket-organize.md +0 -304
  173. claude_mpm/dashboard/analysis_runner.py +0 -455
  174. claude_mpm/dashboard/index.html +0 -13
  175. claude_mpm/dashboard/open_dashboard.py +0 -66
  176. claude_mpm/dashboard/static/css/activity.css +0 -1958
  177. claude_mpm/dashboard/static/css/connection-status.css +0 -370
  178. claude_mpm/dashboard/static/css/dashboard.css +0 -4701
  179. claude_mpm/dashboard/static/js/components/activity-tree.js +0 -1871
  180. claude_mpm/dashboard/static/js/components/agent-hierarchy.js +0 -777
  181. claude_mpm/dashboard/static/js/components/agent-inference.js +0 -956
  182. claude_mpm/dashboard/static/js/components/build-tracker.js +0 -333
  183. claude_mpm/dashboard/static/js/components/code-simple.js +0 -857
  184. claude_mpm/dashboard/static/js/components/connection-debug.js +0 -654
  185. claude_mpm/dashboard/static/js/components/diff-viewer.js +0 -891
  186. claude_mpm/dashboard/static/js/components/event-processor.js +0 -542
  187. claude_mpm/dashboard/static/js/components/event-viewer.js +0 -1155
  188. claude_mpm/dashboard/static/js/components/export-manager.js +0 -368
  189. claude_mpm/dashboard/static/js/components/file-change-tracker.js +0 -443
  190. claude_mpm/dashboard/static/js/components/file-change-viewer.js +0 -690
  191. claude_mpm/dashboard/static/js/components/file-tool-tracker.js +0 -724
  192. claude_mpm/dashboard/static/js/components/file-viewer.js +0 -580
  193. claude_mpm/dashboard/static/js/components/hud-library-loader.js +0 -211
  194. claude_mpm/dashboard/static/js/components/hud-manager.js +0 -671
  195. claude_mpm/dashboard/static/js/components/hud-visualizer.js +0 -1718
  196. claude_mpm/dashboard/static/js/components/module-viewer.js +0 -2764
  197. claude_mpm/dashboard/static/js/components/session-manager.js +0 -579
  198. claude_mpm/dashboard/static/js/components/socket-manager.js +0 -368
  199. claude_mpm/dashboard/static/js/components/ui-state-manager.js +0 -749
  200. claude_mpm/dashboard/static/js/components/unified-data-viewer.js +0 -1824
  201. claude_mpm/dashboard/static/js/components/working-directory.js +0 -920
  202. claude_mpm/dashboard/static/js/connection-manager.js +0 -536
  203. claude_mpm/dashboard/static/js/dashboard.js +0 -1914
  204. claude_mpm/dashboard/static/js/extension-error-handler.js +0 -164
  205. claude_mpm/dashboard/static/js/socket-client.js +0 -1474
  206. claude_mpm/dashboard/static/js/tab-isolation-fix.js +0 -185
  207. claude_mpm/dashboard/static/socket.io.min.js +0 -7
  208. claude_mpm/dashboard/static/socket.io.v4.8.1.backup.js +0 -7
  209. claude_mpm/dashboard/templates/code_simple.html +0 -153
  210. claude_mpm/dashboard/templates/index.html +0 -606
  211. claude_mpm/dashboard/test_dashboard.html +0 -372
  212. claude_mpm/scripts/mcp_server.py +0 -75
  213. claude_mpm/scripts/mcp_wrapper.py +0 -39
  214. claude_mpm/services/mcp_gateway/__init__.py +0 -159
  215. claude_mpm/services/mcp_gateway/auto_configure.py +0 -369
  216. claude_mpm/services/mcp_gateway/config/__init__.py +0 -17
  217. claude_mpm/services/mcp_gateway/config/config_loader.py +0 -296
  218. claude_mpm/services/mcp_gateway/config/config_schema.py +0 -243
  219. claude_mpm/services/mcp_gateway/config/configuration.py +0 -429
  220. claude_mpm/services/mcp_gateway/core/__init__.py +0 -43
  221. claude_mpm/services/mcp_gateway/core/base.py +0 -312
  222. claude_mpm/services/mcp_gateway/core/exceptions.py +0 -253
  223. claude_mpm/services/mcp_gateway/core/interfaces.py +0 -443
  224. claude_mpm/services/mcp_gateway/core/process_pool.py +0 -977
  225. claude_mpm/services/mcp_gateway/core/singleton_manager.py +0 -315
  226. claude_mpm/services/mcp_gateway/core/startup_verification.py +0 -316
  227. claude_mpm/services/mcp_gateway/main.py +0 -589
  228. claude_mpm/services/mcp_gateway/registry/__init__.py +0 -12
  229. claude_mpm/services/mcp_gateway/registry/service_registry.py +0 -412
  230. claude_mpm/services/mcp_gateway/registry/tool_registry.py +0 -489
  231. claude_mpm/services/mcp_gateway/server/__init__.py +0 -15
  232. claude_mpm/services/mcp_gateway/server/mcp_gateway.py +0 -414
  233. claude_mpm/services/mcp_gateway/server/stdio_handler.py +0 -372
  234. claude_mpm/services/mcp_gateway/server/stdio_server.py +0 -712
  235. claude_mpm/services/mcp_gateway/tools/__init__.py +0 -36
  236. claude_mpm/services/mcp_gateway/tools/base_adapter.py +0 -485
  237. claude_mpm/services/mcp_gateway/tools/document_summarizer.py +0 -789
  238. claude_mpm/services/mcp_gateway/tools/external_mcp_services.py +0 -654
  239. claude_mpm/services/mcp_gateway/tools/health_check_tool.py +0 -456
  240. claude_mpm/services/mcp_gateway/tools/hello_world.py +0 -551
  241. claude_mpm/services/mcp_gateway/tools/kuzu_memory_service.py +0 -555
  242. claude_mpm/services/mcp_gateway/utils/__init__.py +0 -14
  243. claude_mpm/services/mcp_gateway/utils/package_version_checker.py +0 -160
  244. claude_mpm/services/mcp_gateway/utils/update_preferences.py +0 -170
  245. claude_mpm-5.1.9.dist-info/entry_points.txt +0 -10
  246. claude_mpm-5.1.9.dist-info/licenses/LICENSE +0 -21
  247. {claude_mpm-5.1.9.dist-info → claude_mpm-5.4.48.dist-info}/WHEEL +0 -0
  248. {claude_mpm-5.1.9.dist-info → claude_mpm-5.4.48.dist-info}/top_level.txt +0 -0
@@ -15,7 +15,6 @@ DESIGN DECISIONS:
15
15
  """
16
16
 
17
17
  import asyncio
18
- import contextlib
19
18
  import os
20
19
  import threading
21
20
  import time
@@ -25,10 +24,11 @@ from typing import Dict, Optional
25
24
 
26
25
  import socketio
27
26
  from aiohttp import web
27
+ from watchdog.events import FileSystemEventHandler
28
+ from watchdog.observers import Observer
28
29
 
29
30
  from ...core.enums import ServiceState
30
31
  from ...core.logging_config import get_logger
31
- from ...dashboard.api.simple_directory import list_directory
32
32
  from .event_emitter import get_event_emitter
33
33
  from .handlers.code_analysis import CodeAnalysisHandler
34
34
  from .handlers.dashboard import DashboardHandler
@@ -45,6 +45,91 @@ except ImportError:
45
45
  EVENTBUS_AVAILABLE = False
46
46
 
47
47
 
48
+ class SvelteBuildWatcher(FileSystemEventHandler):
49
+ """File watcher for Svelte build directory changes.
50
+
51
+ Watches for file changes in svelte-build directory and triggers
52
+ hot reload via Socket.IO event emission.
53
+
54
+ STABILITY FIX: Added thread lock and stop() method to prevent timer leaks.
55
+ """
56
+
57
+ def __init__(
58
+ self, sio: socketio.AsyncServer, loop: asyncio.AbstractEventLoop, logger
59
+ ):
60
+ """Initialize the file watcher.
61
+
62
+ Args:
63
+ sio: Socket.IO server instance for emitting events
64
+ loop: Event loop for async operations
65
+ logger: Logger instance
66
+ """
67
+ super().__init__()
68
+ self.sio = sio
69
+ self.loop = loop
70
+ self.logger = logger
71
+ self.debounce_timer = None
72
+ self.debounce_delay = 0.5 # Wait 500ms after last change
73
+ self._timer_lock = threading.Lock() # STABILITY FIX: Prevent race condition
74
+
75
+ def stop(self):
76
+ """Stop the watcher and cancel any pending timers.
77
+
78
+ STABILITY FIX: Ensures timer is cancelled on shutdown.
79
+ """
80
+ with self._timer_lock:
81
+ if self.debounce_timer:
82
+ self.debounce_timer.cancel()
83
+ self.debounce_timer = None
84
+
85
+ def on_any_event(self, event):
86
+ """Handle any file system event.
87
+
88
+ Args:
89
+ event: File system event from watchdog
90
+ """
91
+ # Ignore directory events and temporary files
92
+ if event.is_directory or event.src_path.endswith((".tmp", ".swp", "~")):
93
+ return
94
+
95
+ self.logger.debug(
96
+ f"File change detected: {event.event_type} - {event.src_path}"
97
+ )
98
+
99
+ # STABILITY FIX: Use lock to prevent timer race condition
100
+ with self._timer_lock:
101
+ # Cancel existing timer
102
+ if self.debounce_timer:
103
+ self.debounce_timer.cancel()
104
+
105
+ # Schedule reload after debounce delay
106
+ self.debounce_timer = threading.Timer(
107
+ self.debounce_delay, self._trigger_reload
108
+ )
109
+ self.debounce_timer.start()
110
+
111
+ def _trigger_reload(self):
112
+ """Trigger hot reload by emitting Socket.IO event."""
113
+ try:
114
+ # Schedule the async emit in the event loop
115
+ asyncio.run_coroutine_threadsafe(self._emit_reload_event(), self.loop)
116
+ self.logger.info("Hot reload triggered - Svelte build changed")
117
+ except Exception as e:
118
+ self.logger.error(f"Error triggering reload: {e}")
119
+
120
+ async def _emit_reload_event(self):
121
+ """Emit the reload event to all connected clients."""
122
+ if self.sio:
123
+ await self.sio.emit(
124
+ "reload",
125
+ {
126
+ "type": "reload",
127
+ "timestamp": datetime.now(timezone.utc).isoformat() + "Z",
128
+ "reason": "svelte-build-updated",
129
+ },
130
+ )
131
+
132
+
48
133
  class UnifiedMonitorServer:
49
134
  """Unified server that combines HTTP dashboard and Socket.IO functionality.
50
135
 
@@ -52,15 +137,19 @@ class UnifiedMonitorServer:
52
137
  Replaces multiple competing server implementations with one stable solution.
53
138
  """
54
139
 
55
- def __init__(self, host: str = "localhost", port: int = 8765):
140
+ def __init__(
141
+ self, host: str = "localhost", port: int = 8765, enable_hot_reload: bool = False
142
+ ):
56
143
  """Initialize the unified monitor server.
57
144
 
58
145
  Args:
59
146
  host: Host to bind to
60
147
  port: Port to bind to
148
+ enable_hot_reload: Enable file watching and hot reload for development
61
149
  """
62
150
  self.host = host
63
151
  self.port = port
152
+ self.enable_hot_reload = enable_hot_reload
64
153
  self.logger = get_logger(__name__)
65
154
 
66
155
  # Core components
@@ -78,6 +167,10 @@ class UnifiedMonitorServer:
78
167
  # High-performance event emitter
79
168
  self.event_emitter = None
80
169
 
170
+ # File watching (optional for dev mode)
171
+ self.file_observer: Optional[Observer] = None
172
+ self.file_watcher: Optional[SvelteBuildWatcher] = None
173
+
81
174
  # State
82
175
  self.running = False
83
176
  self.loop = None
@@ -184,6 +277,9 @@ class UnifiedMonitorServer:
184
277
 
185
278
  time.sleep(0.1)
186
279
 
280
+ # STABILITY FIX: Give tasks more time to clean up before closing
281
+ time.sleep(0.5)
282
+
187
283
  # Clear the event loop from the thread BEFORE closing
188
284
  # This prevents other code from accidentally using it
189
285
  asyncio.set_event_loop(None)
@@ -229,6 +325,10 @@ class UnifiedMonitorServer:
229
325
  self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
230
326
  self.logger.info("Heartbeat task started (3-minute interval)")
231
327
 
328
+ # Setup file watching for hot reload (if enabled)
329
+ if self.enable_hot_reload:
330
+ self._setup_file_watcher()
331
+
232
332
  # Setup HTTP routes
233
333
  self._setup_http_routes()
234
334
 
@@ -304,20 +404,64 @@ class UnifiedMonitorServer:
304
404
  self.logger.error(f"Error setting up event emitter: {e}")
305
405
  raise
306
406
 
407
+ def _setup_file_watcher(self):
408
+ """Setup file watcher for Svelte build directory.
409
+
410
+ Watches for changes in svelte-build and triggers hot reload.
411
+ Only enabled when enable_hot_reload is True.
412
+ """
413
+ try:
414
+ dashboard_dir = Path(__file__).resolve().parent.parent.parent / "dashboard"
415
+ svelte_build_dir = dashboard_dir / "static" / "svelte-build"
416
+
417
+ if not svelte_build_dir.exists():
418
+ self.logger.warning(
419
+ f"Svelte build directory not found: {svelte_build_dir}. "
420
+ "Hot reload disabled."
421
+ )
422
+ return
423
+
424
+ # Create file watcher with Socket.IO reference
425
+ self.file_watcher = SvelteBuildWatcher(
426
+ sio=self.sio, loop=self.loop, logger=self.logger
427
+ )
428
+
429
+ # Create observer and schedule watching
430
+ self.file_observer = Observer()
431
+ self.file_observer.schedule(
432
+ self.file_watcher, str(svelte_build_dir), recursive=True
433
+ )
434
+ self.file_observer.start()
435
+
436
+ self.logger.info(f"🔥 Hot reload enabled - watching {svelte_build_dir}")
437
+
438
+ except Exception as e:
439
+ self.logger.error(f"Error setting up file watcher: {e}")
440
+ # Don't raise - hot reload is optional
441
+
307
442
  def _setup_http_routes(self):
308
443
  """Setup HTTP routes for the dashboard."""
309
444
  try:
310
- # Dashboard static files
311
- dashboard_dir = Path(__file__).parent.parent.parent / "dashboard"
445
+ # Dashboard static files - use .resolve() for absolute path
446
+ dashboard_dir = Path(__file__).resolve().parent.parent.parent / "dashboard"
447
+ static_dir = dashboard_dir / "static"
312
448
 
313
- # Main dashboard route
449
+ # Main dashboard route - serve Svelte dashboard
314
450
  async def dashboard_index(request):
315
- template_path = dashboard_dir / "templates" / "index.html"
316
- if template_path.exists():
317
- with template_path.open() as f:
451
+ svelte_index = static_dir / "svelte-build" / "index.html"
452
+ if svelte_index.exists():
453
+ with svelte_index.open(encoding="utf-8") as f:
318
454
  content = f.read()
319
455
  return web.Response(text=content, content_type="text/html")
320
- return web.Response(text="Dashboard not found", status=404)
456
+
457
+ # Log error with path details for debugging
458
+ self.logger.error(
459
+ f"Dashboard index.html not found at: {svelte_index.resolve()}"
460
+ )
461
+ return web.Response(
462
+ text=f"Dashboard not found. Expected location: {svelte_index.resolve()}",
463
+ status=404,
464
+ )
321
465
 
322
466
  # Health check
323
467
  async def health_check(request):
@@ -325,7 +469,8 @@ class UnifiedMonitorServer:
325
469
  version = "1.0.0"
326
470
  try:
327
471
  version_file = (
328
- Path(__file__).parent.parent.parent.parent.parent / "VERSION"
472
+ Path(__file__).resolve().parent.parent.parent.parent.parent
473
+ / "VERSION"
329
474
  )
330
475
  if version_file.exists():
331
476
  version = version_file.read_text().strip()
@@ -442,6 +587,243 @@ class UnifiedMonitorServer:
442
587
  {"success": False, "error": str(e)}, status=500
443
588
  )
444
589
 
590
+ # File listing endpoint for file browser
591
+ async def api_files_handler(request):
592
+ """List files in a directory for the file browser."""
593
+ try:
594
+ # Get path from query param, default to working directory
595
+ path = request.query.get("path", str(Path.cwd()))
596
+ dir_path = Path(path)
597
+
598
+ if not dir_path.exists():
599
+ return web.json_response(
600
+ {"success": False, "error": "Directory not found"},
601
+ status=404,
602
+ )
603
+
604
+ if not dir_path.is_dir():
605
+ return web.json_response(
606
+ {"success": False, "error": "Path is not a directory"},
607
+ status=400,
608
+ )
609
+
610
+ # Patterns to exclude
611
+ exclude_patterns = {
612
+ ".git",
613
+ "node_modules",
614
+ "__pycache__",
615
+ ".svelte-kit",
616
+ "venv",
617
+ ".venv",
618
+ "dist",
619
+ "build",
620
+ ".next",
621
+ ".cache",
622
+ ".pytest_cache",
623
+ ".mypy_cache",
624
+ ".ruff_cache",
625
+ "eggs",
626
+ "*.egg-info",
627
+ ".tox",
628
+ ".nox",
629
+ "htmlcov",
630
+ ".coverage",
631
+ }
632
+
633
+ entries = []
634
+ try:
635
+ for entry in sorted(
636
+ dir_path.iterdir(),
637
+ key=lambda x: (not x.is_dir(), x.name.lower()),
638
+ ):
639
+ # Skip hidden files and excluded patterns
640
+ if entry.name.startswith(".") and entry.name not in {
641
+ ".env",
642
+ ".gitignore",
643
+ }:
644
+ if entry.name in {".git", ".svelte-kit", ".cache"}:
645
+ continue
646
+ if entry.name in exclude_patterns:
647
+ continue
648
+ if any(
649
+ entry.name.endswith(p.replace("*", ""))
650
+ for p in exclude_patterns
651
+ if "*" in p
652
+ ):
653
+ continue
654
+
655
+ try:
656
+ stat = entry.stat()
657
+ entries.append(
658
+ {
659
+ "name": entry.name,
660
+ "path": str(entry),
661
+ "type": "directory"
662
+ if entry.is_dir()
663
+ else "file",
664
+ "size": stat.st_size if entry.is_file() else 0,
665
+ "modified": stat.st_mtime,
666
+ "extension": entry.suffix.lstrip(".")
667
+ if entry.is_file()
668
+ else None,
669
+ }
670
+ )
671
+ except (PermissionError, OSError):
672
+ continue
673
+
674
+ except PermissionError:
675
+ return web.json_response(
676
+ {"success": False, "error": "Permission denied"},
677
+ status=403,
678
+ )
679
+
680
+ # Separate directories and files
681
+ directories = [e for e in entries if e["type"] == "directory"]
682
+ files = [e for e in entries if e["type"] == "file"]
683
+
684
+ return web.json_response(
685
+ {
686
+ "success": True,
687
+ "path": str(dir_path),
688
+ "directories": directories,
689
+ "files": files,
690
+ "total_directories": len(directories),
691
+ "total_files": len(files),
692
+ }
693
+ )
694
+
695
+ except Exception as e:
696
+ self.logger.error(f"Error listing directory: {e}")
697
+ return web.json_response(
698
+ {"success": False, "error": str(e)}, status=500
699
+ )
700
+
701
+ # File read endpoint (GET) for file browser
702
+ async def api_file_read_handler(request):
703
+ """Read file content via GET request."""
704
+ import base64
705
+
706
+ try:
707
+ file_path = request.query.get("path", "")
708
+
709
+ if not file_path:
710
+ return web.json_response(
711
+ {"success": False, "error": "Path parameter required"},
712
+ status=400,
713
+ )
714
+
715
+ path = Path(file_path)
716
+
717
+ if not path.exists():
718
+ return web.json_response(
719
+ {"success": False, "error": "File not found"},
720
+ status=404,
721
+ )
722
+
723
+ if not path.is_file():
724
+ return web.json_response(
725
+ {"success": False, "error": "Path is not a file"},
726
+ status=400,
727
+ )
728
+
729
+ # Get file info
730
+ file_size = path.stat().st_size
731
+ file_ext = path.suffix.lstrip(".").lower()
732
+
733
+ # Define image extensions
734
+ image_extensions = {
735
+ "png",
736
+ "jpg",
737
+ "jpeg",
738
+ "gif",
739
+ "svg",
740
+ "webp",
741
+ "ico",
742
+ "bmp",
743
+ }
744
+
745
+ # Check if file is an image
746
+ if file_ext in image_extensions:
747
+ # Read as binary and encode to base64
748
+ try:
749
+ binary_content = path.read_bytes()
750
+ base64_content = base64.b64encode(binary_content).decode(
751
+ "utf-8"
752
+ )
753
+
754
+ # Map extension to MIME type
755
+ mime_types = {
756
+ "png": "image/png",
757
+ "jpg": "image/jpeg",
758
+ "jpeg": "image/jpeg",
759
+ "gif": "image/gif",
760
+ "svg": "image/svg+xml",
761
+ "webp": "image/webp",
762
+ "ico": "image/x-icon",
763
+ "bmp": "image/bmp",
764
+ }
765
+ mime_type = mime_types.get(file_ext, "image/png")
766
+
767
+ return web.json_response(
768
+ {
769
+ "success": True,
770
+ "path": str(path),
771
+ "content": base64_content,
772
+ "size": file_size,
773
+ "type": "image",
774
+ "mime": mime_type,
775
+ "extension": file_ext,
776
+ }
777
+ )
778
+ except Exception as e:
779
+ self.logger.error(f"Error reading image file: {e}")
780
+ return web.json_response(
781
+ {
782
+ "success": False,
783
+ "error": f"Failed to read image: {e!s}",
784
+ },
785
+ status=500,
786
+ )
787
+
788
+ # Read text file content
789
+ try:
790
+ content = path.read_text(encoding="utf-8")
791
+ lines = content.count("\n") + 1
792
+ except UnicodeDecodeError:
793
+ return web.json_response(
794
+ {"success": False, "error": "File is not a text file"},
795
+ status=415,
796
+ )
797
+
798
+ return web.json_response(
799
+ {
800
+ "success": True,
801
+ "path": str(path),
802
+ "content": content,
803
+ "lines": lines,
804
+ "size": file_size,
805
+ "type": file_ext or "text",
806
+ }
807
+ )
808
+
809
+ except Exception as e:
810
+ self.logger.error(f"Error reading file: {e}")
811
+ return web.json_response(
812
+ {"success": False, "error": str(e)}, status=500
813
+ )
814
+
815
+ # Favicon handler
816
+ async def favicon_handler(request):
817
+ """Serve favicon.svg from static directory."""
818
+ from aiohttp.web_fileresponse import FileResponse
819
+
820
+ favicon_path = static_dir / "svelte-build" / "favicon.svg"
821
+ if favicon_path.exists():
822
+ return FileResponse(
823
+ favicon_path, headers={"Content-Type": "image/svg+xml"}
824
+ )
825
+ raise web.HTTPNotFound()
826
+
445
827
  # Version endpoint for dashboard build tracker
446
828
  async def version_handler(request):
447
829
  """Serve version information for dashboard build tracker."""
@@ -507,7 +889,7 @@ class UnifiedMonitorServer:
507
889
  async def working_directory_handler(request):
508
890
  """Return the current working directory."""
509
891
  return web.json_response(
510
- {"working_directory": Path.cwd(), "success": True}
892
+ {"working_directory": str(Path.cwd()), "success": True}
511
893
  )
512
894
 
513
895
  # Monitor page routes
@@ -525,15 +907,249 @@ class UnifiedMonitorServer:
525
907
  return web.Response(text=content, content_type="text/html")
526
908
  return web.Response(text="Page not found", status=404)
527
909
 
910
+ # Git history handler
911
+ async def git_history_handler(request: web.Request) -> web.Response:
912
+ """Get git history for a file."""
913
+ import subprocess
914
+
915
+ try:
916
+ data = await request.json()
917
+ file_path = data.get("path", "")
918
+ limit = data.get("limit", 10)
919
+
920
+ if not file_path:
921
+ return web.json_response(
922
+ {
923
+ "success": False,
924
+ "error": "No path provided",
925
+ "commits": [],
926
+ },
927
+ status=400,
928
+ )
929
+
930
+ path = Path(file_path)
931
+ if not path.exists():
932
+ return web.json_response(
933
+ {
934
+ "success": False,
935
+ "error": "File not found",
936
+ "commits": [],
937
+ },
938
+ status=404,
939
+ )
940
+
941
+ # Get git log for file
942
+ result = subprocess.run(
943
+ [
944
+ "git",
945
+ "log",
946
+ f"-{limit}",
947
+ "--pretty=format:%H|%an|%ar|%s",
948
+ "--",
949
+ str(path),
950
+ ],
951
+ check=False,
952
+ capture_output=True,
953
+ text=True,
954
+ cwd=str(path.parent),
955
+ )
956
+
957
+ commits = []
958
+ if result.returncode == 0 and result.stdout:
959
+ for line in result.stdout.strip().split("\n"):
960
+ if line:
961
+ parts = line.split("|", 3)
962
+ if len(parts) == 4:
963
+ commits.append(
964
+ {
965
+ "hash": parts[0][:7],
966
+ "author": parts[1],
967
+ "date": parts[2],
968
+ "message": parts[3],
969
+ }
970
+ )
971
+
972
+ return web.json_response({"success": True, "commits": commits})
973
+ except Exception as e:
974
+ return web.json_response(
975
+ {"success": False, "error": str(e), "commits": []}, status=500
976
+ )
977
+
978
+ # Git diff handler
979
+ async def git_diff_handler(request: web.Request) -> web.Response:
980
+ """Get git diff for a file with optional commit selection."""
981
+ import subprocess
982
+
983
+ try:
984
+ file_path = request.query.get("path", "")
985
+ commit_hash = request.query.get(
986
+ "commit", ""
987
+ ) # Optional commit hash
988
+
989
+ if not file_path:
990
+ return web.json_response(
991
+ {
992
+ "success": False,
993
+ "error": "No path provided",
994
+ "diff": "",
995
+ "has_changes": False,
996
+ },
997
+ status=400,
998
+ )
999
+
1000
+ path = Path(file_path)
1001
+ if not path.exists():
1002
+ return web.json_response(
1003
+ {
1004
+ "success": False,
1005
+ "error": "File not found",
1006
+ "diff": "",
1007
+ "has_changes": False,
1008
+ },
1009
+ status=404,
1010
+ )
1011
+
1012
+ # Find git repository root
1013
+ git_root_result = subprocess.run(
1014
+ ["git", "rev-parse", "--show-toplevel"],
1015
+ check=False,
1016
+ capture_output=True,
1017
+ text=True,
1018
+ cwd=str(path.parent),
1019
+ )
1020
+
1021
+ if git_root_result.returncode != 0:
1022
+ # Not in a git repository
1023
+ return web.json_response(
1024
+ {
1025
+ "success": True,
1026
+ "diff": "",
1027
+ "has_changes": False,
1028
+ "tracked": False,
1029
+ "history": [],
1030
+ "has_uncommitted": False,
1031
+ }
1032
+ )
1033
+
1034
+ git_root = Path(git_root_result.stdout.strip())
1035
+
1036
+ # Check if file is tracked by git
1037
+ ls_files_result = subprocess.run(
1038
+ ["git", "ls-files", "--error-unmatch", str(path)],
1039
+ check=False,
1040
+ capture_output=True,
1041
+ text=True,
1042
+ cwd=str(git_root),
1043
+ )
1044
+
1045
+ if ls_files_result.returncode != 0:
1046
+ # File is not tracked by git
1047
+ return web.json_response(
1048
+ {
1049
+ "success": True,
1050
+ "diff": "",
1051
+ "has_changes": False,
1052
+ "tracked": False,
1053
+ "history": [],
1054
+ "has_uncommitted": False,
1055
+ }
1056
+ )
1057
+
1058
+ # Get commit history for this file (last 5 commits)
1059
+ history_result = subprocess.run(
1060
+ [
1061
+ "git",
1062
+ "log",
1063
+ "-5",
1064
+ "--pretty=format:%H|%s|%ar",
1065
+ "--",
1066
+ str(path),
1067
+ ],
1068
+ check=False,
1069
+ capture_output=True,
1070
+ text=True,
1071
+ cwd=str(git_root),
1072
+ )
1073
+
1074
+ history = []
1075
+ if history_result.returncode == 0 and history_result.stdout:
1076
+ for line in history_result.stdout.strip().split("\n"):
1077
+ if line:
1078
+ parts = line.split("|", 2)
1079
+ if len(parts) == 3:
1080
+ history.append(
1081
+ {
1082
+ "hash": parts[0][:7], # Short hash
1083
+ "full_hash": parts[0], # Full hash for API
1084
+ "message": parts[1],
1085
+ "time_ago": parts[2],
1086
+ }
1087
+ )
1088
+
1089
+ # Check for uncommitted changes
1090
+ uncommitted_result = subprocess.run(
1091
+ ["git", "diff", "HEAD", str(path)],
1092
+ check=False,
1093
+ capture_output=True,
1094
+ text=True,
1095
+ cwd=str(git_root),
1096
+ )
1097
+
1098
+ has_uncommitted = bool(uncommitted_result.stdout.strip())
1099
+
1100
+ # Get diff based on commit parameter
1101
+ if commit_hash:
1102
+ # Get diff for specific commit
1103
+ result = subprocess.run(
1104
+ ["git", "show", commit_hash, "--", str(path)],
1105
+ check=False,
1106
+ capture_output=True,
1107
+ text=True,
1108
+ cwd=str(git_root),
1109
+ )
1110
+ diff_output = result.stdout if result.returncode == 0 else ""
1111
+ has_changes = bool(diff_output.strip())
1112
+ else:
1113
+ # Get uncommitted diff (default behavior)
1114
+ diff_output = uncommitted_result.stdout
1115
+ has_changes = has_uncommitted
1116
+
1117
+ return web.json_response(
1118
+ {
1119
+ "success": True,
1120
+ "diff": diff_output,
1121
+ "has_changes": has_changes,
1122
+ "tracked": True,
1123
+ "history": history,
1124
+ "has_uncommitted": has_uncommitted,
1125
+ }
1126
+ )
1127
+ except Exception as e:
1128
+ return web.json_response(
1129
+ {
1130
+ "success": False,
1131
+ "error": str(e),
1132
+ "diff": "",
1133
+ "has_changes": False,
1134
+ "history": [],
1135
+ "has_uncommitted": False,
1136
+ },
1137
+ status=500,
1138
+ )
1139
+
528
1140
  # Register routes
529
1141
  self.app.router.add_get("/", dashboard_index)
1142
+ self.app.router.add_get("/favicon.svg", favicon_handler)
530
1143
  self.app.router.add_get("/health", health_check)
531
1144
  self.app.router.add_get("/version.json", version_handler)
532
1145
  self.app.router.add_get("/api/config", config_handler)
533
1146
  self.app.router.add_get("/api/working-directory", working_directory_handler)
534
- self.app.router.add_get("/api/directory", list_directory)
1147
+ self.app.router.add_get("/api/files", api_files_handler)
1148
+ self.app.router.add_get("/api/file/read", api_file_read_handler)
1149
+ self.app.router.add_get("/api/file/diff", git_diff_handler)
535
1150
  self.app.router.add_post("/api/events", api_events_handler)
536
1151
  self.app.router.add_post("/api/file", api_file_handler)
1152
+ self.app.router.add_post("/api/git-history", git_history_handler)
537
1153
 
538
1154
  # Monitor page routes
539
1155
  self.app.router.add_get("/monitor", lambda r: monitor_page_handler(r))
@@ -546,12 +1162,43 @@ class UnifiedMonitorServer:
546
1162
  "/monitor/events", lambda r: monitor_page_handler(r)
547
1163
  )
548
1164
 
549
- # Static files with cache busting headers for development
550
- static_dir = dashboard_dir / "static"
1165
+ # Serve Svelte _app assets (compiled JS/CSS)
1166
+ svelte_build_dir = static_dir / "svelte-build"
1167
+ if svelte_build_dir.exists():
1168
+ svelte_app_dir = svelte_build_dir / "_app"
1169
+ if svelte_app_dir.exists():
1170
+ # Serve _app assets with proper caching
1171
+ async def app_assets_handler(request):
1172
+ """Serve Svelte _app assets."""
1173
+ from aiohttp.web_fileresponse import FileResponse
1174
+
1175
+ rel_path = request.match_info["filepath"]
1176
+ file_path = svelte_app_dir / rel_path
1177
+
1178
+ if not file_path.exists() or not file_path.is_file():
1179
+ raise web.HTTPNotFound()
1180
+
1181
+ response = FileResponse(file_path)
1182
+
1183
+ # Add cache headers for immutable assets
1184
+ if "/immutable/" in str(rel_path):
1185
+ response.headers["Cache-Control"] = (
1186
+ "public, max-age=31536000, immutable"
1187
+ )
1188
+ else:
1189
+ response.headers["Cache-Control"] = (
1190
+ "no-cache, no-store, must-revalidate"
1191
+ )
1192
+
1193
+ return response
1194
+
1195
+ self.app.router.add_get("/_app/{filepath:.*}", app_assets_handler)
1196
+
1197
+ # Legacy static files (for backward compatibility)
551
1198
  if static_dir.exists():
552
1199
 
553
1200
  async def static_handler(request):
554
- """Serve static files with cache-control headers for development."""
1201
+ """Serve legacy static files with cache-control headers for development."""
555
1202
 
556
1203
  from aiohttp.web_fileresponse import FileResponse
557
1204
 
@@ -576,10 +1223,13 @@ class UnifiedMonitorServer:
576
1223
 
577
1224
  self.app.router.add_get("/static/{filepath:.*}", static_handler)
578
1225
 
579
- # Templates
580
- templates_dir = dashboard_dir / "templates"
581
- if templates_dir.exists():
582
- self.app.router.add_static("/templates/", templates_dir)
1226
+ # Log dashboard availability
1227
+ if svelte_build_dir.exists():
1228
+ self.logger.info(
1229
+ f"✅ Svelte dashboard available at / (root) (build: {svelte_build_dir})"
1230
+ )
1231
+ else:
1232
+ self.logger.warning(f"Svelte build not found at: {svelte_build_dir}")
583
1233
 
584
1234
  self.logger.info("HTTP routes registered successfully")
585
1235
 
@@ -691,11 +1341,37 @@ class UnifiedMonitorServer:
691
1341
  async def _cleanup_async(self):
692
1342
  """Cleanup async resources."""
693
1343
  try:
1344
+ # Stop file observer if running
1345
+ # STABILITY FIX: Ensure watcher is stopped and verify observer termination
1346
+ if self.file_observer:
1347
+ try:
1348
+ # Stop the watcher first to cancel pending timers
1349
+ if self.file_watcher:
1350
+ self.file_watcher.stop()
1351
+
1352
+ # Stop the observer
1353
+ self.file_observer.stop()
1354
+ self.file_observer.join(timeout=2)
1355
+
1356
+ # Verify observer actually stopped
1357
+ if self.file_observer.is_alive():
1358
+ self.logger.warning("File observer did not stop cleanly")
1359
+
1360
+ self.logger.debug("File observer stopped")
1361
+ except Exception as e:
1362
+ self.logger.debug(f"Error stopping file observer: {e}")
1363
+ finally:
1364
+ self.file_observer = None
1365
+ self.file_watcher = None
1366
+
694
1367
  # Cancel heartbeat task if running
1368
+ # STABILITY FIX: Add timeout to prevent infinite wait on cancellation
695
1369
  if self.heartbeat_task and not self.heartbeat_task.done():
696
1370
  self.heartbeat_task.cancel()
697
- with contextlib.suppress(asyncio.CancelledError):
698
- await self.heartbeat_task
1371
+ try:
1372
+ await asyncio.wait_for(self.heartbeat_task, timeout=2.0)
1373
+ except (asyncio.CancelledError, asyncio.TimeoutError):
1374
+ pass
699
1375
  self.logger.debug("Heartbeat task cancelled")
700
1376
 
701
1377
  # Close the Socket.IO server first to stop accepting new connections