diff --git a/pygitweb/api/action.py b/pygitweb/api/action.py
index deabeaf..e116192 100644
--- a/pygitweb/api/action.py
+++ b/pygitweb/api/action.py
@@ -1,12 +1,16 @@
 from __future__ import annotations
 
-from abc import ABC, abstractmethod
+from abc import abstractmethod
+from types import TracebackType
+from typing import Self
 
 from fastapi import Request
 from starlette.responses import Response
 
+from pygitweb.api.plugin import Plugin
 
-class Action(ABC):
+
+class Action(Plugin):
 	"""
 	Action: A per-project action provided by a plugin.
 
@@ -14,6 +18,17 @@ class Action(ABC):
 	"""
 
 	@abstractmethod
+	def __enter__(self) -> Self: ...
+
+	@abstractmethod
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> bool | None: ...
+
+	@abstractmethod
 	def action_name(self) -> str: ...
 
 	@abstractmethod
diff --git a/pygitweb/api/plugin.py b/pygitweb/api/plugin.py
new file mode 100644
index 0000000..03677ec
--- /dev/null
+++ b/pygitweb/api/plugin.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from abc import abstractmethod
+from types import TracebackType
+from typing import Self
+
+from abcreg import AnnotatedABC
+
+
+class Plugin(AnnotatedABC):
+	"""Load-scoped plugin base; enter on load and exit on unload."""
+
+	@abstractmethod
+	def __enter__(self) -> Self: ...
+
+	@abstractmethod
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> bool | None: ...
diff --git a/pygitweb/api/subpage.py b/pygitweb/api/subpage.py
index 0bef74a..4768f9d 100644
--- a/pygitweb/api/subpage.py
+++ b/pygitweb/api/subpage.py
@@ -1,22 +1,37 @@
 from __future__ import annotations
 
-from abc import ABC, abstractmethod
+from abc import abstractmethod
+from types import TracebackType
+from typing import Self
 
+from pygitweb.api.plugin import Plugin
 
-class Subpage(ABC):
+
+class Subpage(Plugin):
 	"""
 	Subpage: A per-project subpage provided by a plugin.
 	"""
 
 	@abstractmethod
+	def __enter__(self) -> Self: ...
+
+	@abstractmethod
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> bool | None: ...
+
+	@abstractmethod
 	def subpage_name(self) -> str: ...
 
-	def link_name(self, project: str) -> str | None:
-		return None
+	@abstractmethod
+	def link_name(self, project: str) -> str | None: ...
 
-	def subpage_html(self, project: str) -> str | None:
-		return None
+	@abstractmethod
+	def subpage_html(self, project: str) -> str | None: ...
 
+	@abstractmethod
 	def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
 		"""Optional HTML appended after the subpage \"view\" link on the project summary table."""
-		return None
diff --git a/pygitweb/api_test.py b/pygitweb/api_test.py
new file mode 100644
index 0000000..31dc80c
--- /dev/null
+++ b/pygitweb/api_test.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Self
+
+import pytest
+from fastapi import Request
+from starlette.responses import Response
+
+from pygitweb.api.action import Action
+from pygitweb.api.subpage import Subpage
+
+
+def test_action_subclass_wrong_return_annotation() -> None:
+	with pytest.raises(TypeError, match="missing or mismatched 'action'"):
+
+		class BadAction(Action):
+			def __enter__(self) -> Self:
+				return self
+
+			def __exit__(
+				self,
+				exc_type: type[BaseException] | None,
+				exc_value: BaseException | None,
+				traceback: TracebackType | None,
+			) -> bool | None:
+				return None
+
+			def action_name(self) -> str:
+				return "bad"
+
+			def action(self, project: str, req: Request | None = None) -> int:
+				return 1
+
+
+def test_action_subclass_missing_exit() -> None:
+	with pytest.raises(TypeError, match="missing or mismatched '__exit__'"):
+
+		class BadAction(Action):
+			def __enter__(self) -> Self:
+				return self
+
+			def action_name(self) -> str:
+				return "bad"
+
+			def action(self, project: str, req: Request | None = None) -> Response | None:
+				return None
+
+
+def test_subpage_subclass_wrong_return_annotation() -> None:
+	with pytest.raises(TypeError, match="missing or mismatched 'subpage_name'"):
+
+		class BadSubpage(Subpage):
+			def __enter__(self) -> Self:
+				return self
+
+			def __exit__(
+				self,
+				exc_type: type[BaseException] | None,
+				exc_value: BaseException | None,
+				traceback: TracebackType | None,
+			) -> bool | None:
+				return None
+
+			def subpage_name(self) -> int:
+				return 1
+
+			def link_name(self, project: str) -> str | None:
+				return None
+
+			def subpage_html(self, project: str) -> str | None:
+				return None
+
+			def summary_value_suffix_html(self, project: str, project_enc: str) -> str | None:
+				return None
+
+
+def test_action_virtual_register_non_compliant() -> None:
+	class Bad:
+		def __enter__(self) -> Self:
+			return self
+
+		def __exit__(
+			self,
+			exc_type: type[BaseException] | None,
+			exc_value: BaseException | None,
+			traceback: TracebackType | None,
+		) -> bool | None:
+			return None
+
+		def action_name(self) -> str:
+			return "bad"
+
+		def action(self, project: str, req: Request | None = None) -> int:
+			return 1
+
+	with pytest.raises(TypeError, match="missing or mismatched 'action'"):
+		Action.register(Bad)
diff --git a/pygitweb/conftest.py b/pygitweb/conftest.py
index 7151358..918a545 100644
--- a/pygitweb/conftest.py
+++ b/pygitweb/conftest.py
@@ -37,7 +37,12 @@ def _reset_pygitweb_app_lifecycle_state() -> None:
 	Stale state made long-poll tests see ``shutting_down`` and skip ``wait_for_changes``, so notify woke 0 waiters.
 	"""
 	from pygitweb.main import app
+	from pygitweb.plugin_registry import PluginRegistry
 
 	app.state.shutting_down = False
 	app.state.shutdown_event = asyncio.Event()
+	if not hasattr(app.state, "plugins"):
+		plugins = PluginRegistry()
+		plugins.load_all()
+		app.state.plugins = plugins
 	yield
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index e65b6b9..75a8d1d 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -9,6 +9,7 @@ the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
 
 from __future__ import annotations
 
+import contextlib
 import hashlib
 import os
 import re
@@ -132,10 +133,8 @@ def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
 			version = read_hook_version(target_path)
 		installed_hash: str | None = None
 		if target_path.is_file():
-			try:
+			with contextlib.suppress(OSError):
 				installed_hash = content_hash(target_path)
-			except OSError:
-				pass
 		installed.append({
 			"name": sample["name"],
 			"label": sample["label"],
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 402eebd..80a8f78 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -50,7 +50,6 @@ from pygitweb.actions import (
 )
 from pygitweb.api.plugins.project_zip import ProjectZip
 from pygitweb.api.project import Project
-from pygitweb.api.subpage import Subpage
 from pygitweb.auth import (
 	access_token_from_request,
 	auth_router,
@@ -97,7 +96,7 @@ from pygitweb.hooks_install import (
 )
 from pygitweb.merge_requests import merge_router
 from pygitweb.permissions import Permission
-from pygitweb.plugin_loader import load_plugin_actions, load_subpages
+from pygitweb.plugin_registry import PluginRegistry
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.sessions import clear_all_sessions
 from pygitweb.settings import router as settings_router
@@ -152,11 +151,15 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
 	app.state.shutting_down = False
 	app.state.shutdown_event = asyncio.Event()
 	app.state.can_signal_shutdown = False
+	plugins = PluginRegistry()
+	plugins.load_all()
+	app.state.plugins = plugins
 	install_graceful_shutdown_wakeup(app)
 	warm_timeline_cache_async()
 	try:
 		yield
 	finally:
+		plugins.unload_all()
 		begin_shutdown(app)
 		clear_timeline_cache_async()
 		clear_all_sessions()
@@ -190,10 +193,6 @@ Ends with the same session cookie as local login.""",
 	],
 )
 
-PLUGIN_ACTIONS = load_plugin_actions()
-SUBPAGES: list[Subpage] = load_subpages()
-DISPATCH_ACTIONS: frozenset[str] = frozenset(ACTIONS) | frozenset(PLUGIN_ACTIONS.keys())
-
 # Todo handle with nginx route
 _static_dir = Path(__file__).parent / "static"
 if _static_dir.is_dir():
@@ -222,10 +221,10 @@ async def loadavg_middleware(request: Request, call_next):
 	return await call_next(request)
 
 
-def _project_subpage_rows(project: str) -> list[list[str]]:
+def _project_subpage_rows(project: str, plugins: PluginRegistry) -> list[list[str]]:
 	project_enc = quote(project, safe="/")
 	rows: list[list[str]] = []
-	for subpage in SUBPAGES:
+	for subpage in plugins.subpages:
 		link_label = subpage.link_name(project)
 		if not link_label:
 			continue
@@ -575,11 +574,12 @@ def project_search_page(project: ValidatedReadableProject) -> HTMLResponse:
 
 @app.get("/project/{project:path}/subpage/{subpage_name}", response_class=HTMLResponse)
 def project_subpage(
+	request: Request,
 	project: ValidatedReadableProject,
 	subpage_name: str,
 	raw: Annotated[bool, Query()] = False,
 ) -> HTMLResponse:
-	for subpage in SUBPAGES:
+	for subpage in request.app.state.plugins.subpages:
 		if subpage.subpage_name() != subpage_name:
 			continue
 		content = subpage.subpage_html(project)
@@ -786,7 +786,8 @@ async def dispatch(
 			}.get(obj_type, "object")
 		else:
 			action = "summary"
-	if action not in DISPATCH_ACTIONS:
+	plugins: PluginRegistry = request.app.state.plugins
+	if action not in plugins.dispatch_actions():
 		raise HTTPException(status_code=400, detail="Unknown action")
 	if action in ("opml", "project_list", "project_index"):
 		raise HTTPException(status_code=400, detail="Project not needed for this action")
@@ -804,15 +805,15 @@ async def dispatch(
 		)
 		if notified is None:
 			return _updates_idle_response()
-	if action in PLUGIN_ACTIONS:
-		plugin = PLUGIN_ACTIONS[action]
+	if action in plugins.actions:
+		plugin = plugins.actions[action]
 		result = plugin.action(project, request)
 		if result is not None:
 			return result
 		pre = PREAMBLE.render(title=f"{plugin.action_name()} - {project}", site_name=settings.SITE_NAME)
 		return HTMLResponse(f"{pre}<p>Action completed.</p>{POSTAMBLE}")
 	if action == "summary":
-		return git_summary(project, extra_rows=_project_subpage_rows(project))
+		return git_summary(project, extra_rows=_project_subpage_rows(project, plugins))
 	if action == "tree":
 		return git_tree(project, hash_param, file_name)
 	if action in ("blob", "blob_plain"):
diff --git a/pygitweb/plugin_registry.py b/pygitweb/plugin_registry.py
new file mode 100644
index 0000000..035aa2f
--- /dev/null
+++ b/pygitweb/plugin_registry.py
@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+import contextlib
+import importlib.metadata
+import logging
+import threading
+from collections.abc import Iterable
+from dataclasses import dataclass
+from typing import Generic, TypeVar
+
+from pygitweb.api.action import Action
+from pygitweb.api.plugin import Plugin
+from pygitweb.api.subpage import Subpage
+from pygitweb.config import ACTIONS as BUILTIN_ACTIONS
+
+logger = logging.getLogger(__name__)
+
+SUBPAGE_GROUP = "pygitweb.subpages"
+ACTION_GROUP = "pygitweb.actions"
+
+_local = threading.local()
+
+T = TypeVar("T", bound=Plugin)
+
+
+def _plugin_id_stack() -> list[str]:
+	stack: list[str] | None = getattr(_local, "plugin_id_stack", None)
+	if stack is None:
+		stack = []
+		_local.plugin_id_stack = stack
+	return stack
+
+
+def current_plugin_id() -> str | None:
+	stack = _plugin_id_stack()
+	return stack[-1] if stack else None
+
+
+class _PluginLogFilter(logging.Filter):
+	def filter(self, record: logging.LogRecord) -> bool:
+		plugin_id = current_plugin_id()
+		if plugin_id is not None:
+			record.msg = f"{plugin_id}: {record.msg}"
+		return True
+
+
+def _install_plugin_log_filter() -> None:
+	logging.getLogger("pygitweb").addFilter(_PluginLogFilter())
+
+
+_install_plugin_log_filter()
+
+
+@contextlib.contextmanager
+def plugin_load_context(plugin_id: str):
+	stack = _plugin_id_stack()
+	stack.append(plugin_id)
+	try:
+		yield
+	finally:
+		if stack and stack[-1] == plugin_id:
+			stack.pop()
+		elif plugin_id in stack:
+			stack.remove(plugin_id)
+
+
+def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
+	return importlib.metadata.entry_points().select(group=group)
+
+
+@dataclass
+class PluginHandle(Generic[T]):
+	plugin_id: str
+	_stack: contextlib.ExitStack
+	instance: T
+
+	def unload(self) -> None:
+		self._stack.close()
+
+
+def _load_handle(
+	ep: importlib.metadata.EntryPoint,
+	expected_type: type[T],
+) -> PluginHandle[T] | None:
+	stack = contextlib.ExitStack()
+	try:
+		stack.enter_context(plugin_load_context(ep.name))
+		loaded = ep.load()
+	except Exception:
+		stack.close()
+		logger.exception("pygitweb: failed to load entry point %r", ep.name)
+		return None
+	if not isinstance(loaded, type) or not issubclass(loaded, expected_type):
+		stack.close()
+		logger.error(
+			"pygitweb: entry point %r must be a %s subclass, got %r",
+			ep.name,
+			expected_type.__name__,
+			loaded,
+		)
+		return None
+	try:
+		instance = stack.enter_context(loaded())
+	except Exception:
+		stack.close()
+		logger.exception("pygitweb: failed to enter plugin context for %r", ep.name)
+		return None
+	return PluginHandle(plugin_id=ep.name, _stack=stack, instance=instance)
+
+
+class PluginRegistry:
+	def __init__(self) -> None:
+		self._lock = threading.Lock()
+		self._action_handles: dict[str, PluginHandle[Action]] = {}
+		self._subpage_handles: list[PluginHandle[Subpage]] = []
+
+	def load_all(self) -> None:
+		with self._lock:
+			self._unload_locked()
+			self._load_actions_locked()
+			self._load_subpages_locked()
+
+	def unload_all(self) -> None:
+		with self._lock:
+			self._unload_locked()
+
+	def _unload_locked(self) -> None:
+		for handle in self._action_handles.values():
+			handle.unload()
+		for handle in self._subpage_handles:
+			handle.unload()
+		self._action_handles.clear()
+		self._subpage_handles.clear()
+
+	def _load_actions_locked(self) -> None:
+		for ep in _entry_points(ACTION_GROUP):
+			handle = _load_handle(ep, Action)
+			if handle is None:
+				continue
+			name = handle.instance.action_name()
+			if name in BUILTIN_ACTIONS:
+				logger.warning(
+					"pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
+					name,
+					ep.name,
+				)
+				handle.unload()
+				continue
+			if name in self._action_handles:
+				logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
+				handle.unload()
+				continue
+			self._action_handles[name] = handle
+
+	def _load_subpages_locked(self) -> None:
+		seen_names: set[str] = set()
+		for ep in _entry_points(SUBPAGE_GROUP):
+			handle = _load_handle(ep, Subpage)
+			if handle is None:
+				continue
+			key = handle.instance.subpage_name()
+			if key in seen_names:
+				logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
+				handle.unload()
+				continue
+			seen_names.add(key)
+			self._subpage_handles.append(handle)
+
+	@property
+	def actions(self) -> dict[str, Action]:
+		return {name: handle.instance for name, handle in self._action_handles.items()}
+
+	@property
+	def subpages(self) -> list[Subpage]:
+		return [handle.instance for handle in self._subpage_handles]
+
+	def dispatch_actions(self) -> frozenset[str]:
+		return frozenset(BUILTIN_ACTIONS) | frozenset(self._action_handles.keys())
diff --git a/pygitweb/plugin_registry_test.py b/pygitweb/plugin_registry_test.py
new file mode 100644
index 0000000..f6ca8ca
--- /dev/null
+++ b/pygitweb/plugin_registry_test.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Self
+
+import pytest
+from fastapi import Request
+from starlette.responses import Response
+
+from pygitweb.api.action import Action
+from pygitweb.plugin_registry import PluginHandle, PluginRegistry, current_plugin_id, plugin_load_context
+
+
+def test_plugin_load_context_sets_current_plugin() -> None:
+	previous = current_plugin_id()
+	with plugin_load_context("demo"):
+		assert current_plugin_id() == "demo"
+	assert current_plugin_id() == previous
+
+
+def test_plugin_handle_unload_exits_context() -> None:
+	events: list[str] = []
+
+	class Tracked(Action):
+		def __enter__(self) -> Self:
+			events.append("enter")
+			return self
+
+		def __exit__(
+			self,
+			exc_type: type[BaseException] | None,
+			exc_value: BaseException | None,
+			traceback: TracebackType | None,
+		) -> bool | None:
+			events.append("exit")
+			return None
+
+		def action_name(self) -> str:
+			return "tracked"
+
+		def action(self, project: str, req: Request | None = None) -> Response | None:
+			return None
+
+	import contextlib
+
+	stack = contextlib.ExitStack()
+	stack.enter_context(plugin_load_context("tracked"))
+	instance = stack.enter_context(Tracked())
+	handle = PluginHandle(plugin_id="tracked", _stack=stack, instance=instance)
+	assert current_plugin_id() == "tracked"
+	assert events == ["enter"]
+	handle.unload()
+	assert events == ["enter", "exit"]
+	assert current_plugin_id() != "tracked"
+
+
+def test_plugin_registry_load_and_unload() -> None:
+	registry = PluginRegistry()
+	registry.load_all()
+	registry.unload_all()
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index 62e492c..b9f87a4 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -11,6 +11,7 @@ license = "MIT"
 license-files = ["LICENSE"]
 requires-python = ">=3.11"
 dependencies = [
+    "abcreg>=0.1.2",
     "pygittools>=0.1.0",
     "fastapi[standard-no-fastapi-cloud-cli]>=0.104.0",
     "uvicorn[standard]>=0.24.0",
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py
index 2363401..389d1e9 100644
--- a/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/action_pytesthtml.py
@@ -2,6 +2,8 @@ from __future__ import annotations
 
 import subprocess
 from pathlib import Path
+from types import TracebackType
+from typing import Self
 
 from fastapi import Request
 from starlette.responses import Response
@@ -16,6 +18,17 @@ class ActionPytestHtml(Action):
 	ActionPytestHtml: Run pytest and render the results.
 	"""
 
+	def __enter__(self) -> Self:
+		return self
+
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> bool | None:
+		return None
+
 	def action_name(self) -> str:
 		return PYTESTHTML_ACTION_NAME
 
diff --git a/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py b/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py
index d609a0c..3e63227 100644
--- a/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py
+++ b/pygitweb_pytesthtml/pygitweb_pytesthtml/subpage_pytesthtml.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
 from pathlib import Path
+from types import TracebackType
+from typing import Self
 from urllib.parse import quote
 
 from pygitweb.api.subpage import Subpage
@@ -13,6 +15,17 @@ class SubpagePytestHtml(Subpage):
 	SubpagePytestHtml: Render the latest Pytest results for a project.
 	"""
 
+	def __enter__(self) -> Self:
+		return self
+
+	def __exit__(
+		self,
+		exc_type: type[BaseException] | None,
+		exc_value: BaseException | None,
+		traceback: TracebackType | None,
+	) -> bool | None:
+		return None
+
 	def subpage_name(self) -> str:
 		return "pytesthtml"
 
diff --git a/uv.lock b/uv.lock
index af2e766..1462f51 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,6 +12,15 @@ members = [
 ]
 
 [[package]]
+name = "abcreg"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/74/a5e6d286c4126d549d1c847a6e959183b345f978b507bc7e03e980bf7f89/abcreg-0.1.2.tar.gz", hash = "sha256:c601e7809401ba21817c6ce038fecee505afd2535da8e482e2ed2d491468152d", size = 18917, upload-time = "2026-06-23T18:27:15.423Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/50/90/1142e8f42806715e970ce6f004d104b34b7dd7a7c7172127016aae2a7a12/abcreg-0.1.2-py3-none-any.whl", hash = "sha256:9a25c51a38bbe20b5e2bf4e11fce63032b76d58a55f169ab1d248d2a85b84735", size = 3672, upload-time = "2026-06-23T18:27:14.263Z" },
+]
+
+[[package]]
 name = "annotated-doc"
 version = "0.0.4"
 source = { registry = "https://pypi.org/simple" }
@@ -954,6 +963,7 @@ name = "pygitweb"
 version = "0.1.0"
 source = { editable = "pygitweb" }
 dependencies = [
+    { name = "abcreg" },
     { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
     { name = "httpx" },
     { name = "jinja2" },
@@ -968,6 +978,7 @@ dependencies = [
 
 [package.metadata]
 requires-dist = [
+    { name = "abcreg", specifier = ">=0.1.2" },
     { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.104.0" },
     { name = "httpx", specifier = ">=0.27.0" },
     { name = "jinja2", specifier = ">=3.1.0" },
