feat(plugins): Add extensibility and hooks system to plugin API

Add @extension.extensible decorators to all plugin API handler methods and core plugin functions to enable extension points. Implement plugin hooks system allowing plugins to define custom behavior via hooks.py file. Add call_plugin_hook function to execute plugin-specific hooks for events like uninstall, save_plugin_config, and get_plugin_config. Introduce uninstall_plugin function that calls uninstall hook before deletion. Move circular

frdel committed Mar 12, 2026 at 13:21 UTC a48ac95a298ac133e41c82d305c83ccae1d94d59
15 files changed +338 -78
AGENTS.md
+3
@@ -128,6 +128,9 @@ Key Files:
128 - Location: Always develop new plugins in usr/plugins/.
129 - Manifest: Every plugin requires a plugin.yaml with name, description, version, and optionally settings_sections, per_project_config, per_agent_config, and always_enabled.
130 - Discovery: Conventions based on folder names (api/, tools/, webui/, extensions/).
131 +- Runtime hooks: Plugins may also expose hooks in hooks.py, callable by the framework through helpers.plugins.call_plugin_hook(...).
132 +- Hook runtime: hooks.py executes inside the Agent Zero framework Python environment, so sys.executable -m pip installs dependencies into that same framework runtime.
133 +- Environment targeting: If a plugin needs packages or binaries for the separate agent execution runtime or system environment, it must explicitly switch environments in a subprocess by targeting the correct interpreter, virtualenv, or package manager.
134 - Settings: Use get_plugin_config(plugin_name, agent=agent) to retrieve settings. Plugins can expose a UI for settings via webui/config.html. Plugin settings modals instantiate a local context from $store.pluginSettingsPrototype; bind plugin fields to config.* and use context.* for modal-level state and actions. For plugins wrapping core settings, set context.saveMode = 'core' in x-init.
135 - Activation: Global and scoped activation rules are stored as .toggle-1 (ON) and .toggle-0 (OFF). Scoped rules are handled via the plugin "Switch" modal.
136
api/plugins.py
+13 -2
@@ -5,7 +5,7 @@ import sys
5 from datetime import datetime, timezone
6
7 from helpers.api import ApiHandler, Request, Response
8 -from helpers import plugins, files
8 +from helpers import plugins, files, extension
9
10
11 class Plugins(ApiHandler):
@@ -53,6 +53,7 @@ class Plugins(ApiHandler):
53
54 return Response(status=400, response=f"Unknown action: {action}")
55
56 + @extension.extensible
57 def _get_config(self, input: dict) -> dict | Response:
58 plugin_name = input.get("plugin_name", "")
59 project_name = input.get("project_name", "")
@@ -90,6 +91,7 @@ class Plugins(ApiHandler):
91 "data": settings,
92 }
93
94 + @extension.extensible
95 def _get_toggle_status(self, input: dict) -> dict | Response:
96 plugin_name = input.get("plugin_name", "")
97 project_name = input.get("project_name", "")
@@ -140,6 +142,7 @@ class Plugins(ApiHandler):
142 "loaded_path": "",
143 }
144
145 + @extension.extensible
146 def _list_configs(self, input: dict) -> dict | Response:
147 plugin_name = input.get("plugin_name", "")
148 asset_type = input.get("asset_type", "config")
@@ -160,6 +163,7 @@ class Plugins(ApiHandler):
163
164 return {"ok": True, "data": configs}
165
166 + @extension.extensible
167 def _delete_config(self, input: dict) -> dict | Response:
168 plugin_name = input.get("plugin_name", "")
169 path = input.get("path", "")
@@ -196,12 +200,13 @@ class Plugins(ApiHandler):
200
201 return {"ok": True}
202
203 + @extension.extensible
204 def _delete_plugin(self, input: dict) -> dict | Response:
205 plugin_name = input.get("plugin_name", "")
206 if not plugin_name:
207 return Response(status=400, response="Missing plugin_name")
208 try:
204 - plugins.delete_plugin(plugin_name)
209 + plugins.uninstall_plugin(plugin_name)
210 except FileNotFoundError as e:
211 return Response(status=404, response=str(e))
212 except ValueError as e:
@@ -210,6 +215,7 @@ class Plugins(ApiHandler):
215 return Response(status=500, response=f"Failed to delete plugin: {str(e)}")
216 return {"ok": True}
217
218 + @extension.extensible
219 def _get_default_config(self, input: dict) -> dict | Response:
220 plugin_name = input.get("plugin_name", "")
221 if not plugin_name:
@@ -217,6 +223,7 @@ class Plugins(ApiHandler):
223 settings = plugins.get_default_plugin_config(plugin_name)
224 return {"ok": True, "data": settings or {}}
225
226 + @extension.extensible
227 def _save_config(self, input: dict) -> dict | Response:
228 plugin_name = input.get("plugin_name", "")
229 project_name = input.get("project_name", "")
@@ -229,6 +236,7 @@ class Plugins(ApiHandler):
236 plugins.save_plugin_config(plugin_name, project_name, agent_profile, settings)
237 return {"ok": True}
238
239 + @extension.extensible
240 def _toggle_plugin(self, input: dict) -> dict | Response:
241 plugin_name = input.get("plugin_name", "")
242 enabled = input.get("enabled")
@@ -246,6 +254,7 @@ class Plugins(ApiHandler):
254 )
255 return {"ok": True}
256
257 + @extension.extensible
258 def _get_doc(self, input: dict) -> dict | Response:
259 plugin_name = input.get("plugin_name", "")
260 doc = input.get("doc", "")
@@ -265,6 +274,7 @@ class Plugins(ApiHandler):
274
275 return {"ok": True, "content": files.read_file(file_path), "filename": filename}
276
277 + @extension.extensible
278 def _run_init_script(self, input: dict) -> dict | Response:
279 plugin_name = input.get("plugin_name", "")
280 if not plugin_name:
@@ -311,6 +321,7 @@ class Plugins(ApiHandler):
321 "executed_at": executed_at,
322 }
323
324 + @extension.extensible
325 def _get_init_exec(self, input: dict) -> dict | Response:
326 plugin_name = input.get("plugin_name", "")
327 if not plugin_name:
docs/agents/AGENTS.plugins.md
+31
@@ -25,6 +25,7 @@ Each plugin lives in usr/plugins/<plugin_name>/.
25 usr/plugins/<plugin_name>/
26 ├── plugin.yaml # Required: Title, version, settings + activation metadata
27 ├── initialize.py # Optional: one-time setup script (dependencies, models, etc.)
28 +├── hooks.py # Optional: runtime hook functions callable by the framework
29 ├── default_config.yaml # Optional: fallback settings defaults
30 ├── README.md # Optional: shown in Plugin List UI
31 ├── LICENSE # Optional: shown in Plugin List UI
@@ -68,6 +69,36 @@ Field reference:
69 - `per_agent_config`: Enables agent-profile-scoped settings and toggle rules
70 - `always_enabled`: Forces ON and disables toggle controls in the UI (reserved for framework use)
71
72 +### hooks.py (framework runtime hooks)
73 +
74 +Plugins can include an optional `hooks.py` file at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
75 +
76 +- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**, not the separate agent execution environment.
77 +- Use it for framework-internal operations such as install-time setup, plugin registration work, filesystem preparation, cache updates, or other tasks that need access to Agent Zero internals.
78 +- Hook functions may be synchronous or async. Async hooks are awaited by the framework.
79 +- Hook modules are cached until plugin caches are cleared, so changes may require a plugin refresh/reload cycle.
80 +
81 +Current example: the plugin installer calls `install()` from `hooks.py` after a plugin is copied into place.
82 +
83 +### Runtime and dependency implications
84 +
85 +- If `hooks.py` installs Python packages with `sys.executable -m pip`, those packages are installed into the **same Python environment that runs Agent Zero itself**.
86 +- This is the correct place for Python dependencies that your plugin's backend code needs while running inside the framework runtime.
87 +- It is **not** the right place for dependencies meant only for the separate agent execution runtime or for arbitrary system-level tooling.
88 +
89 +If your plugin needs to install packages or binaries for the agent execution environment instead of the framework runtime, launch a subprocess that explicitly activates or targets that other environment first. In practice this means invoking the correct interpreter or shell for that environment rather than relying on the current process environment. For example:
90 +
91 +- target a specific Python interpreter path for that runtime
92 +- activate the desired virtualenv inside a subprocess shell command before running `pip`
93 +- invoke the appropriate package manager from a subprocess prepared for that environment
94 +
95 +In Docker deployments, this distinction is especially important:
96 +
97 +- Framework runtime: `/opt/venv-a0`
98 +- Agent execution runtime: `/opt/venv`
99 +
100 +So a `hooks.py` install step affects `/opt/venv-a0` unless you intentionally switch to `/opt/venv` (or another target) inside your subprocess.
101 +
102 ---
103
104 ## 3. Frontend Extensions
docs/developer/plugins.md
+28
@@ -50,6 +50,7 @@ Field reference:
50 usr/plugins/<plugin_name>/
51 ├── plugin.yaml
52 ├── initialize.py # optional one-time setup script
53 +├── hooks.py # optional runtime hook functions callable by the framework
54 ├── default_config.yaml # optional defaults
55 ├── README.md # optional, shown in Plugin List UI
56 ├── LICENSE # optional, shown in Plugin List UI
@@ -97,6 +98,33 @@ if __name__ == "__main__":
98
99 Return `0` on success, non-zero on failure. Print progress for user feedback. Use `sys.executable` for pip commands.
100
101 +## Runtime Hooks (`hooks.py`)
102 +
103 +Plugins can also include an optional `hooks.py` at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
104 +
105 +- `hooks.py` executes inside the **Agent Zero framework runtime and Python environment**.
106 +- Use it for framework-internal operations such as install hooks, registration, cache preparation, file setup, or other work that needs direct access to framework internals.
107 +- Hook functions may be synchronous or async.
108 +- Hook modules are cached, so edits may require a plugin refresh or cache clear before changes are picked up.
109 +
110 +Current built-in usage: the plugin installer calls `install()` from `hooks.py` after copying a plugin into place.
111 +
112 +### Dependency and environment behavior
113 +
114 +- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the **same Python environment that is currently running Agent Zero**.
115 +- That is the correct target for dependencies needed by your plugin's backend code inside the framework runtime.
116 +- It is not automatically the right target for packages intended only for the separate agent execution runtime or for system-level binaries.
117 +
118 +If you need to install into a different environment, do it explicitly from a subprocess. In practice, that means targeting the correct interpreter or activating the correct environment inside the subprocess before running `pip` or another package manager.
119 +
120 +Examples of the right approach:
121 +
122 +- call a specific Python executable for the target runtime
123 +- activate the target virtualenv in a subprocess shell command before invoking `pip`
124 +- run OS-level package installation from a subprocess prepared for the intended environment
125 +
126 +In Docker deployments, `hooks.py` normally affects the framework runtime at `/opt/venv-a0`, while the agent execution runtime is `/opt/venv`.
127 +
128 ## Settings Resolution
129
130 Plugin settings are resolved by scope. Higher priority overrides lower priority:
helpers/api.py
+1 -1
@@ -16,7 +16,7 @@ from helpers import files, cache
16
17 ThreadLockType = Union[threading.Lock, threading.RLock]
18
19 -CACHE_AREA = "api_handlers(api)(plugins)"
19 +CACHE_AREA = "api_handlers(api)(plugins)(extensions)"
20 cache.toggle_area(CACHE_AREA, False) # cache off for now
21
22 Input = dict
helpers/cache.py
+7
@@ -18,6 +18,13 @@ def toggle_area(area: str, enabled: bool) -> None:
18 _enabled_areas[area] = enabled
19
20
21 +def has(area: str, key: str) -> bool:
22 + if not _is_enabled(area):
23 + return False
24 + with _lock:
25 + return key in _cache.get(area, {})
26 +
27 +
28 def add(area: str, key: str, data: Any) -> None:
29 if not _is_enabled(area):
30 return
helpers/extension.py
+2 -2
@@ -1,7 +1,7 @@
1 from abc import abstractmethod
2 from typing import Any, Awaitable, Type, cast
3 from helpers import extract_tools, files
4 -from helpers import cache, plugins, subagents
4 +from helpers import cache, subagents
5 from typing import TYPE_CHECKING
6 from functools import wraps
7 import inspect
@@ -262,4 +262,4 @@ def _get_extensions(folder: str):
262
263 classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
264 cache.add(_CACHE_AREA, folder, classes)
265 - return classes
265 + return classes
\ No newline at end of file
helpers/plugins.py
+103 -10
@@ -15,7 +15,15 @@ from typing import (
15 TypedDict,
16 )
17
18 -from helpers import files, notification, print_style, yaml as yaml_helper, cache
18 +from helpers import (
19 + files,
20 + notification,
21 + print_style,
22 + yaml as yaml_helper,
23 + cache,
24 + extension,
25 + extract_tools,
26 +)
27 from pydantic import BaseModel, Field
28
29 from helpers.defer import DeferredTask
@@ -29,6 +37,7 @@ _META_TARGET_RE = re.compile(
37 re.IGNORECASE,
38 )
39
40 +
41 type ToggleState = Literal["enabled", "disabled", "advanced"]
42
43
@@ -44,6 +53,10 @@ CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
53 DISABLED_FILE_NAME = ".toggle-0"
54 ENABLED_FILE_NAME = ".toggle-1"
55 TOGGLE_FILE_PATTERN = ".toggle-[01]"
56 +
57 +HOOKS_SCRIPT = "hooks.py"
58 +HOOKS_CACHE_AREA = "plugin_hooks(plugins)"
59 +
60 _last_frontend_reload_notification_at = 0.0
61
62
@@ -77,6 +90,7 @@ class PluginListItem(BaseModel):
90 toggle_state: ToggleState = "disabled"
91
92
93 +@extension.extensible
94 def after_plugin_change(plugin_names: list[str] | None = None):
95 clear_plugin_cache()
96 send_frontend_reload_notification(plugin_names)
@@ -192,6 +206,14 @@ def find_plugin_dir(plugin_name: str):
206 return None
207
208
209 +@extension.extensible
210 +def uninstall_plugin(plugin_name):
211 + # call the uninstall hook if any
212 + call_plugin_hook(plugin_name, "uninstall")
213 + # then delete
214 + delete_plugin(plugin_name)
215 +
216 +@extension.extensible
217 def delete_plugin(plugin_name: str):
218 plugin_dir = find_plugin_dir(plugin_name)
219 if not plugin_dir:
@@ -199,7 +221,9 @@ def delete_plugin(plugin_name: str):
221 custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
222 if not files.is_in_dir(plugin_dir, custom_plugins_dir):
223 raise ValueError("Only custom plugins can be deleted")
202 - send_frontend_reload_notification([plugin_name]) # send before deletion to properly check the extensions, second notification will be skipped automatically
224 + send_frontend_reload_notification(
225 + [plugin_name]
226 + ) # send before deletion to properly check the extensions, second notification will be skipped automatically
227 files.delete_dir(plugin_dir)
228 after_plugin_change([plugin_name])
229
@@ -298,7 +322,6 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
322 else "disabled"
323 )
324
301 -
325 # additional toggles in project/agent directories, return advanced
326 if meta.per_agent_config or meta.per_project_config:
327 configs = find_plugin_assets(
@@ -316,6 +339,7 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
339 return state
340
341
342 +@extension.extensible
343 def toggle_plugin(
344 plugin_name: str,
345 enabled: bool,
@@ -352,6 +376,7 @@ def toggle_plugin(
376 after_plugin_change([plugin_name])
377
378
379 +@extension.extensible
380 def get_plugin_config(
381 plugin_name: str,
382 agent: Agent | None = None,
@@ -380,35 +405,75 @@ def get_plugin_config(
405 file_path = files.get_abs_path(
406 find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
407 )
408 +
409 + result = None
410 if file_path and files.exists(file_path):
384 - return (
411 + result = (
412 json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
413 )(files.read_file(file_path))
387 - return None
414 +
415 + # call plugin hook to modify the standard result if needed
416 + new_result = call_plugin_hook(
417 + plugin_name,
418 + "save_plugin_config",
419 + result=result,
420 + agent=agent,
421 + project_name=project_name,
422 + agent_profile=agent_profile,
423 + )
424 +
425 + if new_result is not None:
426 + return new_result
427 + return result
428
429
430 def get_default_plugin_config(plugin_name: str):
431 file_path = files.get_abs_path(
432 find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
433 )
394 - if file_path and files.exists(file_path):
395 - return (
434 +
435 + # call plugin hook to get the result
436 + result = call_plugin_hook(
437 + plugin_name,
438 + "save_plugin_config",
439 + file_path = file_path
440 + )
441 +
442 + # or do standard load
443 + if result is None and file_path and files.exists(file_path):
444 + result = (
445 json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
446 )(files.read_file(file_path))
398 - return None
447
448 + return result
449
450 +
451 +@extension.extensible
452 def save_plugin_config(
453 plugin_name: str, project_name: str, agent_profile: str, settings: dict
454 ):
455 file_path = determine_plugin_asset_path(
456 plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
457 )
407 - if file_path:
408 - files.write_file(file_path, json.dumps(settings))
458 +
459 + # call plugin hook to get the result first
460 + new_settings = call_plugin_hook(
461 + plugin_name,
462 + "save_plugin_config",
463 + result=None,
464 + project_name=project_name,
465 + agent_profile=agent_profile,
466 + settings=settings,
467 + )
468 +
469 + # or do standard load
470 + if new_settings is not None and file_path:
471 + files.write_file(file_path, json.dumps(new_settings))
472 after_plugin_change([plugin_name])
473
474
475 +
476 +
477 def find_plugin_asset(
478 plugin_name: str, *subpaths: str, project_name="", agent_profile=""
479 ):
@@ -593,3 +658,31 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None):
658 )
659
660 DeferredTask().start_task(_send_later)
661 +
662 +
663 +def call_plugin_hook(plugin_name: str, hook_name: str, *args, **kwargs):
664 + hooks = None
665 +
666 + # use cached hooks if enabled
667 + if not cache.has(HOOKS_CACHE_AREA, plugin_name):
668 + hooks_script = files.get_abs_path(find_plugin_dir(plugin_name), HOOKS_SCRIPT)
669 + hooks = (
670 + extract_tools.import_module(hooks_script)
671 + if files.exists(hooks_script)
672 + else None
673 + )
674 + cache.add(HOOKS_CACHE_AREA, plugin_name, hooks)
675 + else:
676 + hooks = cache.get(HOOKS_CACHE_AREA, plugin_name)
677 +
678 + if not hooks:
679 + return
680 +
681 + hook = getattr(hooks, hook_name, None)
682 + if not hook:
683 + return
684 +
685 + if asyncio.iscoroutinefunction(hook):
686 + return asyncio.run(hook(*args, **kwargs))
687 +
688 + return hook(*args, **kwargs)
helpers/subagents.py
+7 -3
@@ -252,7 +252,7 @@ def _merge_agent_list_items(
252
253
254 def get_agents_roots() -> list[str]:
255 - from helpers import plugins
255 + # from helpers import plugins
256
257 plugin_agents = plugins.get_enabled_plugin_paths(None, "agents")
258 project_agents = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/agents")
@@ -378,7 +378,7 @@ def get_paths(
378
379 # plugin agents/<profile>/...
380 if include_plugins:
381 - from helpers import plugins
381 + # from helpers import plugins
382 for plugin_dir in plugins.get_enabled_plugin_paths(agent, "agents", profile_name):
383 path = files.get_abs_path(plugin_dir, *subpaths)
384 if (not must_exist_completely) or files.exists(files.get_abs_path(plugin_dir, *check_subpaths)):
@@ -397,7 +397,7 @@ def get_paths(
397
398 if include_plugins:
399 # plugins/*/subpaths...
400 - from helpers import plugins
400 + # from helpers import plugins
401
402 for plugin_dir in plugins.get_enabled_plugin_paths(agent):
403 path = files.get_abs_path(plugin_dir, *subpaths)
@@ -412,3 +412,7 @@ def get_paths(
412 paths.append(path)
413
414 return paths
415 +
416 +
417 +# end-of-file imports to prevent circular imports
418 +from helpers import plugins
\ No newline at end of file
plugins/README.md
+11
@@ -45,6 +45,17 @@ always_enabled: false
45
46 Plugins can include an optional `initialize.py` at the plugin root for one-time setup such as installing dependencies or downloading models. Users trigger it via the **Init** button in the Plugin List UI. The script should return `0` on success and print progress messages for user feedback.
47
48 +## Runtime Hooks (`hooks.py`)
49 +
50 +Plugins can also include an optional `hooks.py` at the plugin root. The framework loads it on demand and can call exported hook functions by name through `helpers.plugins.call_plugin_hook(...)`.
51 +
52 +- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**.
53 +- Use it for framework-internal work such as install hooks, cache preparation, registration, or filesystem setup.
54 +- If it runs `sys.executable -m pip install ...`, packages are installed into the same Python environment that runs Agent Zero.
55 +- If you need to install into the separate agent runtime or into the system environment, explicitly target that environment from a subprocess by selecting the correct interpreter, virtualenv, or package manager.
56 +
57 +In Docker, `hooks.py` normally affects `/opt/venv-a0`; the agent execution runtime is `/opt/venv`.
58 +
59 ## Plugin Index & Community Sharing
60
61 The **Plugin Index** at https://github.com/agent0ai/a0-plugins is the community-maintained registry of plugins available to all Agent Zero users.
plugins/_plugin_installer/helpers/install.py
+66 -43
@@ -2,15 +2,15 @@ from __future__ import annotations
2
3 import json
4 import os
5 -import shutil
5 import time
6 +from turtle import stamp
7 import urllib.request
8 import uuid
9 import zipfile
10 from pathlib import Path
11 from typing import Any
12
13 -from helpers import files
13 +from helpers import files, print_style, plugins
14 from helpers import yaml as yaml_helper
15 from helpers.plugins import (
16 META_FILE_NAME,
@@ -21,6 +21,7 @@ from helpers.plugins import (
21 from werkzeug.datastructures import FileStorage
22 from werkzeug.utils import secure_filename
23
24 +
25 def _get_user_plugins_dir() -> str:
26 """Return absolute path to usr/plugins/."""
27 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
@@ -33,7 +34,7 @@ def _get_plugin_name(meta: PluginMetadata) -> str:
34 return plugin_name
35
36
36 -def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
37 +def validate_plugin_dir(path: str, plugin_name: str = "") -> PluginMetadata:
38 """Check directory contains plugin.yaml and return parsed metadata.
39 Raises ValueError if plugin.yaml is missing or invalid."""
40 meta_path = os.path.join(path, META_FILE_NAME)
@@ -44,7 +45,9 @@ def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
45 data = yaml_helper.loads(content)
46 model = PluginMetadata.model_validate(data)
47 if plugin_name and plugin_name != model.name:
47 - raise ValueError(f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file.")
48 + raise ValueError(
49 + f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file."
50 + )
51 return model
52
53
@@ -89,36 +92,47 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
92 """Extract ZIP, find plugin.yaml, move its parent to usr/plugins/.
93 Returns dict with plugin name and metadata.
94 Cleans up tmp files regardless of outcome."""
92 - base_tmp = files.get_abs_path("tmp", "plugin_installs")
93 - os.makedirs(base_tmp, exist_ok=True)
94 - stamp = time.strftime("%Y%m%d_%H%M%S")
95 - extract_dir = os.path.join(base_tmp, f"extract_{stamp}")
96 - os.makedirs(extract_dir, exist_ok=True)
95 + temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
96 + extract_dir = files.get_abs_path(files.TEMP_DIR, "plugin_installs", temp_name)
97 + extract_dir = files.create_dir_safe(extract_dir)
98 + dest = ""
99
100 try:
99 - # Extract with path traversal protection
101 try:
102 + # Extract with path traversal protection
103 with zipfile.ZipFile(zip_path, "r") as z:
102 - real_extract = os.path.realpath(extract_dir)
104 for member in z.namelist():
105 member_path = os.path.realpath(os.path.join(extract_dir, member))
105 - if not member_path.startswith(real_extract + os.sep) and member_path != real_extract:
106 + if not (files.is_in_dir(member_path, extract_dir)):
107 raise ValueError(f"Unsafe path in archive: {member}")
108 z.extractall(extract_dir)
108 - except zipfile.BadZipFile:
109 - raise ValueError("The uploaded file is not a valid ZIP archive")
109
111 - # Find plugin.yaml
112 - plugin_root = _find_plugin_root(extract_dir)
113 - meta = validate_plugin_dir(plugin_root)
114 - plugin_name = _get_plugin_name(meta)
110 + # Find plugin.yaml
111 + plugin_root = _find_plugin_root(extract_dir)
112 + meta = validate_plugin_dir(plugin_root)
113 + plugin_name = _get_plugin_name(meta)
114
116 - check_plugin_conflict(plugin_name)
115 + check_plugin_conflict(plugin_name)
116 +
117 + # Move to usr/plugins/
118 + dest = os.path.join(_get_user_plugins_dir(), plugin_name)
119 + files.create_dir(os.path.dirname(dest))
120 + files.move_dir(plugin_root, dest)
121 + except Exception as e:
122 + print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
123 + files.delete_dir(extract_dir)
124 + raise
125 +
126 + # run installation hook
127 + try:
128 + run_install_hook(plugin_name)
129 + except Exception as e:
130 + print_style.PrintStyle.error(
131 + f"Failed to run installation hook for {plugin_name}: {e}"
132 + )
133 + files.delete_dir(dest)
134 + raise
135
118 - # Move to usr/plugins/
119 - dest = os.path.join(_get_user_plugins_dir(), plugin_name)
120 - os.makedirs(os.path.dirname(dest), exist_ok=True)
121 - shutil.move(plugin_root, dest)
136 after_plugin_change([plugin_name])
137
138 return {
@@ -129,50 +143,59 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
143 }
144 finally:
145 # Cleanup: extracted files and the archive
132 - shutil.rmtree(extract_dir, ignore_errors=True)
146 try:
134 - os.unlink(zip_path)
135 - except OSError:
147 + files.delete_dir(extract_dir)
148 + files.delete_file(zip_path)
149 + except Exception as e:
150 pass
151
152
139 -def install_from_git(url: str, token: str | None = None, plugin_name: str="") -> dict:
153 +def install_from_git(url: str, token: str | None = None, plugin_name: str = "") -> dict:
154 """Clone git repo into usr/plugins/, validate plugin.yaml.
155 Returns dict with plugin name and metadata."""
156 from helpers.git import clone_repo
157
158 temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
145 - dest = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
146 - files.create_dir_safe(dest)
159 + git_dir = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
160
161 try:
149 - clone_repo(url, dest, token=token or None)
162 + files.create_dir_safe(git_dir)
163 + clone_repo(url, git_dir, token=token or None)
164 + meta = validate_plugin_dir(git_dir, plugin_name=plugin_name)
165 + plugin_name = _get_plugin_name(meta)
166 + check_plugin_conflict(plugin_name)
167 + final_dir = os.path.join(_get_user_plugins_dir(), plugin_name)
168 + files.move_dir(git_dir, final_dir)
169 except Exception as e:
151 - # Cleanup partial clone
152 - shutil.rmtree(dest, ignore_errors=True)
153 - raise ValueError(f"Git clone failed: {e}") from e
170 + # No plugin.yaml — remove cloned repo
171 + print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
172 + files.delete_dir(git_dir)
173 + raise
174
175 + # run installation hook
176 try:
156 - meta = validate_plugin_dir(dest, plugin_name=plugin_name)
157 - except ValueError:
158 - # No plugin.yaml — remove cloned repo
159 - shutil.rmtree(dest, ignore_errors=True)
177 + run_install_hook(plugin_name)
178 + except Exception as e:
179 + print_style.PrintStyle.error(
180 + f"Failed to run installation hook for {plugin_name}: {e}"
181 + )
182 + files.delete_dir(final_dir)
183 raise
184
162 - plugin_name = _get_plugin_name(meta)
163 - check_plugin_conflict(plugin_name)
164 - final_dest = os.path.join(_get_user_plugins_dir(), plugin_name)
165 - files.move_dir(dest, final_dest)
185 after_plugin_change([plugin_name])
186
187 return {
188 "success": True,
189 "plugin_name": plugin_name,
190 "title": meta.title or plugin_name,
172 - "path": files.deabsolute_path(final_dest),
191 + "path": files.deabsolute_path(final_dir),
192 }
193
194
195 +def run_install_hook(plugin_name: str):
196 + return plugins.call_plugin_hook(plugin_name, "install")
197 +
198 +
199 def get_marketplace_index() -> dict[str, Any]:
200 """Return the plugin index plus installed marketplace keys."""
201 index_data = fetch_plugin_index()
@@ -200,4 +223,4 @@ def fetch_plugin_index() -> dict:
223 req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
224 with urllib.request.urlopen(req, timeout=30) as resp:
225 data = json.loads(resp.read().decode())
203 - return data
\ No newline at end of file
226 + return data
plugins/_plugin_installer/webui/install-detail.html
+23 -9
@@ -61,36 +61,36 @@
61 <div class="pi-hero-manage">
62 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_main_screen">
63 <button type="button" class="button"
64 - @click="$store.pluginInstallStore.manageOpenPlugin()">
64 + @click="$store.pluginInstallStore.handleOpenPlugin()">
65 <span class="icon material-symbols-outlined">dashboard</span> Open
66 </button>
67 </template>
68 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_config_screen">
69 <button type="button" class="button"
70 - @click="$store.pluginInstallStore.manageOpenConfig()">
70 + @click="$store.pluginInstallStore.handleOpenConfig()">
71 <span class="icon material-symbols-outlined">settings</span> Config
72 </button>
73 </template>
74 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_readme">
75 <button type="button" class="button"
76 - @click="$store.pluginInstallStore.manageOpenDoc('readme')">
76 + @click="$store.pluginInstallStore.handleOpenDoc('readme')">
77 <span class="icon material-symbols-outlined">description</span> README
78 </button>
79 </template>
80 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_license">
81 <button type="button" class="button"
82 - @click="$store.pluginInstallStore.manageOpenDoc('license')">
82 + @click="$store.pluginInstallStore.handleOpenDoc('license')">
83 <span class="icon material-symbols-outlined">gavel</span> License
84 </button>
85 </template>
86 <template x-if="$store.pluginInstallStore.installedPluginInfo.has_init_script">
87 <button type="button" class="button"
88 - @click="$store.pluginInstallStore.manageOpenInit()">
88 + @click="$store.pluginInstallStore.handleOpenInit()">
89 <span class="icon material-symbols-outlined">terminal</span> Init
90 </button>
91 </template>
92 <button type="button" class="button"
93 - @click="$store.pluginInstallStore.manageOpenInfo()">
93 + @click="$store.pluginInstallStore.handleOpenInfo()">
94 <span class="icon material-symbols-outlined">info</span> Info
95 </button>
96 </div>
@@ -135,9 +135,18 @@
135 </template>
136 <template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo?.is_custom">
137 <button type="button" class="pi-btn-uninstall"
138 - @click="$confirmClick($event, () => $store.pluginInstallStore.manageDeletePlugin())">
139 - <span class="material-symbols-outlined">delete</span>
140 - <span>Uninstall</span>
138 + @click="$confirmClick($event, () => $store.pluginInstallStore.handleDeletePlugin())"
139 + :disabled="$store.pluginInstallStore.loading">
140 + <span class="pi-btn-loading" x-show="$store.pluginInstallStore.loading">
141 + <span class="spinner"></span>
142 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Uninstalling...'"></span>
143 + </span>
144 + <span x-show="!$store.pluginInstallStore.loading">
145 + <span class="material-symbols-outlined">delete</span>
146 + </span>
147 + <span x-show="!$store.pluginInstallStore.loading">
148 + <span>Uninstall</span>
149 + </span>
150 </button>
151 </template>
152 <template x-if="$store.pluginInstallStore.selectedPlugin.discussion">
@@ -416,6 +425,11 @@
425 color: #fff;
426 }
427
428 + .pi-btn-uninstall:disabled {
429 + opacity: 0.6;
430 + cursor: not-allowed;
431 + }
432 +
433 .pi-btn-uninstall .material-symbols-outlined {
434 font-size: 1.3rem;
435 }
plugins/_plugin_installer/webui/pluginInstallStore.js
+15 -7
@@ -452,38 +452,43 @@ const model = {
452 },
453
454
455 - manageOpenPlugin() {
455 + handleOpenPlugin() {
456 const info = this.installedPluginInfo;
457 if (!info || !info.name || !info.has_main_screen) return;
458 openModal(`/plugins/${info.name}/webui/main.html`);
459 },
460
461 - async manageOpenConfig() {
461 + async handleOpenConfig() {
462 if (this.installedPluginInfo) {
463 await pluginListStore.openPluginConfig(this.installedPluginInfo);
464 }
465 },
466
467 - async manageOpenDoc(doc) {
467 + async handleOpenDoc(doc) {
468 if (this.installedPluginInfo) {
469 await pluginListStore.openPluginDoc(this.installedPluginInfo, doc);
470 }
471 },
472
473 - manageOpenInfo() {
473 + handleOpenInfo() {
474 if (this.installedPluginInfo) {
475 pluginListStore.openPluginInfo(this.installedPluginInfo);
476 }
477 },
478
479 - manageOpenInit() {
479 + handleOpenInit() {
480 if (this.installedPluginInfo) {
481 pluginInitStore.open(this.installedPluginInfo);
482 }
483 },
484
485 - async manageDeletePlugin() {
486 - if (this.installedPluginInfo) {
485 + async handleDeletePlugin() {
486 + if (!this.installedPluginInfo) return;
487 +
488 + try {
489 + this.loading = true;
490 + this.loadingMessage = "Uninstalling plugin...";
491 +
492 await pluginListStore.deletePlugin(this.installedPluginInfo);
493 const currentPlugin = this.selectedPlugin;
494 if (currentPlugin) {
@@ -493,6 +498,9 @@ const model = {
498 );
499 }
500 this.installedPluginInfo = null;
501 + } finally {
502 + this.loading = false;
503 + this.loadingMessage = "";
504 }
505 },
506
skills/a0-create-plugin/SKILL.md
+21 -1
@@ -178,6 +178,7 @@ save_plugin_config(
178 /a0/usr/plugins/<name>/
179 plugin.yaml # Required manifest
180 initialize.py # Optional one-time setup script
181 + hooks.py # Optional framework runtime hook functions
182 default_config.yaml # Optional default settings fallback
183 README.md # Optional, shown in Plugin List UI
184 LICENSE # Optional, shown in Plugin List UI
@@ -195,7 +196,6 @@ save_plugin_config(
196 ```
197
198 ## Plugin Initialization Script (`initialize.py`)
198 -
199 If your plugin requires one-time setup (e.g., installing dependencies, downloading models), add an `initialize.py` at the plugin root:
200
201 ```python
@@ -220,6 +220,26 @@ if __name__ == "__main__":
220
221 Users trigger it via the **Init** button in the Plugin List UI. Return `0` on success, non-zero on failure.
222
223 +## Runtime Hooks (`hooks.py`)
224 +If your plugin needs framework-internal hook points, add a `hooks.py` file at the plugin root. The framework can call exported functions by name via `helpers.plugins.call_plugin_hook(...)`.
225 +
226 +- `hooks.py` runs inside the **Agent Zero framework runtime**, not the separate agent execution environment.
227 +- Use it for things like install hooks, plugin registration work, cache setup, file preparation, or other internal framework operations.
228 +- Hook functions may be sync or async.
229 +- Current example: the plugin installer calls `install()` in `hooks.py` after placing a plugin in `usr/plugins/`.
230 +
231 +### Environment targeting rules
232 +- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the same Python environment that is running Agent Zero.
233 +- That is correct for dependencies needed by the plugin inside the framework runtime.
234 +- If the dependency is meant for the separate agent runtime or for OS-level tools, do **not** assume the current environment is correct.
235 +
236 +Instead, explicitly switch targets in a subprocess:
237 +- invoke the exact Python interpreter for the target runtime
238 +- activate the target virtualenv in the subprocess before running `pip`
239 +- run the relevant OS package manager from a subprocess configured for the intended environment
240 +
241 +In Docker, this usually means `hooks.py` affects `/opt/venv-a0` unless you intentionally target `/opt/venv` or another environment.
242 +
243 ---
244
245 ## Community Plugin: GitHub Repo + Plugin Index Submission
webui/js/cache.js
+7
@@ -14,6 +14,13 @@ export function toggle_area(area, enabled) {
14 enabledAreas.set(area, !!enabled);
15 }
16
17 +export function has(area, key) {
18 + if (!isEnabled(area)) return false;
19 + const areaCache = cache.get(area);
20 + if (!areaCache) return false;
21 + return areaCache.has(key);
22 +}
23 +
24 export function add(area, key, data) {
25 if (!isEnabled(area)) return;
26 let areaCache = cache.get(area);