diff --git a/pygitweb/change_queue.py b/pygitweb/change_queue.py
index ebb1754..9594516 100644
--- a/pygitweb/change_queue.py
+++ b/pygitweb/change_queue.py
@@ -99,6 +99,25 @@ class ChangeQueue:
 			event.set()
 		return len(waiters)
 
+	async def notify_all(self) -> int:
+		"""Wake every current waiter. Returns the number of waiters woken."""
+		async with self._lock:
+			snapshot: list[tuple[str, asyncio.Event]] = [
+				(project, event) for project, bucket in self._waiters.items() for event in bucket
+			]
+			self._waiters.clear()
+		seen: set[int] = set()
+		woken = 0
+		for project, event in snapshot:
+			eid = id(event)
+			if eid in seen:
+				continue
+			seen.add(eid)
+			event._change_queue_project = project  # type: ignore[attr-defined]
+			event.set()
+			woken += 1
+		return woken
+
 	async def waiter_count(self, project: str | None = None) -> int:
 		"""Diagnostic: count waiters for one project (or total across all projects)."""
 		async with self._lock:
diff --git a/pygitweb/change_queue_test.py b/pygitweb/change_queue_test.py
index 3648c58..8f2ff1d 100644
--- a/pygitweb/change_queue_test.py
+++ b/pygitweb/change_queue_test.py
@@ -89,3 +89,38 @@ class TestChangeQueue:
 			return await waiter
 
 		assert asyncio.run(scenario()) is None
+
+	def test_notify_all_wakes_every_waiter(self) -> None:
+		async def scenario() -> tuple[int, str | None, str | None, int]:
+			queue: ChangeQueue = ChangeQueue()
+			w1: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["alpha"], timeout=2.0))
+			w2: asyncio.Task[str | None] = asyncio.create_task(queue.wait_for_changes(["beta"], timeout=2.0))
+			await asyncio.sleep(0)
+			woken: int = await queue.notify_all()
+			r1: str | None = await w1
+			r2: str | None = await w2
+			leftover: int = await queue.waiter_count()
+			return woken, r1, r2, leftover
+
+		woken, r1, r2, leftover = asyncio.run(scenario())
+		assert woken == 2
+		assert r1 == "alpha"
+		assert r2 == "beta"
+		assert leftover == 0
+
+	def test_notify_all_dedupes_multi_project_waiter(self) -> None:
+		async def scenario() -> tuple[int, str | None, int]:
+			queue: ChangeQueue = ChangeQueue()
+			waiter: asyncio.Task[str | None] = asyncio.create_task(
+				queue.wait_for_changes(["alpha", "beta"], timeout=2.0)
+			)
+			await asyncio.sleep(0)
+			woken: int = await queue.notify_all()
+			result: str | None = await waiter
+			leftover: int = await queue.waiter_count()
+			return woken, result, leftover
+
+		woken, result, leftover = asyncio.run(scenario())
+		assert woken == 1
+		assert result in {"alpha", "beta"}
+		assert leftover == 0
diff --git a/pygitweb/config.py b/pygitweb/config.py
index cf92125..f169382 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -121,6 +121,7 @@ class Settings(BaseSettings):
 	AUTH: bool | None = None
 	AUTH_CONFIG: str = ""
 	SETTINGS_CONFIG: str = ""
+	ENABLED_PLUGINS: list[str] = Field(default_factory=list)
 
 	@field_validator("AUTH", mode="before")
 	@classmethod
diff --git a/pygitweb/main.py b/pygitweb/main.py
index cf8acdb..256daea 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -51,7 +51,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,
@@ -99,7 +98,12 @@ 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_loader import (
+	DISPATCH_ACTIONS,
+	PLUGIN_ACTIONS,
+	SUBPAGES,
+	apply_plugin_reload,
+)
 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
@@ -191,9 +195,7 @@ 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())
+apply_plugin_reload()
 
 # Todo handle with nginx route
 _static_dir = Path(__file__).parent / "static"
diff --git a/pygitweb/plugin_loader.py b/pygitweb/plugin_loader.py
index 749dcbe..3bce93b 100644
--- a/pygitweb/plugin_loader.py
+++ b/pygitweb/plugin_loader.py
@@ -13,15 +13,49 @@ logger = logging.getLogger(__name__)
 SUBPAGE_GROUP = "pygitweb.subpages"
 ACTION_GROUP = "pygitweb.actions"
 
+PLUGIN_ACTIONS: dict[str, Action] = {}
+SUBPAGES: list[Subpage] = []
+DISPATCH_ACTIONS: set[str] = set(BUILTIN_ACTIONS)
+
 
 def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
 	return importlib.metadata.entry_points().select(group=group)
 
 
-def load_subpages() -> list[Subpage]:
+def _dist_name(ep: importlib.metadata.EntryPoint) -> str | None:
+	dist = ep.dist
+	if dist is None:
+		return None
+	return dist.name
+
+
+def _enabled_set(enabled: frozenset[str] | None) -> frozenset[str]:
+	if enabled is not None:
+		return enabled
+	from pygitweb.config import settings
+
+	return frozenset(settings.ENABLED_PLUGINS)
+
+
+def list_available_plugins() -> list[str]:
+	"""Unique sorted distribution names that declare pygitweb entry points."""
+	names: set[str] = set()
+	for group in (SUBPAGE_GROUP, ACTION_GROUP):
+		for ep in _entry_points(group):
+			name = _dist_name(ep)
+			if name:
+				names.add(name)
+	return sorted(names)
+
+
+def load_subpages(enabled: frozenset[str] | None = None) -> list[Subpage]:
+	allowed = _enabled_set(enabled)
 	out: list[Subpage] = []
 	seen_names: set[str] = set()
 	for ep in _entry_points(SUBPAGE_GROUP):
+		dist = _dist_name(ep)
+		if dist is None or dist not in allowed:
+			continue
 		try:
 			loaded = ep.load()
 		except Exception:
@@ -48,9 +82,13 @@ def load_subpages() -> list[Subpage]:
 	return out
 
 
-def load_plugin_actions() -> dict[str, Action]:
+def load_plugin_actions(enabled: frozenset[str] | None = None) -> dict[str, Action]:
+	allowed = _enabled_set(enabled)
 	out: dict[str, Action] = {}
 	for ep in _entry_points(ACTION_GROUP):
+		dist = _dist_name(ep)
+		if dist is None or dist not in allowed:
+			continue
 		try:
 			loaded = ep.load()
 		except Exception:
@@ -81,3 +119,23 @@ def load_plugin_actions() -> dict[str, Action]:
 			continue
 		out[name] = instance
 	return out
+
+
+def reload_plugins() -> tuple[dict[str, Action], list[Subpage]]:
+	"""Re-read ENABLED_PLUGINS and return freshly loaded actions and subpages."""
+	from pygitweb.config import settings
+
+	allowed = frozenset(settings.ENABLED_PLUGINS)
+	return load_plugin_actions(allowed), load_subpages(allowed)
+
+
+def apply_plugin_reload() -> None:
+	"""Reload plugin actions/subpages from ENABLED_PLUGINS into the runtime registry."""
+	actions, subpages = reload_plugins()
+	PLUGIN_ACTIONS.clear()
+	PLUGIN_ACTIONS.update(actions)
+	SUBPAGES.clear()
+	SUBPAGES.extend(subpages)
+	DISPATCH_ACTIONS.clear()
+	DISPATCH_ACTIONS.update(BUILTIN_ACTIONS)
+	DISPATCH_ACTIONS.update(PLUGIN_ACTIONS.keys())
diff --git a/pygitweb/plugin_loader_test.py b/pygitweb/plugin_loader_test.py
new file mode 100644
index 0000000..4679cce
--- /dev/null
+++ b/pygitweb/plugin_loader_test.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from fastapi import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pygitweb.api.action import Action
+from pygitweb.api.subpage import Subpage
+from pygitweb.plugin_loader import (
+	ACTION_GROUP,
+	DISPATCH_ACTIONS,
+	PLUGIN_ACTIONS,
+	SUBPAGE_GROUP,
+	SUBPAGES,
+	apply_plugin_reload,
+	list_available_plugins,
+	load_plugin_actions,
+	load_subpages,
+)
+
+
+class _DemoAction(Action):
+	def action_name(self) -> str:
+		return "demo_action"
+
+	def action(self, project: str, req: Request | None = None) -> Response | None:
+		return PlainTextResponse(project)
+
+
+class _OtherAction(Action):
+	def action_name(self) -> str:
+		return "other_action"
+
+	def action(self, project: str, req: Request | None = None) -> Response | None:
+		return PlainTextResponse(project)
+
+
+class _DemoSubpage(Subpage):
+	def subpage_name(self) -> str:
+		return "demo_subpage"
+
+
+def _ep(name: str, dist_name: str, loaded: object) -> SimpleNamespace:
+	return SimpleNamespace(
+		name=name,
+		dist=SimpleNamespace(name=dist_name),
+		load=lambda: loaded,
+	)
+
+
+def _fake_entry_points(group: str) -> list[SimpleNamespace]:
+	if group == ACTION_GROUP:
+		return [
+			_ep("demo", "pygitweb_demo", _DemoAction),
+			_ep("other", "pygitweb_other", _OtherAction),
+		]
+	if group == SUBPAGE_GROUP:
+		return [_ep("demo_sp", "pygitweb_demo", _DemoSubpage)]
+	return []
+
+
+def test_list_available_plugins_without_enablement() -> None:
+	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+		assert list_available_plugins() == ["pygitweb_demo", "pygitweb_other"]
+
+
+def test_load_skips_disabled_plugins() -> None:
+	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+		assert load_plugin_actions(frozenset()) == {}
+		assert load_subpages(frozenset()) == []
+		actions = load_plugin_actions(frozenset({"pygitweb_demo"}))
+		assert set(actions) == {"demo_action"}
+		subpages = load_subpages(frozenset({"pygitweb_demo"}))
+		assert [s.subpage_name() for s in subpages] == ["demo_subpage"]
+
+
+def test_apply_plugin_reload_updates_registry(monkeypatch) -> None:
+	from pygitweb.config import settings
+
+	previous = list(settings.ENABLED_PLUGINS)
+	monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_other"])
+	with patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points):
+		try:
+			apply_plugin_reload()
+			assert set(PLUGIN_ACTIONS) == {"other_action"}
+			assert SUBPAGES == []
+			assert "other_action" in DISPATCH_ACTIONS
+			assert "demo_action" not in DISPATCH_ACTIONS
+
+			monkeypatch.setattr(settings, "ENABLED_PLUGINS", ["pygitweb_demo"])
+			apply_plugin_reload()
+			assert set(PLUGIN_ACTIONS) == {"demo_action"}
+			assert [s.subpage_name() for s in SUBPAGES] == ["demo_subpage"]
+			assert "demo_action" in DISPATCH_ACTIONS
+			assert "other_action" not in DISPATCH_ACTIONS
+		finally:
+			monkeypatch.setattr(settings, "ENABLED_PLUGINS", previous)
+			apply_plugin_reload()
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index 70d34db..b4b85ee 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -15,9 +15,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.auth import require_permission
+from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import settings
 from pygitweb.dependencies import ValidatedReadableSettingsProject, ValidatedWritableSettingsProject
 from pygitweb.permissions import Permission
+from pygitweb.plugin_loader import apply_plugin_reload, list_available_plugins
 from pygitweb.settings_config import settings_batch_update
 from pygitweb.shutdown import request_graceful_shutdown
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
@@ -294,8 +296,13 @@ def settings_pygitweb_page(request: Request):
 	for name, val, value_type, doc in _get_pygitweb_values():
 		fields_html.append(_render_form_field(name, val, value_type, doc))
 	form_body = "\n".join(fields_html)
+	enabled = set(settings.ENABLED_PLUGINS)
+	plugin_rows = [{"name": name, "checked": name in enabled} for name in list_available_plugins()]
 	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - PyGitWeb settings", site_name=settings.SITE_NAME)
-	body = env.get_template("pygitweb_settings.html").render(form_body=form_body)
+	body = env.get_template("pygitweb_settings.html").render(
+		form_body=form_body,
+		plugin_rows=plugin_rows,
+	)
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
@@ -321,6 +328,19 @@ async def settings_pygitweb_submit(request: Request):
 	return RedirectResponse(url="/settings/pygitweb", status_code=303)
 
 
+@router.post("/pygitweb/plugins", response_class=HTMLResponse)
+async def settings_pygitweb_plugins(request: Request):
+	"""Enable/disable installed plugins and reload the runtime registry immediately."""
+	form = await request.form()
+	available = set(list_available_plugins())
+	selected = [str(v) for v in form.getlist("plugin") if str(v) in available]
+	with settings_batch_update(settings):
+		settings.ENABLED_PLUGINS = selected
+	apply_plugin_reload()
+	await CHANGE_QUEUE.notify_all()
+	return RedirectResponse(url="/settings/pygitweb", status_code=303)
+
+
 @router.post("/pygitweb/shutdown", response_class=HTMLResponse)
 async def settings_pygitweb_shutdown(request: Request, background_tasks: BackgroundTasks) -> HTMLResponse:
 	"""Gracefully stop the PyGitWeb server process."""
diff --git a/pygitweb/settings.schema.json b/pygitweb/settings.schema.json
index c40b9ad..9eb98d6 100644
--- a/pygitweb/settings.schema.json
+++ b/pygitweb/settings.schema.json
@@ -54,6 +54,13 @@
     "SETTINGS_CONFIG": {
       "type": "string",
       "description": "Path to this settings JSON file. Empty uses ~/.pygitweb/settings.json."
+    },
+    "ENABLED_PLUGINS": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      },
+      "description": "Distribution names of installed plugins whose entry points are active. Empty means all discovered plugins are off."
     }
   },
   "required": [
@@ -68,6 +75,7 @@
     "MAXLOAD",
     "AUTH",
     "AUTH_CONFIG",
-    "SETTINGS_CONFIG"
+    "SETTINGS_CONFIG",
+    "ENABLED_PLUGINS"
   ]
 }
diff --git a/pygitweb/settings_config_test.py b/pygitweb/settings_config_test.py
index 77de4ce..426eb0d 100644
--- a/pygitweb/settings_config_test.py
+++ b/pygitweb/settings_config_test.py
@@ -99,3 +99,13 @@ def test_write_settings_file_roundtrip(settings_path: Path) -> None:
 	loaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
 	assert loaded.SITE_NAME == "Roundtrip"
 	assert loaded.AUTH is False
+
+
+def test_enabled_plugins_roundtrip(settings_path: Path) -> None:
+	settings = init_settings()
+	assert settings.ENABLED_PLUGINS == []
+	settings.ENABLED_PLUGINS = ["pygitweb_demo", "pygitweb_other"]
+	data = json.loads(settings_path.read_text(encoding="utf-8"))
+	assert data["ENABLED_PLUGINS"] == ["pygitweb_demo", "pygitweb_other"]
+	reloaded = Settings.model_validate_json(settings_path.read_text(encoding="utf-8"))
+	assert reloaded.ENABLED_PLUGINS == ["pygitweb_demo", "pygitweb_other"]
diff --git a/pygitweb/settings_plugins_test.py b/pygitweb/settings_plugins_test.py
new file mode 100644
index 0000000..cb8506e
--- /dev/null
+++ b/pygitweb/settings_plugins_test.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from pygitweb.config import settings
+from pygitweb.main import app
+from pygitweb.plugin_loader import PLUGIN_ACTIONS, apply_plugin_reload
+from pygitweb.plugin_loader_test import _fake_entry_points
+
+
+@pytest.fixture
+def client() -> Generator[TestClient, None, None]:
+	with TestClient(app) as c:
+		yield c
+
+
+def test_plugins_page_lists_available(client: TestClient) -> None:
+	with (
+		patch.object(settings, "AUTH", False),
+		patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo"]),
+		patch.object(settings, "ENABLED_PLUGINS", []),
+	):
+		r = client.get("/settings/pygitweb")
+	assert r.status_code == 200
+	assert b'name="plugin" value="pygitweb_demo"' in r.content
+	assert b'id="plugin_pygitweb_demo"' in r.content
+	assert b"checked" not in r.content.split(b'id="plugin_pygitweb_demo"')[1].split(b">")[0]
+
+
+def test_plugins_submit_enables_and_reloads(client: TestClient) -> None:
+	notify = AsyncMock(return_value=0)
+	previous = list(settings.ENABLED_PLUGINS)
+	try:
+		settings.ENABLED_PLUGINS = []
+		with (
+			patch.object(settings, "AUTH", False),
+			patch("pygitweb.plugin_loader._entry_points", side_effect=_fake_entry_points),
+			patch("pygitweb.settings.list_available_plugins", return_value=["pygitweb_demo", "pygitweb_other"]),
+			patch("pygitweb.settings.CHANGE_QUEUE.notify_all", notify),
+		):
+			apply_plugin_reload()
+			assert PLUGIN_ACTIONS == {}
+			r = client.post(
+				"/settings/pygitweb/plugins",
+				data={"plugin": "pygitweb_demo"},
+				follow_redirects=False,
+			)
+			assert r.status_code == 303
+			assert settings.ENABLED_PLUGINS == ["pygitweb_demo"]
+			assert set(PLUGIN_ACTIONS) == {"demo_action"}
+			notify.assert_awaited_once()
+	finally:
+		settings.ENABLED_PLUGINS = previous
+		apply_plugin_reload()
diff --git a/pygitweb/templates/pygitweb_settings.html b/pygitweb/templates/pygitweb_settings.html
index e2cbbbb..d4ad09b 100644
--- a/pygitweb/templates/pygitweb_settings.html
+++ b/pygitweb/templates/pygitweb_settings.html
@@ -12,6 +12,29 @@
 </div>
 <div class="card mt-3">
   <div class="card-body">
+    <h2 class="card-title">Plugins</h2>
+    <p class="text-secondary">Installed plugins are off by default. Enable only those you want active; changes apply immediately.</p>
+    <form action="/settings/pygitweb/plugins" method="post">
+      {% if plugin_rows %}
+        {% for row in plugin_rows %}
+        <div class="mb-3">
+          <div class="form-check">
+            <input type="checkbox" class="form-check-input" id="plugin_{{ row.name }}" name="plugin" value="{{ row.name }}"{% if row.checked %} checked{% endif %}>
+            <label class="form-check-label" for="plugin_{{ row.name }}">{{ row.name }}</label>
+          </div>
+        </div>
+        {% endfor %}
+      {% else %}
+        <p class="text-muted">No installed plugins with pygitweb entry points were found.</p>
+      {% endif %}
+      <div class="form-footer">
+        <button type="submit" class="btn btn-primary"{% if not plugin_rows %} disabled{% endif %}>Save plugins</button>
+      </div>
+    </form>
+  </div>
+</div>
+<div class="card mt-3">
+  <div class="card-body">
     <h2 class="card-title">Server</h2>
     <p class="text-secondary">Stop the PyGitWeb process gracefully. Long-poll connections are released, then the server exits.</p>
     <form action="/settings/pygitweb/shutdown" method="post" onsubmit="return confirm('Shut down PyGitWeb now?');">
