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
+ ]