Avoid reload prompts for scoped plugin changes

Keep plugin cache invalidation and Python module refreshes for project- and agent-scoped changes, but reserve frontend reload notifications for global plugin changes that can affect the loaded WebUI bundle. Cover watchdog and direct-toggle paths with focused regressions and document the scoped lifecycle contract.

Alessandro committed Aug 18, 2026 at 14:02 UTC 758f74fd7578cd6b890c5cf8d0f2d3cae8446bfc
3 files changed +61 -9
helpers/plugins.py
+18 -7
@@ -121,7 +121,7 @@ class PluginUpdateInfo(BaseModel):
121
122 def register_watchdogs():
123
124 - def on_plugin_change(events: list[WatchItem]):
124 + def on_plugin_change(events: list[WatchItem], frontend_reload: bool = True):
125 plugin_names: list[str] = []
126 for path, _event in events:
127 path = path.replace("\\", "/")
@@ -132,7 +132,11 @@ def register_watchdogs():
132 plugin_names.append(plugin_name)
133 print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
134 python_change = any(path.endswith('.py') for path, _event in events)
135 - after_plugin_change(plugin_names or None, python_change=python_change)
135 + after_plugin_change(
136 + plugin_names or None,
137 + python_change=python_change,
138 + frontend_reload=frontend_reload,
139 + )
140
141 relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
142
@@ -162,7 +166,7 @@ def register_watchdogs():
166 *expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"),
167 *expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"),
168 ],
165 - handler=on_plugin_change,
169 + handler=lambda events: on_plugin_change(events, frontend_reload=False),
170 )
171
172 # add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins
@@ -173,16 +177,21 @@ def register_watchdogs():
177 files.get_abs_path(subagents.USER_AGENTS_DIR),
178 ],
179 patterns=[*expand_patterns(f"*/plugins/*/")],
176 - handler=on_plugin_change,
180 + handler=lambda events: on_plugin_change(events, frontend_reload=False),
181 )
182
183
184 @extension.extensible
181 -def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False):
185 +def after_plugin_change(
186 + plugin_names: list[str] | None = None,
187 + python_change: bool = False,
188 + frontend_reload: bool = True,
189 +):
190 clear_plugin_cache(plugin_names)
191 if python_change:
192 refresh_plugin_modules(plugin_names)
185 - send_frontend_reload_notification(plugin_names)
193 + if frontend_reload:
194 + send_frontend_reload_notification(plugin_names)
195
196
197 def refresh_plugin_modules(plugin_names: list[str] | None = None):
@@ -582,7 +591,9 @@ def toggle_plugin(
591 files.write_file(enabled_file, "")
592 else:
593 files.write_file(disabled_file, "")
585 - after_plugin_change([plugin_name])
594 + after_plugin_change(
595 + [plugin_name], frontend_reload=not (project_name or agent_profile)
596 + )
597
598
599 @extension.extensible
helpers/plugins.py.dox.md
+3 -1
@@ -17,7 +17,7 @@
17 - `PluginUpdateInfo` (`BaseModel`)
18 - Top-level functions:
19 - `register_watchdogs()`
20 -- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=...)`
20 +- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=..., frontend_reload: bool=...)`
21 - `refresh_plugin_modules(plugin_names: list[str] | None=...)`
22 - `clear_plugin_cache(plugin_names: list[str] | None=...)`
23 - `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first).
@@ -53,6 +53,8 @@
53 stale global or scoped disable files, and disable attempts are rejected.
54 - Config hooks receive `hook_context={"caller": caller}` with one of `ui`,
55 `agent`, or `api`; this is behavioral context, not an authorization boundary.
56 +- Project- and agent-scoped plugin changes invalidate runtime caches without a
57 + frontend reload prompt because the loaded WebUI extension bundle is global.
58 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
59 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
60 - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`.
tests/test_plugin_activation_ui.py
+40 -1
@@ -147,9 +147,44 @@ def test_scoped_plugin_without_settings_form_exposes_configuration_index():
147 assert "context.pluginMeta?.has_config_screen" in settings_html
148
149
150 +def test_scoped_plugin_watchdogs_skip_frontend_reload(monkeypatch):
151 + handlers = {}
152 + changes = []
153 + monkeypatch.setattr(
154 + plugins.watchdog,
155 + "add_watchdog",
156 + lambda **kwargs: handlers.setdefault(kwargs["id"], kwargs["handler"]),
157 + )
158 + monkeypatch.setattr(
159 + plugins,
160 + "after_plugin_change",
161 + lambda names=None, python_change=False, frontend_reload=True: changes.append(
162 + (names, python_change, frontend_reload)
163 + ),
164 + )
165 +
166 + plugins.register_watchdogs()
167 + handlers["plugins_agents"](
168 + [["/tmp/usr/agents/custom/plugins/_code_execution/.toggle-0", "delete"]]
169 + )
170 + handlers["plugins_roots"](
171 + [["/tmp/plugins/_code_execution/.toggle-0", "delete"]]
172 + )
173 +
174 + assert changes == [
175 + (["_code_execution"], False, False),
176 + (["_code_execution"], False, True),
177 + ]
178 +
179 +
180 def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypatch):
181 monkeypatch.setattr(files, "_base_dir", str(tmp_path))
152 - monkeypatch.setattr(plugins, "after_plugin_change", lambda *_args, **_kwargs: None)
182 + changes = []
183 + monkeypatch.setattr(
184 + plugins,
185 + "after_plugin_change",
186 + lambda names, **kwargs: changes.append((names, kwargs)),
187 + )
188 monkeypatch.setitem(
189 sys.modules,
190 "helpers.projects",
@@ -175,3 +210,7 @@ def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypat
210
211 assert (scoped_plugin_dir / ".toggle-1").exists()
212 assert not (scoped_plugin_dir / ".toggle-0").exists()
213 + assert changes == [
214 + (["example"], {"frontend_reload": False}),
215 + (["example"], {"frontend_reload": False}),
216 + ]