toggling inheritance (match configs)

These changes improve the plugin's ability to manage toggle states across different scopes the same way we handle plugin configs.

Alessandro committed Feb 26, 2026 at 10:51 UTC 601a5277ce7221e5c03d2345919bad2e21bff2ec
2 files changed +115 -31
python/api/plugins.py
+48
@@ -51,6 +51,54 @@ class Plugins(ApiHandler):
51 "data": settings,
52 }
53
54 + if action == "get_toggle_status":
55 + plugin_name = input.get("plugin_name", "")
56 + project_name = input.get("project_name", "")
57 + agent_profile = input.get("agent_profile", "")
58 + if not plugin_name:
59 + return Response(status=400, response="Missing plugin_name")
60 +
61 + meta = plugins.get_plugin_meta(plugin_name)
62 + if not meta:
63 + return Response(status=404, response="Plugin not found")
64 +
65 + if meta.always_enabled:
66 + return {
67 + "ok": True,
68 + "status": "enabled",
69 + "loaded_project_name": project_name,
70 + "loaded_agent_profile": agent_profile,
71 + "loaded_path": "",
72 + }
73 +
74 + result = plugins.find_plugin_assets(
75 + plugins.TOGGLE_FILE_PATTERN,
76 + plugin_name=plugin_name,
77 + project_name=project_name,
78 + agent_profile=agent_profile,
79 + only_first=True,
80 + )
81 +
82 + if result:
83 + entry = result[0]
84 + path = entry.get("path", "")
85 + status = "enabled" if path.endswith(plugins.ENABLED_FILE_NAME) else "disabled"
86 + return {
87 + "ok": True,
88 + "status": status,
89 + "loaded_project_name": entry.get("project_name", ""),
90 + "loaded_agent_profile": entry.get("agent_profile", ""),
91 + "loaded_path": path,
92 + }
93 +
94 + return {
95 + "ok": True,
96 + "status": "enabled",
97 + "loaded_project_name": "",
98 + "loaded_agent_profile": "",
99 + "loaded_path": "",
100 + }
101 +
102 if action == "list_configs":
103 plugin_name = input.get("plugin_name", "")
104 asset_type = input.get("asset_type", "config")
webui/components/plugins/toggle/plugin-toggle-store.js
+67 -31
@@ -22,8 +22,13 @@ const model = {
22 alwaysEnabled: false,
23 perProjectConfig: true,
24 perAgentConfig: true,
25 - explicitPath: null,
25 hasExplicitRuleForScope: false,
26 +
27 + // Where the effective toggle was actually resolved (mirrors plugin-settings-store loadedXxx fields)
28 + loadedPath: "",
29 + loadedProjectName: "",
30 + loadedAgentProfile: "",
31 +
32 configs: [],
33
34 async open(plugin) {
@@ -34,6 +39,9 @@ const model = {
39 this.projectName = "";
40 this.agentProfileKey = "";
41 this.configs = [];
42 + this.loadedPath = "";
43 + this.loadedProjectName = "";
44 + this.loadedAgentProfile = "";
45
46 const pluginName = typeof plugin === 'string' ? plugin : plugin?.name;
47 this.pluginName = pluginName;
@@ -61,6 +69,9 @@ const model = {
69 this.alwaysEnabled = false;
70 this.hasConfigScreen = false;
71 this.hasExplicitRuleForScope = false;
72 + this.loadedPath = "";
73 + this.loadedProjectName = "";
74 + this.loadedAgentProfile = "";
75 },
76
77 async loadProjects() {
@@ -106,7 +117,7 @@ const model = {
117 });
118 const result = await response.json().catch(() => ({}));
119 this.configs = result.ok ? (result.data || []) : [];
109 - this.calculateStatus();
120 + await this.loadToggleStatus();
121 } catch (e) {
122 this.error = e?.message || "Failed to load configurations";
123 } finally {
@@ -114,6 +125,46 @@ const model = {
125 }
126 },
127
128 + async loadToggleStatus() {
129 + if (!this.pluginName) return;
130 +
131 + if (this.alwaysEnabled) {
132 + this.status = 'enabled';
133 + this.hasExplicitRuleForScope = true;
134 + this.loadedPath = "";
135 + this.loadedProjectName = this.projectName || "";
136 + this.loadedAgentProfile = this.agentProfileKey || "";
137 + return;
138 + }
139 +
140 + try {
141 + const response = await fetchApi("/plugins", {
142 + method: "POST",
143 + headers: { "Content-Type": "application/json" },
144 + body: JSON.stringify({
145 + action: "get_toggle_status",
146 + plugin_name: this.pluginName,
147 + project_name: this.projectName || "",
148 + agent_profile: this.agentProfileKey || "",
149 + }),
150 + });
151 + const result = await response.json().catch(() => ({}));
152 + if (result.ok) {
153 + this.status = result.status || 'enabled';
154 + this.loadedPath = result.loaded_path || "";
155 + this.loadedProjectName = result.loaded_project_name || "";
156 + this.loadedAgentProfile = result.loaded_agent_profile || "";
157 + const p = this.projectName || "";
158 + const a = this.agentProfileKey || "";
159 + this.hasExplicitRuleForScope = !!(this.loadedPath) &&
160 + this.loadedProjectName === p &&
161 + this.loadedAgentProfile === a;
162 + }
163 + } catch (e) {
164 + this.error = e?.message || "Failed to load toggle status";
165 + }
166 + },
167 +
168 async openConfigWithScope() {
169 if (!this.pluginName) return;
170
@@ -172,31 +223,6 @@ const model = {
223 }
224 },
225
175 - calculateStatus() {
176 - if (this.alwaysEnabled) {
177 - this.explicitPath = null;
178 - this.hasExplicitRuleForScope = true;
179 - this.status = 'enabled';
180 - return;
181 - }
182 -
183 - const p = this.projectName || "";
184 - const a = this.agentProfileKey || "";
185 - const explicit = this.configs.find(
186 - c => (c.project_name||"") === p && (c.agent_profile||"") === a
187 - );
188 -
189 - if (explicit) {
190 - this.explicitPath = explicit.path;
191 - this.hasExplicitRuleForScope = true;
192 - this.status = explicit.path.endsWith(".toggle-1") ? 'enabled' : 'disabled';
193 - } else {
194 - this.explicitPath = null;
195 - this.hasExplicitRuleForScope = false;
196 - this.status = 'enabled'; // default when no explicit config
197 - }
198 - },
199 -
226 async setEnabled(enabled) {
227 if (!this.pluginName || this.alwaysEnabled) return;
228 this.isSaving = true;
@@ -224,7 +250,7 @@ const model = {
250 },
251
252 async onScopeChanged() {
227 - this.calculateStatus();
253 + await this.loadToggleStatus();
254
255 // Sync scope with settings store so its loadSettings picks up the right context
256 settingsStore.projectName = this.projectName || "";
@@ -254,9 +280,19 @@ const model = {
280
281 get noScopeRuleMessage() {
282 if (this.alwaysEnabled || this.isLoading) return "";
257 - if (this.configs.length === 0) return "No activation rules configured. Plugin defaults to ON.";
258 - if (!this.explicitPath) return "No rule set for this scope. Status defaults to ON.";
259 - return "";
283 + if (this.hasExplicitRuleForScope) return "";
284 +
285 + if (!this.loadedPath) {
286 + return this.configs.length === 0
287 + ? "No activation rules configured. Plugin defaults to ON."
288 + : "No rule for this scope. Defaults to ON.";
289 + }
290 +
291 + // Inherited from a parent scope - mirrors plugin-settings-store scopeMismatchMessage
292 + const pLabel = this.projectLabel(this.loadedProjectName || "");
293 + const aLabel = this.agentProfileLabel(this.loadedAgentProfile || "");
294 + const state = this.status === 'enabled' ? 'ON' : 'OFF';
295 + return `No rule for this scope. Inheriting from ${pLabel}, ${aLabel}: ${state}.`;
296 }
297 };
298