From 399741cb4beb0af1bb219f6124b773c629c8adaa Mon Sep 17 00:00:00 2001 From: Kowyo Date: Sat, 6 Jun 2026 16:12:23 +0800 Subject: [PATCH 1/4] feat: add plugin system with entry point discovery Add PluginManager and MiniAgentPlugin base class for loading external plugins via the mini_agent.plugins entry point group. Integrate plugin hooks into agent lifecycle (init, session start, turn complete, session end). Add /plugins command and --plugins CLI flag. Assisted-by: mini-agent:deepseek-v4-flash --- docs/plugins.md | 53 +++++++++++++++++++++++++ docs/quickstart.md | 1 + docs/usage.md | 1 + src/mini_agent/__init__.py | 3 +- src/mini_agent/cli/main.py | 41 ++++++++++++++++++++ src/mini_agent/plugin.py | 79 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 docs/plugins.md create mode 100644 src/mini_agent/plugin.py diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..dac3b0e --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,53 @@ +# Plugins + +mini-agent loads external plugins via the `mini_agent.plugins` [entry point](https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/) group. + +## Interface + +```python +from mini_agent import MiniAgentPlugin + +class MyPlugin(MiniAgentPlugin): + def on_session_start(self, session_id: str): ... +``` + +| Hook | When | +|------|------| +| `on_agent_init()` | Startup, before CLI loop | +| `on_session_start(session_id)` | New session, `/new`, `/resume` | +| `on_turn_complete(session_id, history, round_usages)` | After each assistant response saved | +| `on_session_end(session_id, history, round_usages)` | Interactive loop exits | + +## Creating a Plugin + +```toml +[project.entry-points."mini_agent.plugins"] +my-plugin = "my_plugin.plugin:create_plugin" +``` + +```python +# src/my_plugin/plugin.py +from mini_agent import MiniAgentPlugin + +class MyPlugin(MiniAgentPlugin): + def on_session_start(self, session_id: str) -> None: + print(f"Session started: {session_id}") + +def create_plugin(): + return MyPlugin() +``` + +```bash +pip install my-plugin +mini --plugins +``` + +## Secrets + +Plugins can store API keys in `~/.mini-agent/.env` (loaded automatically). Shell env vars take precedence. + +```bash +# ~/.mini-agent/.env +LANGFUSE_PUBLIC_KEY=pk-lf-... +LANGFUSE_SECRET_KEY=sk-lf-... +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 7b0cf37..c4760b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -49,3 +49,4 @@ Summarize this repository - [Usage](usage.md) - CLI flags, slash commands, and keyboard shortcuts. - [Providers](providers.md) - authentication and gateway setup. - [Config](config.md) - model and reasoning effort defaults. +- [Plugins](plugins.md) - extending mini-agent with external plugins. diff --git a/docs/usage.md b/docs/usage.md index 2ed4134..b3c1fe8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -47,6 +47,7 @@ mini /resume | `/new` | Start a new session | | `/resume` | Pick from previous sessions | | `/copy` | Copy last assistant message to clipboard | +| `/plugins` | List active plugins | | `/exit`, `q` | Quit | ## Keyboard Shortcuts diff --git a/src/mini_agent/__init__.py b/src/mini_agent/__init__.py index 485ca8e..4db276b 100644 --- a/src/mini_agent/__init__.py +++ b/src/mini_agent/__init__.py @@ -1,3 +1,4 @@ from .cli.main import main +from .plugin import MiniAgentPlugin, PluginManager -__all__ = ["main"] +__all__ = ["MiniAgentPlugin", "PluginManager", "main"] diff --git a/src/mini_agent/cli/main.py b/src/mini_agent/cli/main.py index e15ab8e..25379be 100644 --- a/src/mini_agent/cli/main.py +++ b/src/mini_agent/cli/main.py @@ -10,6 +10,7 @@ REASONING_EFFORT_LEVELS, config, ) +from ..plugin import PluginManager from .clipboard import copy_last_assistant_text from .display import ( ACCENT_COLOR, @@ -39,15 +40,18 @@ console = Console() session_manager = SessionManager() +plugin_manager = PluginManager.discover() def _run_non_interactive(prompt: str) -> None: history: list[MessageParam] = [{"role": "user", "content": prompt}] session_id = session_manager.new_id() + plugin_manager.on_session_start(session_id) history_len = len(history) agent_loop(history) if len(history) > history_len and token_tracker.get() is not None: session_manager.save(session_id, history, token_tracker.round_usages) + plugin_manager.on_turn_complete(session_id, history, token_tracker.round_usages) def _run_interactive(prompt: str | None = None, session_id: str | None = None) -> None: @@ -73,6 +77,8 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - except StopIteration: print("Session ID not found.\n") + plugin_manager.on_session_start(current_session_id) + while True: try: query = session.prompt(pre_run=pre_run) @@ -93,6 +99,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - if command == "/new": history.clear() current_session_id = session_manager.new_id() + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = 0 next_indicator[0] = 1 token_tracker.reset() @@ -104,6 +111,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - current_session_id, history, _ = prompt_resume( session_manager, current_session_id, history ) + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = count_images_in_history(history) next_indicator[0] = max_indicator_in_history(history) + 1 attached_images.clear() @@ -122,6 +130,15 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - print() attached_images.clear() continue + if command == "/plugins": + plugins = plugin_manager.list_plugins() + if plugins: + print(f"Active plugins: {', '.join(plugins)}") + else: + print("No plugins loaded.") + print() + attached_images.clear() + continue content = build_user_content(query, attached_images, sent_image_count) @@ -135,6 +152,13 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - session_manager.save( current_session_id, history, token_tracker.round_usages ) + plugin_manager.on_turn_complete( + current_session_id, history, token_tracker.round_usages + ) + + plugin_manager.on_session_end( + current_session_id, history, token_tracker.round_usages + ) if session_manager.exists(current_session_id): usage_report = format_usage_report(token_tracker.get()) @@ -184,6 +208,11 @@ def main() -> None: type=str, help="Resume a session by ID, or resume the most recent session if no ID provided", ) + parser.add_argument( + "--plugins", + action="store_true", + help="List available plugins and exit", + ) parser.add_argument( "prompt", nargs="?", @@ -192,6 +221,18 @@ def main() -> None: ) args = parser.parse_args() + plugin_manager.on_agent_init() + + if args.plugins: + plugins = plugin_manager.list_plugins() + if plugins: + print(f"Active plugins ({len(plugins)}):") + for name in plugins: + print(f" - {name}") + else: + print("No plugins loaded.") + return + if args.model: config.set_session_model(args.model) diff --git a/src/mini_agent/plugin.py b/src/mini_agent/plugin.py new file mode 100644 index 0000000..3f44d41 --- /dev/null +++ b/src/mini_agent/plugin.py @@ -0,0 +1,79 @@ +"""Plugin system for mini-agent. + +Plugins are discovered via the ``mini_agent.plugins`` entry point group. +""" + +import contextlib +import importlib.metadata +import warnings +from dataclasses import dataclass, field +from typing import Any + + +class MiniAgentPlugin: + """Override lifecycle methods as needed. All default to no-ops.""" + + def on_agent_init(self) -> None: ... + def on_session_start(self, session_id: str) -> None: ... + def on_turn_complete( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... + def on_session_end( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... + + +@dataclass +class PluginManager: + """Discovers plugins and dispatches lifecycle events.""" + + plugins: list[Any] = field(default_factory=list) + + @staticmethod + def discover() -> PluginManager: + plugins: list[Any] = [] + for ep in importlib.metadata.entry_points(group="mini_agent.plugins"): + try: + plugins.append(ep.load()()) + except Exception as exc: + warnings.warn(f"Failed to load plugin '{ep.name}': {exc}", stacklevel=2) + return PluginManager(plugins=plugins) + + def on_agent_init(self) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_agent_init() + + def on_session_start(self, session_id: str) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_session_start(session_id) + + def on_turn_complete( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_turn_complete(session_id, history, round_usages) + + def on_session_end( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_session_end(session_id, history, round_usages) + + def list_plugins(self) -> list[str]: + return [type(p).__name__ for p in self.plugins] From 38682636c21d04855c24d51aea0021b835847ae2 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Sat, 6 Jun 2026 16:54:54 +0800 Subject: [PATCH 2/4] feat(cli): add /plugins to tab completion and remove --plugins flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /plugins slash command already existed in the interactive loop but was missing from the tab-completion list. The --plugins CLI flag was redundant — plugins can be listed via /plugins inside a session. Assisted-by: mini-agent:deepseek-v4-flash --- docs/plugins.md | 1 - src/mini_agent/cli/display/completion.py | 1 + src/mini_agent/cli/main.py | 15 --------------- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index dac3b0e..e9026bf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -39,7 +39,6 @@ def create_plugin(): ```bash pip install my-plugin -mini --plugins ``` ## Secrets diff --git a/src/mini_agent/cli/display/completion.py b/src/mini_agent/cli/display/completion.py index c7e96ed..7bfcce1 100644 --- a/src/mini_agent/cli/display/completion.py +++ b/src/mini_agent/cli/display/completion.py @@ -17,6 +17,7 @@ "/model": "Select a model", "/copy": "Copy the last assistant response to clipboard", "/status": "Show current session configuration and token usage", + "/plugins": "List currently active plugins", "/exit": "exit the session", } diff --git a/src/mini_agent/cli/main.py b/src/mini_agent/cli/main.py index 25379be..d955b6c 100644 --- a/src/mini_agent/cli/main.py +++ b/src/mini_agent/cli/main.py @@ -208,11 +208,6 @@ def main() -> None: type=str, help="Resume a session by ID, or resume the most recent session if no ID provided", ) - parser.add_argument( - "--plugins", - action="store_true", - help="List available plugins and exit", - ) parser.add_argument( "prompt", nargs="?", @@ -223,16 +218,6 @@ def main() -> None: plugin_manager.on_agent_init() - if args.plugins: - plugins = plugin_manager.list_plugins() - if plugins: - print(f"Active plugins ({len(plugins)}):") - for name in plugins: - print(f" - {name}") - else: - print("No plugins loaded.") - return - if args.model: config.set_session_model(args.model) From 35f9c2eedc3f711329ea8061c2a0ebce0f4eb2e8 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Sun, 7 Jun 2026 10:22:53 +0800 Subject: [PATCH 3/4] fix(plugin): resolve circular import and mypyc inheritance issues - Replace module-level PluginManager.discover() call with a lazy initializer to prevent circular imports when external plugins import from mini_agent during entry point loading - Exclude plugin.py from mypyc compilation so MiniAgentPlugin remains an interpreted class that external plugins can inherit from Assisted-by: mini-agent:deepseek-v4-flash --- setup.py | 4 +++- src/mini_agent/cli/main.py | 28 ++++++++++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index be81c62..498c3ed 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,10 @@ sysconfig._CONFIG_VARS["MACOSX_DEPLOYMENT_TARGET"] = "11.0" +EXCLUDE = {"__init__.py", "plugin.py"} + modules = [ - str(f) for f in Path("src/mini_agent").rglob("*.py") if f.name != "__init__.py" + str(f) for f in Path("src/mini_agent").rglob("*.py") if f.name not in EXCLUDE ] setup( diff --git a/src/mini_agent/cli/main.py b/src/mini_agent/cli/main.py index d955b6c..29259da 100644 --- a/src/mini_agent/cli/main.py +++ b/src/mini_agent/cli/main.py @@ -40,18 +40,26 @@ console = Console() session_manager = SessionManager() -plugin_manager = PluginManager.discover() + + +def _plugin_manager() -> PluginManager: + """Lazily initialize and cache the plugin manager.""" + if not hasattr(_plugin_manager, "_instance"): + _plugin_manager._instance = PluginManager.discover() + return _plugin_manager._instance def _run_non_interactive(prompt: str) -> None: history: list[MessageParam] = [{"role": "user", "content": prompt}] session_id = session_manager.new_id() - plugin_manager.on_session_start(session_id) + _plugin_manager().on_session_start(session_id) history_len = len(history) agent_loop(history) if len(history) > history_len and token_tracker.get() is not None: session_manager.save(session_id, history, token_tracker.round_usages) - plugin_manager.on_turn_complete(session_id, history, token_tracker.round_usages) + _plugin_manager().on_turn_complete( + session_id, history, token_tracker.round_usages + ) def _run_interactive(prompt: str | None = None, session_id: str | None = None) -> None: @@ -77,7 +85,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - except StopIteration: print("Session ID not found.\n") - plugin_manager.on_session_start(current_session_id) + _plugin_manager().on_session_start(current_session_id) while True: try: @@ -99,7 +107,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - if command == "/new": history.clear() current_session_id = session_manager.new_id() - plugin_manager.on_session_start(current_session_id) + _plugin_manager().on_session_start(current_session_id) sent_image_count[0] = 0 next_indicator[0] = 1 token_tracker.reset() @@ -111,7 +119,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - current_session_id, history, _ = prompt_resume( session_manager, current_session_id, history ) - plugin_manager.on_session_start(current_session_id) + _plugin_manager().on_session_start(current_session_id) sent_image_count[0] = count_images_in_history(history) next_indicator[0] = max_indicator_in_history(history) + 1 attached_images.clear() @@ -131,7 +139,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - attached_images.clear() continue if command == "/plugins": - plugins = plugin_manager.list_plugins() + plugins = _plugin_manager().list_plugins() if plugins: print(f"Active plugins: {', '.join(plugins)}") else: @@ -152,11 +160,11 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - session_manager.save( current_session_id, history, token_tracker.round_usages ) - plugin_manager.on_turn_complete( + _plugin_manager().on_turn_complete( current_session_id, history, token_tracker.round_usages ) - plugin_manager.on_session_end( + _plugin_manager().on_session_end( current_session_id, history, token_tracker.round_usages ) @@ -216,7 +224,7 @@ def main() -> None: ) args = parser.parse_args() - plugin_manager.on_agent_init() + _plugin_manager().on_agent_init() if args.model: config.set_session_model(args.model) From d7ce20f8ed7ba4c4ef92a16dc57e5283aea55800 Mon Sep 17 00:00:00 2001 From: Kowyo Date: Sun, 7 Jun 2026 10:45:10 +0800 Subject: [PATCH 4/4] fix(plugin): stop re-exporting MiniAgentPlugin from mini-agent root Remove MiniAgentPlugin and PluginManager from mini-agent.__init__ to break the circular import chain: 1. __init__.py imports cli.main -> calls PluginManager.discover() 2. discover() loads langfuse plugin -> from mini_agent import MiniAgentPlugin 3. MiniAgentPlugin not yet in namespace (__init__ still executing) External plugins must now import from mini_agent.plugin subpackage: from mini_agent.plugin import MiniAgentPlugin Also update mini-agent-langfuse to use the new import path. --- setup.py | 8 +++-- src/mini_agent/__init__.py | 5 +-- src/mini_agent/cli/main.py | 33 ++++++++----------- src/mini_agent/plugin/__init__.py | 4 +++ src/mini_agent/plugin/base.py | 26 +++++++++++++++ .../{plugin.py => plugin/manager.py} | 24 +------------- 6 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 src/mini_agent/plugin/__init__.py create mode 100644 src/mini_agent/plugin/base.py rename src/mini_agent/{plugin.py => plugin/manager.py} (73%) diff --git a/setup.py b/setup.py index 498c3ed..9696813 100644 --- a/setup.py +++ b/setup.py @@ -9,10 +9,14 @@ sysconfig._CONFIG_VARS["MACOSX_DEPLOYMENT_TARGET"] = "11.0" -EXCLUDE = {"__init__.py", "plugin.py"} +EXCLUDED = {"__init__.py"} +EXCLUDED_PATHS = {"plugin/base.py"} modules = [ - str(f) for f in Path("src/mini_agent").rglob("*.py") if f.name not in EXCLUDE + str(f) + for f in Path("src/mini_agent").rglob("*.py") + if f.name not in EXCLUDED + and str(f.relative_to("src/mini_agent")) not in EXCLUDED_PATHS ] setup( diff --git a/src/mini_agent/__init__.py b/src/mini_agent/__init__.py index 4db276b..1991136 100644 --- a/src/mini_agent/__init__.py +++ b/src/mini_agent/__init__.py @@ -1,4 +1,5 @@ +"""Mini-agent: a minimal agent.""" + from .cli.main import main -from .plugin import MiniAgentPlugin, PluginManager -__all__ = ["MiniAgentPlugin", "PluginManager", "main"] +__all__ = ["main"] diff --git a/src/mini_agent/cli/main.py b/src/mini_agent/cli/main.py index 29259da..80326a3 100644 --- a/src/mini_agent/cli/main.py +++ b/src/mini_agent/cli/main.py @@ -40,29 +40,24 @@ console = Console() session_manager = SessionManager() - - -def _plugin_manager() -> PluginManager: - """Lazily initialize and cache the plugin manager.""" - if not hasattr(_plugin_manager, "_instance"): - _plugin_manager._instance = PluginManager.discover() - return _plugin_manager._instance +plugin_manager = PluginManager.discover() def _run_non_interactive(prompt: str) -> None: history: list[MessageParam] = [{"role": "user", "content": prompt}] session_id = session_manager.new_id() - _plugin_manager().on_session_start(session_id) + plugin_manager.on_session_start(session_id) history_len = len(history) agent_loop(history) if len(history) > history_len and token_tracker.get() is not None: session_manager.save(session_id, history, token_tracker.round_usages) - _plugin_manager().on_turn_complete( - session_id, history, token_tracker.round_usages - ) + plugin_manager.on_turn_complete(session_id, history, token_tracker.round_usages) -def _run_interactive(prompt: str | None = None, session_id: str | None = None) -> None: +def _run_interactive( + prompt: str | None = None, + session_id: str | None = None, +) -> None: print_welcome_banner() history: list[MessageParam] = [] current_session_id = session_manager.new_id() @@ -85,7 +80,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - except StopIteration: print("Session ID not found.\n") - _plugin_manager().on_session_start(current_session_id) + plugin_manager.on_session_start(current_session_id) while True: try: @@ -107,7 +102,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - if command == "/new": history.clear() current_session_id = session_manager.new_id() - _plugin_manager().on_session_start(current_session_id) + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = 0 next_indicator[0] = 1 token_tracker.reset() @@ -119,7 +114,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - current_session_id, history, _ = prompt_resume( session_manager, current_session_id, history ) - _plugin_manager().on_session_start(current_session_id) + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = count_images_in_history(history) next_indicator[0] = max_indicator_in_history(history) + 1 attached_images.clear() @@ -139,7 +134,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - attached_images.clear() continue if command == "/plugins": - plugins = _plugin_manager().list_plugins() + plugins = plugin_manager.list_plugins() if plugins: print(f"Active plugins: {', '.join(plugins)}") else: @@ -160,11 +155,11 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - session_manager.save( current_session_id, history, token_tracker.round_usages ) - _plugin_manager().on_turn_complete( + plugin_manager.on_turn_complete( current_session_id, history, token_tracker.round_usages ) - _plugin_manager().on_session_end( + plugin_manager.on_session_end( current_session_id, history, token_tracker.round_usages ) @@ -224,7 +219,7 @@ def main() -> None: ) args = parser.parse_args() - _plugin_manager().on_agent_init() + plugin_manager.on_agent_init() if args.model: config.set_session_model(args.model) diff --git a/src/mini_agent/plugin/__init__.py b/src/mini_agent/plugin/__init__.py new file mode 100644 index 0000000..3c0b8cf --- /dev/null +++ b/src/mini_agent/plugin/__init__.py @@ -0,0 +1,4 @@ +from .base import MiniAgentPlugin +from .manager import PluginManager + +__all__ = ["MiniAgentPlugin", "PluginManager"] diff --git a/src/mini_agent/plugin/base.py b/src/mini_agent/plugin/base.py new file mode 100644 index 0000000..9768ef6 --- /dev/null +++ b/src/mini_agent/plugin/base.py @@ -0,0 +1,26 @@ +"""Base class for mini-agent plugins. + +Zero internal dependencies — safe to import before the rest of +the mini-agent package is fully initialised. +""" + +from typing import Any + + +class MiniAgentPlugin: + """Override lifecycle methods as needed. All default to no-ops.""" + + def on_agent_init(self) -> None: ... + def on_session_start(self, session_id: str) -> None: ... + def on_turn_complete( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... + def on_session_end( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... diff --git a/src/mini_agent/plugin.py b/src/mini_agent/plugin/manager.py similarity index 73% rename from src/mini_agent/plugin.py rename to src/mini_agent/plugin/manager.py index 3f44d41..5e2f8a2 100644 --- a/src/mini_agent/plugin.py +++ b/src/mini_agent/plugin/manager.py @@ -1,7 +1,4 @@ -"""Plugin system for mini-agent. - -Plugins are discovered via the ``mini_agent.plugins`` entry point group. -""" +"""Plugin discovery and lifecycle dispatch.""" import contextlib import importlib.metadata @@ -10,25 +7,6 @@ from typing import Any -class MiniAgentPlugin: - """Override lifecycle methods as needed. All default to no-ops.""" - - def on_agent_init(self) -> None: ... - def on_session_start(self, session_id: str) -> None: ... - def on_turn_complete( - self, - session_id: str, - history: list[dict[str, Any]], - round_usages: list[Any] | None, - ) -> None: ... - def on_session_end( - self, - session_id: str, - history: list[dict[str, Any]], - round_usages: list[Any] | None, - ) -> None: ... - - @dataclass class PluginManager: """Discovers plugins and dispatches lifecycle events."""