diff --git a/configure.ac b/configure.ac
index c303ac0..740db46 100644
--- a/configure.ac
+++ b/configure.ac
@@ -46,6 +46,7 @@ AM_PATH_PYTHON(3.3)
 
 AM_CHECK_PYMOD(gi,,,[AC_MSG_ERROR(Could not find python module: gi)])
 AM_CHECK_PYMOD(json,,,[AC_MSG_ERROR(Could not find python module: json)])
+AM_CHECK_PYMOD(pluggy,,[pluggy_available="yes"],[pluggy_available="no"])
 AM_CHECK_PYMOD(brlapi,,[brlapi_available="yes"],[brlapi_available="no"])
 AM_CHECK_PYMOD(speechd,,[speechd_available="yes"],[speechd_available="no"])
 AC_ARG_WITH([liblouis],
@@ -129,9 +130,9 @@ src/cthulhu/plugins/HelloCthulhu/Makefile
 src/cthulhu/plugins/PluginManager/Makefile
 src/cthulhu/plugins/Clipboard/Makefile
 src/cthulhu/plugins/DisplayVersion/Makefile
-src/cthulhu/plugins/HelloWorld/Makefile
+src/cthulhu/plugins/hello_world/Makefile
 src/cthulhu/plugins/CapsLockHack/Makefile
-src/cthulhu/plugins/SelfVoice/Makefile
+src/cthulhu/plugins/self_voice/Makefile
 src/cthulhu/plugins/Date/Makefile
 src/cthulhu/plugins/Time/Makefile
 src/cthulhu/plugins/MouseReview/Makefile
@@ -168,6 +169,7 @@ if test "$have_libpeas" = "no"; then
 fi
 
 echo
+echo Use pluggy: $pluggy_available
 echo Use speech-dispatcher: $speechd_available
 echo Use brltty: $brlapi_available
 echo Use liblouis: $louis_available
diff --git a/contrib/toggle.screenreader.gschema.xml b/contrib/toggle.screenreader.gschema.xml
new file mode 100644
index 0000000..7aa86ec
--- /dev/null
+++ b/contrib/toggle.screenreader.gschema.xml
@@ -0,0 +1,20 @@
+
+
+  
+    
+        '<Shift><Alt>v'
+        Keybinding
+        Keybinding associated to toggle screen reader.
+    
+    
+        '/opt/I38/scripts/toggle_screenreader.sh 1>/dev/null'
+        Command
+        Command to toggle the screen reader between orca and cthulhu.
+    
+    
+        'Toggle screenreader'
+        Name
+        Description associated to toggle screen reader.
+    
+  
+
diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py
index ba0a3b4..6e85089 100644
--- a/src/cthulhu/cthulhu.py
+++ b/src/cthulhu/cthulhu.py
@@ -34,6 +34,94 @@ __copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \
 __license__   = "LGPL"
 
 import faulthandler
+
+class APIHelper:
+    """Helper class for plugin API interactions, including keybindings."""
+    
+    def __init__(self, app):
+        """Initialize the APIHelper.
+        
+        Arguments:
+        - app: the Cthulhu application
+        """
+        self.app = app
+        self._gestureBindings = {}
+        
+    def registerGestureByString(self, function, name, gestureString, 
+                                inputEventType='default', normalizer='cthulhu',
+                                learnModeEnabled=True, contextName=None):
+        """Register a gesture by string.
+        
+        Arguments:
+        - function: the function to call when the gesture is performed
+        - name: a human-readable name for this gesture
+        - gestureString: string representation of the gesture (e.g., 'kb:cthulhu+z')
+        - inputEventType: the type of input event
+        - normalizer: the normalizer to use
+        - learnModeEnabled: whether this should be available in learn mode
+        - contextName: the context for this gesture (e.g., plugin name)
+        
+        Returns the binding ID or None if registration failed
+        """
+        if not gestureString.startswith("kb:"):
+            return None
+            
+        # Extract the key portion from the gesture string
+        key = gestureString.split(":", 1)[1]
+        
+        # Handle Cthulhu modifier specially
+        if "cthulhu+" in key.lower():
+            from . import keybindings
+            key = key.lower().replace("cthulhu+", "")
+            
+            # Create a keybinding handler
+            class GestureHandler:
+                def __init__(self, function, description):
+                    self.function = function
+                    self.description = description
+                
+                def __call__(self, script, inputEvent):
+                    return self.function(script, inputEvent)
+            
+            handler = GestureHandler(function, name)
+            
+            # Register the binding with the active script
+            from . import cthulhu_state
+            if cthulhu_state.activeScript:
+                bindings = cthulhu_state.activeScript.getKeyBindings()
+                binding = keybindings.KeyBinding(
+                    key, 
+                    keybindings.defaultModifierMask,
+                    keybindings.CTHULHU_MODIFIER_MASK,
+                    handler)
+                bindings.add(binding)
+                
+                # Store binding for later reference
+                if contextName not in self._gestureBindings:
+                    self._gestureBindings[contextName] = []
+                self._gestureBindings[contextName].append(binding)
+                
+                return binding
+        
+        return None
+        
+    def unregisterShortcut(self, binding, contextName=None):
+        """Unregister a previously registered shortcut.
+        
+        Arguments:
+        - binding: the binding to unregister
+        - contextName: the context for this gesture
+        """
+        # Remove from script's keybindings
+        from . import cthulhu_state
+        if cthulhu_state.activeScript:
+            bindings = cthulhu_state.activeScript.getKeyBindings()
+            bindings.remove(binding)
+            
+        # Remove from our tracking
+        if contextName in self._gestureBindings:
+            if binding in self._gestureBindings[contextName]:
+                self._gestureBindings[contextName].remove(binding)
 import gi
 import importlib
 import os
@@ -74,7 +162,7 @@ from .ax_object import AXObject
 from .ax_utilities import AXUtilities
 from .input_event import BrailleEvent
 from . import cmdnames
-from . import plugin_system_manager
+from . import plugin_system_manager  # This will now be your pluggy-based implementation
 from . import guilabels
 from . import acss
 from . import text_attribute_names
@@ -920,7 +1008,6 @@ class Cthulhu(GObject.Object):
         GObject.Object.__init__(self)
         # add members
         self.resourceManager = resource_manager.ResourceManager(self)
-        self.APIHelper = plugin_system_manager.APIHelper(self)
         self.eventManager = _eventManager
         self.settingsManager = _settingsManager
         self.scriptManager = _scriptManager
@@ -928,6 +1015,7 @@ class Cthulhu(GObject.Object):
         self.dynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
         self.translationManager = translation_manager.TranslationManager(self)
         self.debugManager = debug
+        self.APIHelper = APIHelper(self)
         self.createCompatAPI()
         self.pluginSystemManager = plugin_system_manager.PluginSystemManager(self)
     def getAPIHelper(self):
@@ -987,6 +1075,7 @@ class Cthulhu(GObject.Object):
         # cthulhu lets say, special compat handling....
         self.getDynamicApiManager().registerAPI('EmitRegionChanged', emitRegionChanged)
         self.getDynamicApiManager().registerAPI('LoadUserSettings', loadUserSettings)
+        self.getDynamicApiManager().registerAPI('APIHelper', self.APIHelper)
 
 cthulhuApp = Cthulhu()
 
diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py
index 6ab45f7..ff8827e 100644
--- a/src/cthulhu/cthulhuVersion.py
+++ b/src/cthulhu/cthulhuVersion.py
@@ -23,5 +23,5 @@
 # Fork of Orca Screen Reader (GNOME)
 # Original source: https://gitlab.gnome.org/GNOME/orca
 
-version = "2024.12.22"
+version = "2025.04.03"
 codeName = "testing"
diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py
index 183d5a5..dd7c613 100644
--- a/src/cthulhu/cthulhu_gui_prefs.py
+++ b/src/cthulhu/cthulhu_gui_prefs.py
@@ -193,7 +193,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
         # ***** Key Bindings treeview initialization *****
 
         self.keyBindView = self.get_widget("keyBindingsTreeview")
-        
+
         if self.keyBindView.get_columns():
             for column in self.keyBindView.get_columns():
                 self.keyBindView.remove_column(column)
@@ -337,7 +337,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
         column.set_resizable(True)
         column.set_sort_column_id(EDITABLE)
         self.keyBindView.append_column(column)
-        
+
         # Populates the treeview with all the keybindings:
         #
         self._populateKeyBindings()
@@ -582,7 +582,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
             self.get_widget("rateScale").set_value(rate)
         else:
             self.get_widget("rateScale").set_value(50.0)
-            
+
         pitch = self._getPitchForVoiceType(voiceType)
         if pitch is not None:
             self.get_widget("pitchScale").set_value(pitch)
@@ -1150,7 +1150,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
         grid = self.get_widget('flashMessageDurationGrid')
         grid.set_sensitive(not checkbox.get_active())
         self.prefsDict["flashIsPersistent"] = checkbox.get_active()
-        
+
     def textAttributeSpokenToggled(self, cell, path, model):
         """The user has toggled the state of one of the text attribute
         checkboxes to be spoken. Update our model to reflect this, then
@@ -1596,7 +1596,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
         elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_YMD:
             indexdate = DATE_FORMAT_ABBREVIATED_YMD
         combobox2.set_active (indexdate)
-        
+
         combobox3 = self.get_widget("timeFormatCombo")
         self.populateComboBox(combobox3,
           [sdtime(messages.TIME_FORMAT_LOCALE, ltime()),
@@ -1757,7 +1757,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
                         prefs["enableEchoByWord"])
         self.get_widget("enableEchoBySentenceCheckButton").set_active( \
                         prefs["enableEchoBySentence"])
-        
+
         # Text attributes pane.
         #
         self._createTextAttributesTreeView()
@@ -1785,7 +1785,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
             self.get_widget("generalDesktopButton").set_active(True)
         else:
             self.get_widget("generalLaptopButton").set_active(True)
-        
+
         combobox = self.get_widget("sayAllStyle")
         self.populateComboBox(combobox, [guilabels.SAY_ALL_STYLE_LINE,
                                          guilabels.SAY_ALL_STYLE_SENTENCE])
@@ -2748,7 +2748,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
         elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_YMD:
             newFormat = messages.DATE_FORMAT_ABBREVIATED_YMD
         self.prefsDict["presentDateFormat"] = newFormat
-    
+
     def timeFormatChanged(self, widget):
         """Signal handler for the "changed" signal for the timeFormat
            GtkComboBox widget. Set the 'timeFormat' preference to the
diff --git a/src/cthulhu/dynamic_api_manager.py b/src/cthulhu/dynamic_api_manager.py
index fa5da8d..e377f75 100644
--- a/src/cthulhu/dynamic_api_manager.py
+++ b/src/cthulhu/dynamic_api_manager.py
@@ -66,7 +66,7 @@ class DynamicApiManager():
     def getAPI(self, key, application = '', fallback = True):
         # get dynamic API
         api = None
-        
+
         try:
             api = self.api[application][key]
             return api
@@ -83,5 +83,5 @@ class DynamicApiManager():
             api = self.api[application]['']
         except:
             print('API Key: "{}/{}" not found,'.format(application, key))
-        
+
         return api
diff --git a/src/cthulhu/laptop_keyboardmap.py b/src/cthulhu/laptop_keyboardmap.py
index 3aa3967..c4bcfda 100644
--- a/src/cthulhu/laptop_keyboardmap.py
+++ b/src/cthulhu/laptop_keyboardmap.py
@@ -44,7 +44,7 @@ CTHULHU_SHIFT_MODIFIER_MASK = keybindings.CTHULHU_SHIFT_MODIFIER_MASK
 CTHULHU_CTRL_MODIFIER_MASK = keybindings.CTHULHU_CTRL_MODIFIER_MASK
 
 keymap = (
-    
+
     ("9", defaultModifierMask, CTHULHU_MODIFIER_MASK,
     "routePointerToItemHandler"),
 
diff --git a/src/cthulhu/plugin.py b/src/cthulhu/plugin.py
index b5d127b..100e8f0 100644
--- a/src/cthulhu/plugin.py
+++ b/src/cthulhu/plugin.py
@@ -1,185 +1,89 @@
 #!/usr/bin/env python3
-#
 # Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
 #
 # This library is free software; you can redistribute it and/or
 # modify it under the terms of the GNU Lesser General Public
 # License as published by the Free Software Foundation; either
 # version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
 
-import os, inspect
-import gi
-from gi.repository import GObject
+"""Base class for Cthulhu plugins using pluggy."""
 
-class Plugin():
-    #__gtype_name__ = 'BasePlugin'
+import os
+import logging
 
-    def __init__(self, *args, **kwargs):
-        self.API = None
-        self.pluginInfo = None
-        self.moduleDir = ''
-        self.hidden = False
-        self.moduleName = ''
+# Import pluggy for hook specifications
+try:
+    import pluggy
+    cthulhu_hookimpl = pluggy.HookimplMarker("cthulhu")
+    PLUGGY_AVAILABLE = True
+except ImportError:
+    # Fallback if pluggy is not available
+    def cthulhu_hookimpl(func=None, **kwargs):
+        """Fallback decorator when pluggy is not available.
+        
+        This is a no-op decorator that returns the original function.
+        It allows the code to continue working without pluggy, though
+        plugins will be disabled.
+        """
+        if func is None:
+            return lambda f: f
+        return func
+    PLUGGY_AVAILABLE = False
+    logging.getLogger(__name__).info("Pluggy not available, plugins will be disabled")
+
+logger = logging.getLogger(__name__)
+
+class Plugin:
+    """Base class for Cthulhu plugins."""
+
+    def __init__(self):
+        """Initialize the plugin with default attributes."""
+        self.app = None
+        self.plugin_info = None
+        self.module_name = ''
         self.name = ''
         self.version = ''
-        self.website = ''
-        self.authors = []
-        self.buildIn = False
         self.description = ''
-        self.iconName = ''
-        self.copyright = ''
-        self.dependencies = False
-        self.helpUri = ''
-        self.dataDir = ''
-        self.translationContext = None
-    def setApp(self, app):
+
+    def set_app(self, app):
+        """Set the application reference."""
         self.app = app
-        self.dynamicApiManager = app.getDynamicApiManager()
-        self.signalManager = app.getSignalManager()
 
-    def getApp(self):
-        return self.app
-    def setPluginInfo(self, pluginInfo):
-        self.pluginInfo = pluginInfo
-        self.updatePluginInfoAttributes()
-    def getPluginInfo(self):
-        return self.pluginInfo
-    def updatePluginInfoAttributes(self):
-        self.moduleDir = ''
-        self.hidden = False
-        self.moduleName = ''
-        self.name = ''
-        self.version = ''
-        self.website = ''
-        self.authors = []
-        self.buildIn = False
-        self.description = ''
-        self.iconName = ''
-        self.copyright = ''
-        self.dependencies = False
-        self.helpUri = ''
-        self.dataDir = ''
-        pluginInfo = self.getPluginInfo()
-        if pluginInfo == None:
+    def set_plugin_info(self, plugin_info):
+        """Set plugin information and extract relevant attributes."""
+        self.plugin_info = plugin_info
+        if plugin_info:
+            self.module_name = getattr(plugin_info, 'module_name', '')
+            self.name = getattr(plugin_info, 'name', '')
+            self.version = getattr(plugin_info, 'version', '')
+            self.description = getattr(plugin_info, 'description', '')
+
+    @cthulhu_hookimpl
+    def activate(self, plugin=None):
+        """Activate the plugin. Override this in subclasses."""
+        if plugin is not None and plugin is not self:
             return
-        self.moduleName = self.getApp().getPluginSystemManager().getPluginModuleName(pluginInfo)
-        self.name = self.getApp().getPluginSystemManager().getPluginName(pluginInfo)
-        self.version = self.getApp().getPluginSystemManager().getPluginVersion(pluginInfo)
-        self.moduleDir = self.getApp().getPluginSystemManager().getPluginModuleDir(pluginInfo)
-        self.buildIn = self.getApp().getPluginSystemManager().isPluginBuildIn(pluginInfo)
-        self.description = self.getApp().getPluginSystemManager().getPluginDescription(pluginInfo)
-        self.hidden = self.getApp().getPluginSystemManager().isPluginHidden(pluginInfo)
-        self.website = self.getApp().getPluginSystemManager().getPluginWebsite(pluginInfo)
-        self.authors = self.getApp().getPluginSystemManager().getPluginAuthors(pluginInfo)
-        self.iconName = self.getApp().getPluginSystemManager().getPluginIconName(pluginInfo)
-        self.copyright = self.getApp().getPluginSystemManager().getPluginCopyright(pluginInfo)
-        self.dependencies = self.getApp().getPluginSystemManager().getPluginDependencies(pluginInfo)
+        logger.info(f"Activating plugin: {self.name}")
 
-        #settings = self.getApp().getPluginSystemManager().getPluginSettings(pluginInfo)
-        #hasDependencies = self.getApp().getPluginSystemManager().hasPluginDependency(pluginInfo)
+    @cthulhu_hookimpl
+    def deactivate(self, plugin=None):
+        """Deactivate the plugin. Override this in subclasses."""
+        if plugin is not None and plugin is not self:
+            return
+        logger.info(f"Deactivating plugin: {self.name}")
 
-        #externalData = self.getApp().getPluginSystemManager().getPluginExternalData(pluginInfo)
-        self.helpUri = self.getApp().getPluginSystemManager().getPlugingetHelpUri(pluginInfo)
-        self.dataDir = self.getApp().getPluginSystemManager().getPluginDataDir(pluginInfo)
-        self.updateTranslationContext()
-
-    def updateTranslationContext(self, domain = None, localeDir = None,  language = None, fallbackToCthulhuTranslation = True):
-        self.translationContext = None
-        useLocaleDir = '{}/locale/'.format(self.getModuleDir())
-        if localeDir:
-            if os.path.isdir(localeDir):
-                useLocaleDir = localeDir
-        useName = self.getModuleName()
-        useDomain = useName
-        if domain:
-            useDomain = domain
-        useLanguage = None
-        if language:
-            useLanguage = language
-        self.translationContext = self.getApp().getTranslationManager().initTranslation(useName, domain=useDomain, localeDir=useLocaleDir, language=useLanguage, fallbackToCthulhuTranslation=fallbackToCthulhuTranslation)
-        # Point _ to the translation object in the globals namespace of the caller frame
-        try:
-            callerFrame = inspect.currentframe().f_back
-            # Install our gettext and ngettext function to the upper frame
-            callerFrame.f_globals['_'] = self.translationContext.gettext
-            callerFrame.f_globals['ngettext'] = self.translationContext.ngettext
-        finally:
-            del callerFrame # Avoid reference problems with frames (per python docs)
-    def getTranslationContext(self):
-        return self.translationContext
-    def isPluginBuildIn(self):
-        return self.buildIn
-    def isPluginHidden(self):
-        return self.hidden
-    def getAuthors(self):
-        return self.authors
-    def getCopyright(self):
-        return self.copyright
-    def getDataDir(self):
-        return self.dataDir
-    def getDependencies(self):
-        return self.dependencies
-    def getDescription(self):
-        return self.description
-    def getgetHelpUri(self):
-        return self.helpUri
-    def getIconName(self):
-        return self.iconName
-    def getModuleDir(self):
-        return self.moduleDir
-    def getModuleName(self):
-        return self.moduleName
-    def getName(self):
-        return self.name
-    def getVersion(self):
-        return self.version
-    def getWebsite(self):
-        return self.website
-    def getSetting(key):
-        #self.getModuleName())
+    def registerGestureByString(self, function, name, gestureString, learnModeEnabled=True):
+        """Register a gesture by string."""
+        if self.app:
+            api_helper = self.app.getAPIHelper()
+            if api_helper:
+                return api_helper.registerGestureByString(
+                    function, 
+                    name, 
+                    gestureString, 
+                    'default', 
+                    'cthulhu', 
+                    learnModeEnabled, 
+                    contextName=self.module_name
+                )
         return None
-    def setSetting(key, value):
-        #self.getModuleName())
-        pass
-    def registerGestureByString(self, function, name, gestureString, learnModeEnabled = True):
-        keybinding = self.getApp().getAPIHelper().registerGestureByString(function, name, gestureString, 'default', 'cthulhu', learnModeEnabled, contextName = self.getModuleName())
-        return keybinding
-    def unregisterShortcut(self, function, name, gestureString, learnModeEnabled = True):
-        ok = self.getApp().getAPIHelper().unregisterShortcut(keybinding, contextName = self.getModuleName())
-        return ok
-    def registerSignal(self, signalName, signalFlag = GObject.SignalFlags.RUN_LAST, closure = GObject.TYPE_NONE, accumulator=()):
-        ok = self.signalManager.registerSignal(signalName, signalFlag, closure, accumulator, contextName = self.getModuleName())
-        return ok
-    def unregisterSignal(self, signalName):
-        # how to unregister?
-        pass
-
-    def connectSignal(self, signalName, function, param = None):
-        signalID = self.signalManager.connectSignal(signalName, function, param, contextName = self.getModuleName())
-        return signalID
-    def disconnectSignalByFunction(self, function):
-        # need get mapped function
-        mappedFunction = function
-        self.signalManager.disconnectSignalByFunction(mappedFunction, contextName = self.getModuleName())
-
-    def registerAPI(self, key, value, application = ''):
-        ok = self.dynamicApiManager.registerAPI(key, value, application = application, contextName = self.getModuleName())
-        return ok
-    def unregisterAPI(self, key, application = ''):
-        self.dynamicApiManager.unregisterAPI(key, application = application, contextName = self.getModuleName())
diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py
index 48af6da..8a7f13b 100644
--- a/src/cthulhu/plugin_system_manager.py
+++ b/src/cthulhu/plugin_system_manager.py
@@ -1,544 +1,343 @@
 #!/usr/bin/env python3
-#
 # Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
 #
 # This library is free software; you can redistribute it and/or
 # modify it under the terms of the GNU Lesser General Public
 # License as published by the Free Software Foundation; either
 # version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
 
-"""PluginManager for loading cthulhu plugins."""
-import os, inspect, sys, tarfile, shutil
+"""Plugin System Manager for Cthulhu using pluggy."""
 
+import os
+import inspect
+import importlib.util
+import logging
 from enum import IntEnum
 
-version = sys.version[:3] # we only need major.minor version.
-if version in ["3.3","3.4"]:
-    from importlib.machinery import SourceFileLoader
-else: # Python 3.5+, no support for python < 3.3.
-    import importlib.util
+# Import pluggy if available
+try:
+    import pluggy
+    PLUGGY_AVAILABLE = True
+except ImportError:
+    PLUGGY_AVAILABLE = False
+    logging.getLogger(__name__).info("Pluggy not available, plugins will be disabled")
 
-import gi
-gi.require_version('Peas', '1.0')
-from gi.repository import GObject
-from gi.repository import Peas
-
-gi.require_version('Atspi', '2.0') 
-from gi.repository import Atspi
-
-from cthulhu import resource_manager
-
-class API(GObject.GObject):
-    """Interface that gives access to all the objects of Cthulhu."""
-    def __init__(self, app):
-        GObject.GObject.__init__(self)
-        self.app = app
+logger = logging.getLogger(__name__)
 
 class PluginType(IntEnum):
-    """Types of plugins we support, depending on their directory location."""
-    # pylint: disable=comparison-with-callable,inconsistent-return-statements,no-else-return
-    # SYSTEM: provides system wide plugins
+    """Types of plugins we support."""
     SYSTEM = 1
-    # USER: provides per user plugin
     USER = 2
 
-    def __str__(self):
-        if self.value == PluginType.SYSTEM:
-            return _("System plugin")
-        elif self.value == PluginType.USER:
-            return _("User plugin")
-
     def get_root_dir(self):
         """Returns the directory where this type of plugins can be found."""
         if self.value == PluginType.SYSTEM:
-            return os.path.dirname(os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe())))) + '/plugins'
+            current_file = inspect.getfile(inspect.currentframe())
+            current_dir = os.path.dirname(os.path.realpath(os.path.abspath(current_file)))
+            return os.path.join(current_dir, 'plugins')
         elif self.value == PluginType.USER:
-            return os.path.expanduser('~') + '/.local/share/cthulhu/plugins'
+            return os.path.expanduser('~/.local/share/cthulhu/plugins')
 
 
-class PluginSystemManager():
-    """Cthulhu Plugin Manager to handle a set of plugins.
-    Attributes:
-        DEFAULT_LOADERS (tuple): Default loaders used by the plugin manager. For
-            possible values see
-            https://developer.gnome.org/libpeas/stable/PeasEngine.html#peas-engine-enable-loader
-    """
-    DEFAULT_LOADERS = ("python3", )
+class PluginInfo:
+    """Information about a plugin."""
+
+    def __init__(self, name, module_name, module_dir, metadata=None):
+        self.name = name
+        self.module_name = module_name
+        self.module_dir = module_dir
+        self.metadata = metadata or {}
+        self.builtin = False
+        self.hidden = False
+        self.module = None
+        self.instance = None
+        self.loaded = False
+
+    def get_module_name(self):
+        return self.module_name
+
+    def get_name(self):
+        return self.metadata.get('name', self.name)
+
+    def get_version(self):
+        return self.metadata.get('version', '0.0.0')
+
+    def get_description(self):
+        return self.metadata.get('description', '')
+
+    def get_module_dir(self):
+        return self.module_dir
+
+
+class PluginSystemManager:
+    """Cthulhu Plugin Manager using pluggy."""
 
     def __init__(self, app):
         self.app = app
-        self.engine = Peas.Engine.get_default()
 
-        for loader in self.DEFAULT_LOADERS:
-            self.engine.enable_loader(loader)
+        # Initialize plugin manager
+        if PLUGGY_AVAILABLE:
+            self.plugin_manager = pluggy.PluginManager("cthulhu")
+
+            # Define hook specifications
+            hook_spec = pluggy.HookspecMarker("cthulhu")
+
+            class CthulhuHookSpecs:
+                @hook_spec
+                def activate(self, plugin=None):
+                    """Called when the plugin is activated."""
+                    pass
+
+                @hook_spec
+                def deactivate(self, plugin=None):
+                    """Called when the plugin is deactivated."""
+                    pass
+
+            self.plugin_manager.add_hookspecs(CthulhuHookSpecs)
+        else:
+            self.plugin_manager = None
+
+        # Plugin storage
+        self._plugins = {}  # module_name -> PluginInfo
+        self._active_plugins = []
+
+        # Create plugin directories
+        self._setup_plugin_dirs()
+
+    def _setup_plugin_dirs(self):
+        """Ensure plugin directories exist."""
+        os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True)
+        os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True)
 
-        self._setupPluginsDir()
-        self._setupExtensionSet()
-        
-        if self.app:
-            self.gsettingsManager = self.app.getSettingsManager()
-        # settings else:
-        # settings     self.gsettingsManager = gsettings_manager.getSettingsManager(self.app)
-        self._activePlugins = []
-        self._ignorePluginModulePath = []
     @property
     def plugins(self):
-        """Gets the engine's plugin list."""
-        return self.engine.get_plugin_list()
-    @classmethod
-    def getPluginType(cls, pluginInfo):
-        """Gets the PluginType for the specified Peas.PluginInfo."""
-        paths = [pluginInfo.get_data_dir(), PluginType.SYSTEM.get_root_dir()]
-        if os.path.commonprefix(paths) == PluginType.SYSTEM.get_root_dir():
-            return PluginType.SYSTEM
-        return PluginType.USER
+        """Get all available plugins."""
+        return list(self._plugins.values())
 
-    def getExtension(self, pluginInfo):
-        if not pluginInfo:
-            return None
-
-        return self.extension_set.get_extension(pluginInfo)
-    def rescanPlugins(self):
-        self.engine.garbage_collect()
-        self.engine.rescan_plugins()
     def getApp(self):
         return self.app
-    def getPluginInfoByName(self, pluginName, pluginType=PluginType.USER):
-        """Gets the plugin info for the specified plugin name.
-        Args:
-            pluginName (str): The name from the .plugin file of the module.
-        Returns:
-            Peas.PluginInfo: The plugin info if it exists. Otherwise, `None`.
-        """
-        for pluginInfo in self.plugins:
-            if pluginInfo.get_module_name() == pluginName and PluginSystemManager.getPluginType(pluginInfo) == pluginType:
-                return pluginInfo
-        return None
+
+    def rescanPlugins(self):
+        """Scan for plugins in the plugin directories."""
+        old_plugins = self._plugins.copy()
+        self._plugins = {}
+
+        # Scan system and user plugins
+        self._scan_plugins_in_directory(PluginType.SYSTEM.get_root_dir())
+        self._scan_plugins_in_directory(PluginType.USER.get_root_dir())
+
+        # Preserve state for already loaded plugins
+        for name, old_info in old_plugins.items():
+            if name in self._plugins and old_info.loaded:
+                self._plugins[name].loaded = True
+                self._plugins[name].instance = old_info.instance
+                self._plugins[name].module = old_info.module
+
+    def _scan_plugins_in_directory(self, directory):
+        """Scan for plugins in a directory."""
+        if not os.path.exists(directory) or not os.path.isdir(directory):
+            return
+
+        for item in os.listdir(directory):
+            plugin_dir = os.path.join(directory, item)
+            if not os.path.isdir(plugin_dir):
+                continue
+
+            plugin_file = os.path.join(plugin_dir, "plugin.py")
+            metadata_file = os.path.join(plugin_dir, "plugin.info")
+
+            if os.path.isfile(plugin_file):
+                # Extract plugin info
+                module_name = os.path.basename(plugin_dir)
+                metadata = self._load_plugin_metadata(metadata_file)
+
+                plugin_info = PluginInfo(
+                    metadata.get('name', module_name),
+                    module_name,
+                    plugin_dir,
+                    metadata
+                )
+
+                # Check if it's a built-in or hidden plugin
+                plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true'
+                plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true'
+
+                self._plugins[module_name] = plugin_info
+
+    def _load_plugin_metadata(self, metadata_file):
+        """Load plugin metadata from a file."""
+        metadata = {}
+
+        if os.path.isfile(metadata_file):
+            try:
+                with open(metadata_file, 'r') as f:
+                    for line in f:
+                        line = line.strip()
+                        if not line or line.startswith('#'):
+                            continue
+
+                        if '=' in line:
+                            key, value = line.split('=', 1)
+                            metadata[key.strip()] = value.strip()
+            except Exception as e:
+                logger.error(f"Error loading plugin metadata: {e}")
+
+        return metadata
+
     def getActivePlugins(self):
-        return self._activePlugins
+        """Get the list of active plugin names."""
+        return self._active_plugins
+
     def setActivePlugins(self, activePlugins):
-        self._activePlugins = activePlugins
+        """Set active plugins and sync their state."""
+        self._active_plugins = activePlugins
         self.syncAllPluginsActive()
-    def isPluginBuildIn(self, pluginInfo):
-        return pluginInfo.is_builtin()
-    def isPluginHidden(self, pluginInfo):
-        return pluginInfo.is_hidden()
-    def getPluginAuthors(self, pluginInfo):
-        return pluginInfo.get_authors()
-    def getPluginCopyright(self, pluginInfo):
-        return pluginInfo.get_copyright()
-    def getPluginDataDir(self, pluginInfo):
-        return pluginInfo.get_data_dir()
-    def getPluginDependencies(self, pluginInfo):
-        return pluginInfo.get_dependencies()
-    def getPluginDescription(self, pluginInfo):
-        return pluginInfo.get_description()
-    def getPlugingetHelpUri(self, pluginInfo):
-        return pluginInfo.get_help_uri()
-    def getPluginIconName(self, pluginInfo):
-        return pluginInfo.get_icon_name()
-    def getPluginModuleDir(self, pluginInfo):
-        return pluginInfo.get_module_dir()
-    def getPluginModuleName(self, pluginInfo):
-        return pluginInfo.get_module_name()
-    def getPluginName(self, pluginInfo):
-        return pluginInfo.get_name()
-    def getPluginSettings(self, pluginInfo):
-        return pluginInfo.get_settings()
-    def getPluginVersion(self, pluginInfo):
-        return pluginInfo.get_version()
-    def getPluginWebsite(self, pluginInfo):
-        return pluginInfo.get_website()
-    # has_dependency and get_external_data seems broken-> takes exactly 2 arguments (1 given) but documentation doesnt say any parameter
-    #def hasPluginDependency(self, pluginInfo):
-    #    return pluginInfo.has_dependency()
-    #def getPluginExternalData(self, pluginInfo):
-    #    return pluginInfo.get_external_data()
-    def isPluginAvailable(self, pluginInfo):
-        try:
-            return pluginInfo.is_available()
-        except:
-            return False
-    def isPluginLoaded(self, pluginInfo):
-        try:
-            return pluginInfo.is_loaded()
-        except:
-            return False
-    
-    def getIgnoredPlugins(self):
-        return self._ignorePluginModulePath
-    def setIgnoredPlugins(self, pluginModulePath, ignored):
-        if pluginModulePath.endswith('/'):
-            pluginModulePath = pluginModulePath[:-1]
-        if ignored:
-            if not pluginModulePath in self.getIgnoredPlugins():
-                self._ignorePluginModulePath.append(pluginModulePath)
-        else:
-            if pluginModulePath  in self.getIgnoredPlugins():
-                self._ignorePluginModulePath.remove(pluginModulePath)
 
     def setPluginActive(self, pluginInfo, active):
-        if self.isPluginBuildIn(pluginInfo):
+        """Set the active state of a plugin."""
+        if pluginInfo.builtin:
             active = True
-        pluginName = self.getPluginModuleName(pluginInfo)
-        if active:
-            if not pluginName  in self.getActivePlugins():
-                if self.loadPlugin(pluginInfo):
-                    self._activePlugins.append(pluginName )
-        else:
-            if pluginName  in self.getActivePlugins():
-                if self.unloadPlugin(pluginInfo):
-                    self._activePlugins.remove(pluginName )
-    def isPluginActive(self, pluginInfo):
-        if self.isPluginBuildIn(pluginInfo):
-            return True
-        if self.isPluginLoaded(pluginInfo):
-            return True
-        active_plugin_names = self.getActivePlugins()
-        return self.getPluginModuleName(pluginInfo) in active_plugin_names
-    def syncAllPluginsActive(self, ForceAllPlugins=False):
-        self.unloadAllPlugins(ForceAllPlugins)
-        self.loadAllPlugins(ForceAllPlugins)
 
-    def loadAllPlugins(self, ForceAllPlugins=False):
-        """Loads plugins from settings."""
+        pluginName = pluginInfo.get_module_name()
+
+        if active:
+            if pluginName not in self.getActivePlugins():
+                if self.loadPlugin(pluginInfo):
+                    self._active_plugins.append(pluginName)
+        else:
+            if pluginName in self.getActivePlugins():
+                if self.unloadPlugin(pluginInfo):
+                    self._active_plugins.remove(pluginName)
+
+    def isPluginActive(self, pluginInfo):
+        """Check if a plugin is active."""
+        if pluginInfo.builtin:
+            return True
+
+        if pluginInfo.loaded:
+            return True
+
+        return pluginInfo.get_module_name() in self.getActivePlugins()
+
+    def syncAllPluginsActive(self):
+        """Sync the active state of all plugins."""
+        # First unload inactive plugins
         for pluginInfo in self.plugins:
-            if self.isPluginActive(pluginInfo) or ForceAllPlugins:
+            if not self.isPluginActive(pluginInfo) and pluginInfo.loaded:
+                self.unloadPlugin(pluginInfo)
+
+        # Then load active plugins
+        for pluginInfo in self.plugins:
+            if self.isPluginActive(pluginInfo) and not pluginInfo.loaded:
                 self.loadPlugin(pluginInfo)
 
     def loadPlugin(self, pluginInfo):
-        resourceManager = self.getApp().getResourceManager()
-        moduleName = pluginInfo.get_module_name()
-        try:
-            if pluginInfo not in self.plugins:
-                print("Plugin missing: {}".format(moduleName))
-                return False
-            resourceManager.addResourceContext(moduleName)
-            self.engine.load_plugin(pluginInfo)
-        except Exception as e:
-            print('loadPlugin:',e)
+        """Load a plugin."""
+        # Skip if pluggy is not available
+        if not PLUGGY_AVAILABLE:
+            logger.info(f"Skipping plugin {pluginInfo.get_name()}: pluggy not available")
             return False
-        return True
 
-    def unloadAllPlugins(self, ForceAllPlugins=False):
-        """Loads plugins from settings."""
-        for pluginInfo in self.plugins:
-            if not self.isPluginActive(pluginInfo) or ForceAllPlugins:
-                self.unloadPlugin(pluginInfo)
+        module_name = pluginInfo.get_module_name()
+
+        try:
+            # Already loaded?
+            if pluginInfo.loaded:
+                return True
+
+            # Import plugin module
+            plugin_file = os.path.join(pluginInfo.get_module_dir(), "plugin.py")
+            spec = importlib.util.spec_from_file_location(module_name, plugin_file)
+            module = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(module)
+            pluginInfo.module = module
+
+            # Find Plugin class
+            plugin_class = None
+            for attr_name in dir(module):
+                attr = getattr(module, attr_name)
+                if (inspect.isclass(attr) and 
+                    attr.__module__ == module.__name__ and
+                    hasattr(attr, 'activate')):
+                    plugin_class = attr
+                    break
+
+            if not plugin_class:
+                logger.error(f"No plugin class found in {module_name}")
+                return False
+
+            # Create and initialize plugin instance
+            plugin_instance = plugin_class()
+            pluginInfo.instance = plugin_instance
+
+            # Ensure plugins have a reference to the app
+            plugin_instance.app = self.getApp()
+            
+            if hasattr(plugin_instance, 'set_app'):
+                plugin_instance.set_app(self.getApp())
+
+            if hasattr(plugin_instance, 'set_plugin_info'):
+                plugin_instance.set_plugin_info(pluginInfo)
+
+            # Register with pluggy and activate
+            self.plugin_manager.register(plugin_instance)
+            try:
+                self.plugin_manager.hook.activate(plugin=plugin_instance)
+            except Exception as e:
+                logger.error(f"Error activating plugin {module_name}: {e}")
+                return False
+
+            pluginInfo.loaded = True
+            logger.info(f"Loaded plugin: {module_name}")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to load plugin {module_name}: {e}")
+            return False
 
     def unloadPlugin(self, pluginInfo):
-        resourceManager = self.getApp().getResourceManager()
-        moduleName = pluginInfo.get_module_name()
-        try:
-            if pluginInfo not in self.plugins:
-                print("Plugin missing: {}".format(moduleName))
-                return False
-            if self.isPluginBuildIn(pluginInfo):
-                return False
-            self.engine.unload_plugin(pluginInfo)
-            self.getApp().getResourceManager().removeResourceContext(moduleName)
-            self.engine.garbage_collect()
-        except Exception as e:
-            print('unloadPlugin:',e)
+        """Unload a plugin."""
+        # Skip if pluggy is not available
+        if not PLUGGY_AVAILABLE:
             return False
-        return True
-    def installPlugin(self, pluginFilePath, pluginType=PluginType.USER):
-        if not self.isValidPluginFile(pluginFilePath):
-            return False
-        pluginFolder = pluginType.get_root_dir()
-        if not pluginFolder.endswith('/'):
-            pluginFolder += '/'
-        if not os.path.exists(pluginFolder):
-            os.mkdir(pluginFolder)
-        else:
-            if not os.path.isdir(pluginFolder):
-                return False
-        try:
-            with tarfile.open(pluginFilePath) as tar:
-                tar.extractall(path=pluginFolder)
-        except Exception as e:
-            print(e)
-        
-        pluginModulePath = self.getModuleDirByPluginFile(pluginFilePath)
-        if pluginModulePath != '':
-            pluginModulePath = pluginFolder + pluginModulePath
-            self.setIgnoredPlugins(pluginModulePath[:-1], False) # without ending /
-            print('install', pluginFilePath)
-            self.callPackageTriggers(pluginModulePath, 'onPostInstall')
-        self.rescanPlugins()
 
-        return True
-    def getModuleDirByPluginFile(self, pluginFilePath):
-        if not isinstance(pluginFilePath, str):
-            return ''
-        if pluginFilePath == '':
-            return ''
-        if not os.path.exists(pluginFilePath):
-            return ''
-        try:
-            with tarfile.open(pluginFilePath) as tar:
-                tarMembers = tar.getmembers()
-                for tarMember in tarMembers:
-                    if tarMember.isdir():
-                        return tarMember.name
-        except Exception as e:
-            print(e)
-        return ''
-    def isValidPluginFile(self, pluginFilePath):
-        if not isinstance(pluginFilePath, str):
+        if pluginInfo.builtin:
             return False
-        if pluginFilePath == '':
-            return False
-        if not os.path.exists(pluginFilePath):
-            return False
-        pluginFolder = ''
-        pluginFileExists = False
-        packageFileExists = False
-        try:
-            with tarfile.open(pluginFilePath) as tar:
-                tarMembers = tar.getmembers()
-                for tarMember in tarMembers:
-                    if tarMember.isdir():
-                        if pluginFolder == '':
-                            pluginFolder = tarMember.name
-                    if tarMember.isfile():
-                        if tarMember.name.endswith('.plugin'):
-                            pluginFileExists = True
-                        if tarMember.name.endswith('package.py'):
-                            pluginFileExists = True
-                    if not tarMember.name.startswith(pluginFolder):
-                        return False
-        except Exception as e:
-            print(e)
-            return False
-        return pluginFileExists
-    def uninstallPlugin(self, pluginInfo):
-        if self.isPluginBuildIn(pluginInfo):
-            return False
-        # do we want to allow removing system plugins?
-        if PluginSystemManager.getPluginType(pluginInfo) == PluginType.SYSTEM:
-            return False
-        pluginFolder = pluginInfo.get_data_dir()
-        if not pluginFolder.endswith('/'):
-            pluginFolder += '/'
-        if not os.path.isdir(pluginFolder):
-            return False
-        if self.isPluginActive(pluginInfo):
-            self.setPluginActive(pluginInfo, False)
-            SettingsManager = self.app.getSettingsManager()
-            # TODO SettingsManager.set_settings_value_list('active-plugins', self.getActivePlugins())
-        self.callPackageTriggers(pluginFolder, 'onPreUninstall')
-        
-        try:
-            shutil.rmtree(pluginFolder, ignore_errors=True)
-        except Exception as e:
-            print(e)
-            return False
-        self.setIgnoredPlugins(pluginFolder, True)
-        self.rescanPlugins()
 
-        return True
-    def callPackageTriggers(self, pluginPath, trigger):
-        if not os.path.exists(pluginPath):
+        module_name = pluginInfo.get_module_name()
+
+        try:
+            # Not loaded?
+            if not pluginInfo.loaded:
+                return True
+
+            # Deactivate plugin
+            plugin_instance = pluginInfo.instance
+            if plugin_instance:
+                try:
+                    self.plugin_manager.hook.deactivate(plugin=plugin_instance)
+                except Exception as e:
+                    logger.error(f"Error deactivating plugin {module_name}: {e}")
+
+                # Unregister from pluggy
+                self.plugin_manager.unregister(plugin_instance)
+
+            # Clean up
+            pluginInfo.instance = None
+            pluginInfo.loaded = False
+
+            logger.info(f"Unloaded plugin: {module_name}")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to unload plugin {module_name}: {e}")
+            return False
+
+    def unloadAllPlugins(self, ForceAllPlugins=False):
+        """Unload all plugins."""
+        if not PLUGGY_AVAILABLE:
             return
-        if not pluginPath.endswith('/'):
-            pluginPath += '/'
-        packageModulePath = pluginPath + 'package.py'
-        if not os.path.isfile(packageModulePath):
-            return
-        if not os.access(packageModulePath, os.R_OK):
-            return
-        package = self.getApp().getAPIHelper().importModule('package', packageModulePath)
 
-        if trigger == 'onPostInstall':
-            try:
-                package.onPostInstall(pluginPath, self.getApp())
-            except Exception as e:
-                print(e)
-        elif trigger == 'onPreUninstall':
-            try:
-                package.onPreUninstall(pluginPath, self.getApp())
-            except Exception as e:
-                print(e)
-
-    def _setupExtensionSet(self):
-        plugin_iface = API(self.getApp())
-        self.extension_set = Peas.ExtensionSet.new(self.engine,
-                                                   Peas.Activatable,
-                                                   ["object"],
-                                                   [plugin_iface])
-        self.extension_set.connect("extension-removed",
-                                   self.__extensionRemoved)
-        self.extension_set.connect("extension-added",
-                                   self.__extensionAdded)
-
-    def _setupPluginsDir(self):
-        system_plugins_dir = PluginType.SYSTEM.get_root_dir()
-        user_plugins_dir = PluginType.USER.get_root_dir()
-        if os.path.exists(user_plugins_dir):
-            self.engine.add_search_path(user_plugins_dir)
-        if os.path.exists(system_plugins_dir):
-            self.engine.add_search_path(system_plugins_dir)
-
-    def __extensionRemoved(self, unusedSet, pluginInfo, extension):
-        extension.deactivate()
-
-    def __extensionAdded(self, unusedSet, pluginInfo, extension):
-        extension.setApp(self.getApp())
-        extension.setPluginInfo(pluginInfo)
-        extension.activate()
-    def __loadedPlugins(self, engine, unusedSet):
-        """Handles the changing of the loaded plugin list."""
-        self.getApp().settings.ActivePlugins = engine.get_property("loaded-plugins")
-
-class APIHelper():
-    def __init__(self, app):
-        self.app = app
-        self.cthulhuKeyBindings = None
-
-    '''
-    _pluginAPIManager.seCthulhuAPI('Logger', _logger)
-    _pluginAPIManager.setCthulhuAPI('SettingsManager', _settingsManager)
-    _pluginAPIManager.setCthulhuAPI('ScriptManager', _scriptManager)
-    _pluginAPIManager.setCthulhuAPI('EventManager', _eventManager)
-    _pluginAPIManager.setCthulhuAPI('Speech', speech)
-    _pluginAPIManager.setCthulhuAPI('Sound', sound)
-    _pluginAPIManager.setCthulhuAPI('Braille', braille)
-    _pluginAPIManager.setCthulhuAPI('Debug', debug)
-    _pluginAPIManager.setCthulhuAPI('Messages', messages)
-    _pluginAPIManager.setCthulhuAPI('MouseReview', mouse_review)
-    _pluginAPIManager.setCthulhuAPI('NotificationMessages', notification_messages)
-    _pluginAPIManager.setCthulhuAPI('CthulhuState', cthulhu_state)
-    _pluginAPIManager.setCthulhuAPI('CthulhuPlatform', cthulhu_platform)
-    _pluginAPIManager.setCthulhuAPI('Settings', settings)
-    _pluginAPIManager.setCthulhuAPI('Keybindings', keybindings)
-    '''
-    def outputMessage(self, Message, interrupt=False):
-        settings = self.app.getDynamicApiManager().getAPI('Settings')
-        braille = self.app.getDynamicApiManager().getAPI('Braille')
-        speech = self.app.getDynamicApiManager().getAPI('Speech')
-        if speech != None:
-            if (settings.enableSpeech):
-                if interrupt:
-                    speech.cancel()
-                if Message != '':
-                    speech.speak(Message)
-        if braille != None:
-            if (settings.enableBraille):
-                braille.displayMessage(Message)
-    def createInputEventHandler(self, function, name, learnModeEnabled=True):
-        EventManager = self.app.getDynamicApiManager().getAPI('EventManager')
-        newInputEventHandler = EventManager.input_event.InputEventHandler(function, name, learnModeEnabled)
-        return newInputEventHandler
-    def registerGestureByString(self, function, name, gestureString, profile, application, learnModeEnabled = True, contextName = None):
-        gestureList = gestureString.split(',')
-        registeredGestures = []
-        for gesture in gestureList:
-            if gesture.startswith('kb:'):
-                shortcutString = gesture[3:]
-                registuredGesture = self.registerShortcutByString(function, name, shortcutString, profile, application, learnModeEnabled, contextName=contextName)
-                if registuredGesture:
-                    registeredGestures.append(registuredGesture)
-        return registeredGestures
-    def registerShortcutByString(self, function, name, shortcutString, profile, application, learnModeEnabled = True, contextName = None):
-        keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
-        settings = self.app.getDynamicApiManager().getAPI('Settings')
-        resourceManager = self.app.getResourceManager()
-
-        clickCount = 0
-        cthulhuKey = False
-        shiftKey = False
-        ctrlKey = False
-        altKey = False
-        key = ''
-        shortcutList = shortcutString.split('+')
-        for shortcutElement in shortcutList:
-            shortcutElementLower = shortcutElement.lower()
-            if shortcutElementLower == 'press':
-                clickCount += 1
-            elif shortcutElement == 'cthulhu':
-                cthulhuKey = True
-            elif shortcutElementLower == 'shift':
-                shiftKey = True
-            elif shortcutElementLower == 'control':
-                ctrlKey = True
-            elif shortcutElementLower == 'alt':
-                altKey = True
-            else:
-                key = shortcutElementLower
-        if clickCount == 0:
-            clickCount = 1
-        
-        if self.cthulhuKeyBindings == None:
-            self.cthulhuKeyBindings = keybindings.KeyBindings()
-
-        tryFunction = resource_manager.TryFunction(function)
-        newInputEventHandler = self.createInputEventHandler(tryFunction.runInputEvent, name, learnModeEnabled)
-
-        currModifierMask = keybindings.NO_MODIFIER_MASK
-        if cthulhuKey:
-            currModifierMask = currModifierMask | 1 << keybindings.MODIFIER_CTHULHU
-        if shiftKey:
-            currModifierMask = currModifierMask | 1 << Atspi.ModifierType.SHIFT
-        if altKey:
-            currModifierMask = currModifierMask | 1 << Atspi.ModifierType.ALT
-        if ctrlKey:
-            currModifierMask = currModifierMask | 1 << Atspi.ModifierType.CONTROL
-
-        newKeyBinding = keybindings.KeyBinding(key, keybindings.defaultModifierMask, currModifierMask, newInputEventHandler, clickCount)
-        self.cthulhuKeyBindings.add(newKeyBinding)
-
-        settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
-        
-        if contextName:
-            resourceContext = resourceManager.getResourceContext(contextName)
-            if resourceContext:
-                resourceEntry = resource_manager.ResourceEntry('keyboard', newKeyBinding, function, tryFunction, shortcutString)
-                resourceContext.addGesture(profile, application, newKeyBinding, resourceEntry)
-
-        return newKeyBinding
-
-    def unregisterShortcut(self, KeyBindingToRemove, contextName = None):
-        ok = False
-        keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
-        settings = self.app.getDynamicApiManager().getAPI('Settings')
-        resourceManager = self.app.getResourceManager()
-
-        if self.cthulhuKeyBindings == None:
-            self.cthulhuKeyBindings = keybindings.KeyBindings()
-        try:
-            self.cthulhuKeyBindings.remove(KeyBindingToRemove)
-            settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
-            ok = True
-        except KeyError:
-            pass
-        if contextName:
-            resourceContext = resourceManager.getResourceContext(contextName)
-            if resourceContext:
-                resourceContext.removeGesture(KeyBindingToRemove)
-
-        return ok
-    def importModule(self, moduleName, moduleLocation):
-        if version in ["3.3","3.4"]:
-            return SourceFileLoader(moduleName, moduleLocation).load_module()
-        else:
-            spec = importlib.util.spec_from_file_location(moduleName, moduleLocation)
-            driver_mod = importlib.util.module_from_spec(spec)
-            spec.loader.exec_module(driver_mod)
-            return driver_mod
+        for pluginInfo in self.plugins:
+            if ForceAllPlugins or pluginInfo.loaded:
+                self.unloadPlugin(pluginInfo)
diff --git a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin b/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin
deleted file mode 100644
index 6c63a12..0000000
--- a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=ByeCthulhu
-Loader=python3
-Name=Stop announcement for cthulhu
-Description=Test plugin for cthulhu
-Authors=Chrys chrys@linux-a11y.org
diff --git a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py b/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py
deleted file mode 100644
index b82ec18..0000000
--- a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation; either
-# version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
-
-from cthulhu import plugin
-
-import gi
-gi.require_version('Peas', '1.0')
-from gi.repository import GObject
-from gi.repository import Peas
-import time
-
-
-class ByeCthulhu(GObject.Object, Peas.Activatable, plugin.Plugin):
-    #__gtype_name__ = 'ByeCthulhu'
-
-    object = GObject.Property(type=GObject.Object)
-    def __init__(self):
-        plugin.Plugin.__init__(self)
-    def do_activate(self):
-        API = self.object
-        self.connectSignal("stop-application-completed", self.process)
-    def do_deactivate(self):
-        API = self.object
-    def do_update_state(self):
-        API = self.object
-    def process(self, app):
-        messages = app.getDynamicApiManager().getAPI('Messages')
-        activeScript = app.getDynamicApiManager().getAPI('CthulhuState').activeScript
-        activeScript.presentationInterrupt()
-        activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)
diff --git a/src/cthulhu/plugins/ByeCthulhu/Makefile.am b/src/cthulhu/plugins/ByeCthulhu/Makefile.am
index 38d3489..2fbd68b 100644
--- a/src/cthulhu/plugins/ByeCthulhu/Makefile.am
+++ b/src/cthulhu/plugins/ByeCthulhu/Makefile.am
@@ -1,7 +1,7 @@
 cthulhu_python_PYTHON = \
 	__init__.py \
-	ByeCthulhu.plugin \
-	ByeCthulhu.py
+	plugin.info \
+	plugin.py
 
 cthulhu_pythondir=$(pkgpythondir)/plugins/ByeCthulhu
 
diff --git a/src/cthulhu/plugins/ByeCthulhu/plugin.info b/src/cthulhu/plugins/ByeCthulhu/plugin.info
new file mode 100644
index 0000000..dc61076
--- /dev/null
+++ b/src/cthulhu/plugins/ByeCthulhu/plugin.info
@@ -0,0 +1,8 @@
+name = Bye Cthulhu
+version = 1.0.0
+description = Says goodbye when Cthulhu is shutting down
+authors = Stormux 
+website = https://stormux.org
+copyright = Copyright 2025
+builtin = false
+hidden = false
\ No newline at end of file
diff --git a/src/cthulhu/plugins/ByeCthulhu/plugin.py b/src/cthulhu/plugins/ByeCthulhu/plugin.py
new file mode 100644
index 0000000..4720e87
--- /dev/null
+++ b/src/cthulhu/plugins/ByeCthulhu/plugin.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2025 Stormux
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+
+"""Bye Cthulhu plugin for Cthulhu."""
+
+import logging
+import time
+from cthulhu.plugin import Plugin, cthulhu_hookimpl
+
+logger = logging.getLogger(__name__)
+
+class ByeCthulhu(Plugin):
+    """Plugin that speaks a goodbye message when Cthulhu is shutting down."""
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the plugin."""
+        super().__init__(*args, **kwargs)
+        logger.info("ByeCthulhu plugin initialized")
+        self._signal_handler_id = None
+
+    @cthulhu_hookimpl
+    def activate(self, plugin=None):
+        """Activate the plugin."""
+        # Skip if this activation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+
+        logger.info("Activating ByeCthulhu plugin")
+        try:
+            # Connect to the stop-application-completed signal
+            signal_manager = self.app.getSignalManager()
+            self._signal_handler_id = signal_manager.connectSignal(
+                "stop-application-completed", 
+                self.process
+            )
+        except Exception as e:
+            logger.error(f"Error activating ByeCthulhu plugin: {e}")
+
+    @cthulhu_hookimpl
+    def deactivate(self, plugin=None):
+        """Deactivate the plugin."""
+        # Skip if this deactivation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+
+        logger.info("Deactivating ByeCthulhu plugin")
+        try:
+            # Disconnect signal if we have an ID
+            if self._signal_handler_id is not None:
+                signal_manager = self.app.getSignalManager()
+                signal_manager.disconnectSignal(
+                    "stop-application-completed",
+                    self._signal_handler_id
+                )
+                self._signal_handler_id = None
+        except Exception as e:
+            logger.error(f"Error deactivating ByeCthulhu plugin: {e}")
+
+    def process(self, app):
+        """Process the stop-application-completed signal."""
+        try:
+            messages = app.getDynamicApiManager().getAPI('Messages')
+            state = app.getDynamicApiManager().getAPI('CthulhuState')
+            if state.activeScript:
+                state.activeScript.presentationInterrupt()
+                state.activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)
+        except Exception as e:
+            logger.error(f"Error in ByeCthulhu process: {e}")
\ No newline at end of file
diff --git a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin b/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin
deleted file mode 100644
index 4529805..0000000
--- a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=DisplayVersion
-Loader=python3
-Name=Display Version
-Description=Announce the current version of Cthulhu
-Authors=Storm Dragon 
diff --git a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py b/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py
deleted file mode 100644
index 406dff2..0000000
--- a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation; either
-# version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
-
-from cthulhu import plugin
-
-import gi
-gi.require_version('Peas', '1.0')
-from gi.repository import GObject
-from gi.repository import Peas
-from cthulhu import cthulhuVersion
-
-class DisplayVersion(GObject.Object, Peas.Activatable, plugin.Plugin):
-    __gtype_name__ = 'displayversion'
-
-    object = GObject.Property(type=GObject.Object)
-    
-    def __init__(self):
-        plugin.Plugin.__init__(self)
-        self._api = None
-    
-    def do_activate(self):
-        self._api = self.object
-        self.registerGestureByString(
-            self.speakText, 
-            _(f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}'), 
-            'kb:cthulhu+shift+v'
-        )
-    
-    def do_deactivate(self):
-        self._api = None
-    
-    def speakText(self, script=None, inputEvent=None):
-        if not self._api:
-            return False
-        self._api.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(
-            f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
-            resetStyles=False
-        )
-        return True
diff --git a/src/cthulhu/plugins/DisplayVersion/Makefile.am b/src/cthulhu/plugins/DisplayVersion/Makefile.am
index 0cf43a4..57097d4 100644
--- a/src/cthulhu/plugins/DisplayVersion/Makefile.am
+++ b/src/cthulhu/plugins/DisplayVersion/Makefile.am
@@ -1,7 +1,7 @@
 cthulhu_python_PYTHON = \
 	__init__.py \
-	DisplayVersion.plugin \
-	DisplayVersion.py
+	plugin.info \
+	plugin.py
 
 cthulhu_pythondir=$(pkgpythondir)/plugins/DisplayVersion
 
diff --git a/src/cthulhu/plugins/DisplayVersion/plugin.info b/src/cthulhu/plugins/DisplayVersion/plugin.info
new file mode 100644
index 0000000..1ed4e67
--- /dev/null
+++ b/src/cthulhu/plugins/DisplayVersion/plugin.info
@@ -0,0 +1,8 @@
+name = Display Version
+version = 1.0.0
+description = Announces the Cthulhu version with Cthulhu+Shift+V
+authors = Stormux 
+website = https://stormux.org
+copyright = Copyright 2025
+builtin = false
+hidden = false
\ No newline at end of file
diff --git a/src/cthulhu/plugins/DisplayVersion/plugin.py b/src/cthulhu/plugins/DisplayVersion/plugin.py
new file mode 100644
index 0000000..9f7017b
--- /dev/null
+++ b/src/cthulhu/plugins/DisplayVersion/plugin.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2025 Stormux
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+
+"""Display Version plugin for Cthulhu."""
+
+import logging
+from cthulhu.plugin import Plugin, cthulhu_hookimpl
+from cthulhu import cthulhuVersion
+
+logger = logging.getLogger(__name__)
+
+class DisplayVersion(Plugin):
+    """Plugin that announces the current Cthulhu version."""
+    
+    def __init__(self, *args, **kwargs):
+        """Initialize the plugin."""
+        super().__init__(*args, **kwargs)
+        logger.info("DisplayVersion plugin initialized")
+        self._kb_binding = None
+    
+    @cthulhu_hookimpl
+    def activate(self, plugin=None):
+        """Activate the plugin."""
+        # Skip if this activation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+            
+        try:
+            logger.info("Activating DisplayVersion plugin")
+            
+            # Register keyboard shortcut
+            self._kb_binding = self.registerGestureByString(
+                self.speakText, 
+                f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}', 
+                'kb:cthulhu+shift+v'
+            )
+        except Exception as e:
+            logger.error(f"Error activating DisplayVersion plugin: {e}")
+    
+    @cthulhu_hookimpl
+    def deactivate(self, plugin=None):
+        """Deactivate the plugin."""
+        # Skip if this deactivation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+            
+        logger.info("Deactivating DisplayVersion plugin")
+    
+    def speakText(self, script=None, inputEvent=None):
+        """Speak the Cthulhu version when shortcut is pressed."""
+        try:
+            if self.app:
+                state = self.app.getDynamicApiManager().getAPI('CthulhuState')
+                if state.activeScript:
+                    state.activeScript.presentMessage(
+                        f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
+                        resetStyles=False
+                    )
+            return True
+        except Exception as e:
+            logger.error(f"Error in DisplayVersion speakText: {e}")
+            return False
\ No newline at end of file
diff --git a/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin b/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin
deleted file mode 100644
index 949d8b8..0000000
--- a/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=HelloWorld
-Loader=python3
-Name=Hello World (python3)
-Description=Test plugin for cthulhu
-Authors=Chrys chrys@linux-a11y.org
diff --git a/src/cthulhu/plugins/HelloWorld/HelloWorld.py b/src/cthulhu/plugins/HelloWorld/HelloWorld.py
deleted file mode 100644
index 6a2cc57..0000000
--- a/src/cthulhu/plugins/HelloWorld/HelloWorld.py
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation; either
-# version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
-
-from cthulhu import plugin
-
-import gi
-gi.require_version('Peas', '1.0')
-from gi.repository import GObject
-from gi.repository import Peas
-
-class HelloWorld(GObject.Object, Peas.Activatable, plugin.Plugin):
-    __gtype_name__ = 'helloworld'
-
-    object = GObject.Property(type=GObject.Object)
-    def __init__(self):
-        plugin.Plugin.__init__(self)
-    def do_activate(self):
-        API = self.object
-        self.registerGestureByString(self.speakTest, _('hello world'), 'kb:cthulhu+z')
-        print('activate hello world plugin')
-    def do_deactivate(self):
-        API = self.object
-        print('deactivate hello world plugin')
-    def speakTest(self, script=None, inputEvent=None):
-        API = self.object
-        API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage('hello world', resetStyles=False)
-        return True
diff --git a/src/cthulhu/plugins/HelloWorld/Makefile.am b/src/cthulhu/plugins/HelloWorld/Makefile.am
deleted file mode 100644
index 8793036..0000000
--- a/src/cthulhu/plugins/HelloWorld/Makefile.am
+++ /dev/null
@@ -1,7 +0,0 @@
-cthulhu_python_PYTHON = \
-	__init__.py \
-	HelloWorld.plugin \
-	HelloWorld.py
-
-cthulhu_pythondir=$(pkgpythondir)/plugins/HelloWorld
-
diff --git a/src/cthulhu/plugins/Makefile.am b/src/cthulhu/plugins/Makefile.am
index fc36845..89e438a 100644
--- a/src/cthulhu/plugins/Makefile.am
+++ b/src/cthulhu/plugins/Makefile.am
@@ -1,4 +1,4 @@
-SUBDIRS = Clipboard DisplayVersion HelloWorld SelfVoice Time MouseReview Date ByeCthulhu HelloCthulhu PluginManager CapsLockHack SimplePluginSystem
+SUBDIRS = Clipboard DisplayVersion hello_world self_voice Time MouseReview Date ByeCthulhu HelloCthulhu PluginManager CapsLockHack SimplePluginSystem
 
 cthulhu_pythondir=$(pkgpythondir)/plugins
 
diff --git a/src/cthulhu/plugins/SelfVoice/Makefile.am b/src/cthulhu/plugins/SelfVoice/Makefile.am
deleted file mode 100644
index 2acc355..0000000
--- a/src/cthulhu/plugins/SelfVoice/Makefile.am
+++ /dev/null
@@ -1,7 +0,0 @@
-cthulhu_python_PYTHON = \
-	__init__.py \
-	SelfVoice.plugin \
-	SelfVoice.py
-
-cthulhu_pythondir=$(pkgpythondir)/plugins/SelfVoice
-
diff --git a/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin b/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin
deleted file mode 100644
index 8cf18d2..0000000
--- a/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=SelfVoice
-Loader=python3
-Name=Self Voice Plugin
-Description=use cthulhu text / braile from using unix sockets
-Authors=Chrys chrys@linux-a11y.org
diff --git a/src/cthulhu/plugins/SelfVoice/SelfVoice.py b/src/cthulhu/plugins/SelfVoice/SelfVoice.py
deleted file mode 100644
index cc656ab..0000000
--- a/src/cthulhu/plugins/SelfVoice/SelfVoice.py
+++ /dev/null
@@ -1,135 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (c) 2024 Stormux
-# Copyright (c) 2010-2012 The Orca Team
-# Copyright (c) 2012 Igalia, S.L.
-# Copyright (c) 2005-2010 Sun Microsystems Inc.
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation; either
-# version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the
-# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
-# Boston MA  02110-1301 USA.
-#
-# Fork of Orca Screen Reader (GNOME)
-# Original source: https://gitlab.gnome.org/GNOME/orca
-
-from cthulhu import plugin
-
-import gi
-gi.require_version('Peas', '1.0')
-from gi.repository import GObject
-from gi.repository import Peas
-import select, socket, os, os.path
-from threading import Thread, Lock
-
-APPEND_CODE = '<#APPEND#>'
-PERSISTENT_CODE = '<#PERSISTENT#>'
-
-class SelfVoice(GObject.Object, Peas.Activatable, plugin.Plugin):
-    __gtype_name__ = 'SelfVoice'
-
-    object = GObject.Property(type=GObject.Object)
-    def __init__(self):
-        plugin.Plugin.__init__(self)
-        self.lock = Lock()
-        self.active = False
-        self.voiceThread = Thread(target=self.voiceWorker)
-    def do_activate(self):
-        API = self.object
-        self.activateWorker()
-    def do_deactivate(self):
-        API = self.object
-        self.deactivateWorker()
-    def do_update_state(self):
-        API = self.object
-    def deactivateWorker(self):
-        with self.lock:
-            self.active = False
-        self.voiceThread.join()
-    def activateWorker(self):
-        with self.lock:
-            self.active = True
-        self.voiceThread.start()
-    def isActive(self):
-        with self.lock:
-            return self.active
-    def outputMessage(self,  Message):
-        # Prepare
-        API = self.object
-        append = Message.startswith(APPEND_CODE)
-        if append:
-            Message = Message[len(APPEND_CODE):]
-        if Message.endswith(PERSISTENT_CODE):
-            Message = Message[:len(Message)-len(PERSISTENT_CODE)]
-            API.app.getAPIHelper().outputMessage(Message, not append)
-        else:
-            script_manager = API.app.getDynamicApiManager().getAPI('ScriptManager')
-            scriptManager = script_manager.getManager()
-            scriptManager.getDefaultScript().presentMessage(Message, resetStyles=False)
-        return
-        try:
-            settings = API.app.getDynamicApiManager().getAPI('Settings')
-            braille = API.app.getDynamicApiManager().getAPI('Braille')
-            speech = API.app.getDynamicApiManager().getAPI('Speech')
-            # Speak
-            if speech != None:
-                if (settings.enableSpeech):
-                    if not append:
-                        speech.cancel()
-                    if Message != '':
-                        speech.speak(Message)
-            # Braille
-            if braille != None:
-                if (settings.enableBraille):
-                    braille.displayMessage(Message)
-        except e as Exception:
-            print(e)
-
-    def voiceWorker(self):
-        socketFile = '/tmp/cthulhu.sock'
-        # for testing purposes
-        #socketFile = '/tmp/cthulhu-plugin.sock'
-
-        if os.path.exists(socketFile):
-            os.unlink(socketFile)
-        cthulhuSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-        cthulhuSock.bind(socketFile)
-        os.chmod(socketFile, 0o222)
-        cthulhuSock.listen(1)
-        while self.isActive():
-            # Check if the client is still connected and if data is available:
-            try:
-                r, _, _ = select.select([cthulhuSock], [], [], 0.8)
-            except select.error:
-                break
-            if r == []:
-                continue
-            if cthulhuSock in r:
-                client_sock, client_addr = cthulhuSock.accept()
-            try:
-                rawdata = client_sock.recv(8129)
-                data = rawdata.decode("utf-8").rstrip().lstrip()
-                self.outputMessage(data)
-            except:
-                pass
-            try:
-                client_sock.close()
-            except:
-                pass
-        if cthulhuSock:
-            cthulhuSock.close()
-            cthulhuSock = None
-        if os.path.exists(socketFile):
-            os.unlink(socketFile)
-
-
diff --git a/src/cthulhu/plugins/hello_world/Makefile.am b/src/cthulhu/plugins/hello_world/Makefile.am
new file mode 100644
index 0000000..95853eb
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/Makefile.am
@@ -0,0 +1,6 @@
+cthulhu_python_PYTHON = \
+	__init__.py \
+	plugin.info \
+	plugin.py
+
+cthulhu_pythondir=$(pkgpythondir)/plugins/hello_world
diff --git a/src/cthulhu/plugins/hello_world/README.md b/src/cthulhu/plugins/hello_world/README.md
new file mode 100644
index 0000000..2bd1a89
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/README.md
@@ -0,0 +1,33 @@
+# Hello World Plugin for Cthulhu
+
+This is a simple test plugin for the Cthulhu screen reader that demonstrates how to use the pluggy-based plugin system.
+
+## Features
+
+- Registers a keyboard shortcut (Cthulhu+z) that speaks "hello world" when pressed
+- Demonstrates how to use the Plugin base class
+- Shows how to use cthulhu_hookimpl for hook implementation
+
+## Implementation Details
+
+The plugin uses the following key features:
+- `pluggy` for hook specification and implementation
+- The `Plugin` base class from cthulhu.plugin
+- The `cthulhu_hookimpl` decorator to mark functions as plugin hooks
+- The `registerGestureByString` method to register keyboard shortcuts
+
+## Structure
+
+- `plugin.py`: The main plugin implementation
+- `plugin.info`: Metadata about the plugin
+
+## Requirements
+
+Requires the pluggy package to be installed:
+```
+pip install pluggy
+```
+
+## Usage
+
+The plugin will be automatically loaded when Cthulhu starts if it's listed in the activePlugins setting.
\ No newline at end of file
diff --git a/src/cthulhu/plugins/HelloWorld/__init__.py b/src/cthulhu/plugins/hello_world/__init__.py
similarity index 100%
rename from src/cthulhu/plugins/HelloWorld/__init__.py
rename to src/cthulhu/plugins/hello_world/__init__.py
diff --git a/src/cthulhu/plugins/hello_world/plugin.info b/src/cthulhu/plugins/hello_world/plugin.info
new file mode 100644
index 0000000..50e52cc
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/plugin.info
@@ -0,0 +1,8 @@
+name = Hello World
+version = 1.0.0
+description = Test plugin for Cthulhu
+authors = Storm Dragon storm_dragon@stormux.org
+website = https://stormux.org
+copyright = Copyright 2025
+builtin = false
+hidden = false
diff --git a/src/cthulhu/plugins/hello_world/plugin.py b/src/cthulhu/plugins/hello_world/plugin.py
new file mode 100644
index 0000000..874861c
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/plugin.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2025 Stormux
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+
+"""Hello World plugin for Cthulhu."""
+
+import logging
+from cthulhu.plugin import Plugin, cthulhu_hookimpl
+
+logger = logging.getLogger(__name__)
+
+class HelloWorld(Plugin):
+    """Hello World plugin."""
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the plugin."""
+        super().__init__(*args, **kwargs)
+        logger.info("HelloWorld plugin initialized")
+
+    @cthulhu_hookimpl
+    def activate(self, plugin=None):
+        """Activate the plugin."""
+        # Skip if this activation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+
+        try:
+            logger.info("Activating Hello World plugin")
+
+            # Register our keyboard shortcut
+            self.registerGestureByString(
+                self.speakTest, 
+                "hello world", 
+                "kb:cthulhu+z",
+                learnModeEnabled=True
+            )
+        except Exception as e:
+            logger.error(f"Error activating Hello World plugin: {e}")
+
+    @cthulhu_hookimpl
+    def deactivate(self, plugin=None):
+        """Deactivate the plugin."""
+        # Skip if this deactivation call isn't for us
+        if plugin is not None and plugin is not self:
+            return
+
+        try:
+            logger.info("Deactivating Hello World plugin")
+        except Exception as e:
+            logger.error(f"Error deactivating Hello World plugin: {e}")
+
+    def speakTest(self, script=None, inputEvent=None):
+        """Speak a test message."""
+        try:
+            if self.app:
+                self.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(
+                    'hello world', 
+                    resetStyles=False
+                )
+
+            return True
+        except Exception as e:
+            logger.error(f"Error in speakTest: {e}")
+            return False
diff --git a/src/cthulhu/plugins/self_voice/Makefile.am b/src/cthulhu/plugins/self_voice/Makefile.am
new file mode 100644
index 0000000..cafee1e
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/Makefile.am
@@ -0,0 +1,7 @@
+cthulhu_python_PYTHON = \
+	__init__.py \
+	plugin.info \
+	plugin.py
+
+cthulhu_pythondir=$(pkgpythondir)/plugins/self_voice
+
diff --git a/src/cthulhu/plugins/SelfVoice/__init__.py b/src/cthulhu/plugins/self_voice/__init__.py
similarity index 100%
rename from src/cthulhu/plugins/SelfVoice/__init__.py
rename to src/cthulhu/plugins/self_voice/__init__.py
diff --git a/src/cthulhu/plugins/self_voice/plugin.info b/src/cthulhu/plugins/self_voice/plugin.info
new file mode 100644
index 0000000..9ebe67d
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/plugin.info
@@ -0,0 +1,12 @@
+[Plugin]
+name=Self Voice
+module_name=self_voice
+version=1.0.0
+description=Use Cthulhu speech and braille from external applications via Unix sockets
+authors=Stormux
+copyright=Copyright (c) 2024 Stormux
+website=https://stormux.org
+icon_name=audio-speakers
+builtin=false
+hidden=false
+help_uri=https://stormux.org
diff --git a/src/cthulhu/plugins/self_voice/plugin.py b/src/cthulhu/plugins/self_voice/plugin.py
new file mode 100644
index 0000000..324f15b
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/plugin.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2024 Stormux
+# Copyright (c) 2010-2012 The Orca Team
+# Copyright (c) 2012 Igalia, S.L.
+# Copyright (c) 2005-2010 Sun Microsystems Inc.
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the
+# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
+# Boston MA  02110-1301 USA.
+#
+# Fork of Orca Screen Reader (GNOME)
+# Original source: https://gitlab.gnome.org/GNOME/orca
+
+"""Self Voice plugin for Cthulhu screen reader."""
+
+import os
+import socket
+import select
+import logging
+import threading
+from threading import Thread, Lock
+from cthulhu.plugin import Plugin, cthulhu_hookimpl
+
+logger = logging.getLogger(__name__)
+
+# Special codes for message handling
+APPEND_CODE = '<#APPEND#>'
+PERSISTENT_CODE = '<#PERSISTENT#>'
+
+class SelfVoice(Plugin):
+    """Plugin that provides a socket interface for external applications to send text to Cthulhu."""
+    
+    def __init__(self):
+        """Initialize the plugin."""
+        super().__init__()
+        self.lock = Lock()
+        self.active = False
+        self.voiceThread = Thread(target=self.voiceWorker)
+        self.voiceThread.daemon = True  # Make thread exit when main thread exits
+    
+    @cthulhu_hookimpl
+    def activate(self):
+        """Activate the self-voice plugin."""
+        super().activate()
+        logger.info("Activating Self Voice Plugin")
+        self.activateWorker()
+    
+    @cthulhu_hookimpl
+    def deactivate(self):
+        """Deactivate the self-voice plugin."""
+        logger.info("Deactivating Self Voice Plugin")
+        self.deactivateWorker()
+        super().deactivate()
+    
+    def activateWorker(self):
+        """Start the voice worker thread."""
+        with self.lock:
+            self.active = True
+        
+        # Only start if not already running
+        if not self.voiceThread.is_alive():
+            self.voiceThread = Thread(target=self.voiceWorker)
+            self.voiceThread.daemon = True
+            self.voiceThread.start()
+    
+    def deactivateWorker(self):
+        """Stop the voice worker thread."""
+        with self.lock:
+            self.active = False
+        
+        # Try to join the thread if it's alive, with a timeout
+        if self.voiceThread.is_alive():
+            try:
+                self.voiceThread.join(timeout=2.0)
+            except Exception as e:
+                logger.warning(f"Error stopping voice worker thread: {e}")
+    
+    def isActive(self):
+        """Check if the worker is active."""
+        with self.lock:
+            return self.active
+    
+    def outputMessage(self, message):
+        """Output a message through Cthulhu's speech and braille systems.
+        
+        Args:
+            message: The message to output. May include special codes.
+        """
+        # Process special codes
+        append = message.startswith(APPEND_CODE)
+        if append:
+            message = message[len(APPEND_CODE):]
+        
+        persistent = False
+        if message.endswith(PERSISTENT_CODE):
+            message = message[:len(message)-len(PERSISTENT_CODE)]
+            persistent = True
+        
+        # Output through appropriate channel
+        if persistent:
+            # Use the APIHelper for persistent messages
+            self.app.getAPIHelper().outputMessage(message, not append)
+        else:
+            # Use the script manager for standard messages
+            script_manager = self.app.getDynamicApiManager().getAPI('ScriptManager')
+            scriptManager = script_manager.getManager()
+            scriptManager.getDefaultScript().presentMessage(message, resetStyles=False)
+    
+    def voiceWorker(self):
+        """Worker thread that listens on a socket for messages to speak."""
+        socketFile = '/tmp/cthulhu.sock'
+        # For testing purposes
+        # socketFile = '/tmp/cthulhu-plugin.sock'
+        
+        # Clean up any existing socket file
+        if os.path.exists(socketFile):
+            try:
+                os.unlink(socketFile)
+            except Exception as e:
+                logger.error(f"Error removing existing socket file: {e}")
+                return
+        
+        try:
+            # Create and set up the socket
+            cthulhu_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+            cthulhu_sock.bind(socketFile)
+            os.chmod(socketFile, 0o222)  # Write-only for everyone
+            cthulhu_sock.listen(1)
+            
+            logger.info(f"Self Voice plugin listening on {socketFile}")
+            
+            # Main loop - listen for connections
+            while self.isActive():
+                # Check if data is available with a timeout
+                try:
+                    r, _, _ = select.select([cthulhu_sock], [], [], 0.8)
+                except select.error as e:
+                    logger.error(f"Select error: {e}")
+                    break
+                
+                if not r:  # No data available
+                    continue
+                
+                # Accept connection
+                if cthulhu_sock in r:
+                    try:
+                        client_sock, _ = cthulhu_sock.accept()
+                        client_sock.settimeout(0.5)  # Set a timeout for receiving data
+                        
+                        try:
+                            # Receive and process data
+                            raw_data = client_sock.recv(8192)
+                            if raw_data:
+                                data = raw_data.decode("utf-8").strip()
+                                if data:
+                                    self.outputMessage(data)
+                        except socket.timeout:
+                            pass
+                        except Exception as e:
+                            logger.error(f"Error receiving data: {e}")
+                        finally:
+                            client_sock.close()
+                    except Exception as e:
+                        logger.error(f"Error accepting connection: {e}")
+            
+        except Exception as e:
+            logger.error(f"Socket error: {e}")
+        finally:
+            # Clean up
+            if 'cthulhu_sock' in locals():
+                try:
+                    cthulhu_sock.close()
+                except Exception:
+                    pass
+            
+            if os.path.exists(socketFile):
+                try:
+                    os.unlink(socketFile)
+                except Exception as e:
+                    logger.error(f"Error removing socket file: {e}")
+            
+            logger.info("Self Voice plugin socket closed")
diff --git a/src/cthulhu/resource_manager.py b/src/cthulhu/resource_manager.py
index def0bd9..d06705f 100644
--- a/src/cthulhu/resource_manager.py
+++ b/src/cthulhu/resource_manager.py
@@ -113,7 +113,7 @@ class ResourceContext():
             self.settings[profile][application] = {}
         # add entry
         self.settings[profile][application][sub_setting_name] = entry
-        
+
         print('add', 'settings', self.getName(), profile, application, entry.getResourceText())
 
 
@@ -214,7 +214,7 @@ class ResourceContext():
             self.unregisterAllAPI()
         except Exception as e:
             print(e)
-        
+
     def unregisterAllAPI(self):
         dynamicApiManager = self.app.getDynamicApiManager()
         for application, value in self.getAPIs().copy().items():
@@ -226,7 +226,7 @@ class ResourceContext():
                 print('unregister api ', self.getName(), entry.getEntryType(), entry.getResourceText())
     def unregisterAllGestures(self):
         APIHelper = self.app.getAPIHelper()
-        
+
         for profile, profileValue in self.getGestures().copy().items():
             for application, applicationValue in profileValue.copy().items():
                 for gesture, entry in applicationValue.copy().items():
@@ -272,12 +272,12 @@ class ResourceManager():
     def removeResourceContext(self, contextName):
         if not contextName:
             return
-        
+
         try:
             self.resourceContextDict[contextName].unregisterAllResources()
         except:
             pass
-        
+
         # temp
         try:
             print('_________', 'summery', self.resourceContextDict[contextName].getName(), '_________')
@@ -302,7 +302,7 @@ class ResourceManager():
             return self.resourceContextDict[contextName]
         except KeyError:
             return None
-        
+
     def addAPI(self, application, api, contextName = None):
         if not contextName:
             return
diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py
index 1969cff..68708a7 100644
--- a/src/cthulhu/script_utilities.py
+++ b/src/cthulhu/script_utilities.py
@@ -3558,10 +3558,10 @@ class Utilities:
     @staticmethod
     def unicodeValueString(character):
         """ Returns a four hex digit representation of the given character
-        
+
         Arguments:
         - The character to return representation
-        
+
         Returns a string representaition of the given character unicode vlue
         """
 
diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py
index 5952280..1c05ff9 100644
--- a/src/cthulhu/settings.py
+++ b/src/cthulhu/settings.py
@@ -413,4 +413,4 @@ presentChatRoomLast = False
 presentLiveRegionFromInactiveTab = False
 
 # Plugins
-activePlugins = ['Clipboard', 'DisplayVersion', 'MouseReview', 'Date', 'ByeCthulhu', 'Time', 'HelloCthulhu', 'SelfVoice', 'PluginManager', 'SimplePluginSystem']
+activePlugins = ['Clipboard', 'DisplayVersion', 'MouseReview', 'Date', 'ByeCthulhu', 'Time', 'HelloCthulhu', 'hello_world', 'self_voice', 'PluginManager', 'SimplePluginSystem']
diff --git a/src/cthulhu/speechdispatcherfactory.py b/src/cthulhu/speechdispatcherfactory.py
index 9f9f7eb..efb6297 100644
--- a/src/cthulhu/speechdispatcherfactory.py
+++ b/src/cthulhu/speechdispatcherfactory.py
@@ -69,7 +69,7 @@ class SpeechServer(speechserver.SpeechServer):
     # See the parent class for documentation.
 
     _active_servers = {}
-    
+
     DEFAULT_SERVER_ID = 'default'
     _SERVER_NAMES = {DEFAULT_SERVER_ID: guilabels.DEFAULT_SYNTHESIZER}
 
@@ -93,7 +93,7 @@ class SpeechServer(speechserver.SpeechServer):
 
         Attempt to create the server if it doesn't exist yet.  Returns None
         when it is not possible to create the server.
-        
+
         """
         if serverId not in cls._active_servers:
             cls(serverId)
@@ -781,16 +781,16 @@ class SpeechServer(speechserver.SpeechServer):
     def reset(self, text=None, acss=None):
         self._client.close()
         self._init()
-        
+
     def list_output_modules(self):
         """Return names of available output modules as a tuple of strings.
 
         This method is not a part of Cthulhu speech API, but is used internally
         by the Speech Dispatcher backend.
-        
+
         The returned tuple can be empty if the information can not be
         obtained (e.g. with an older Speech Dispatcher version).
-        
+
         """
         try:
             return self._send_command(self._client.list_output_modules)