Add scoped plugin config discovery and UI

Introduce support for scoped plugin configurations and a small WebUI to list/switch them. Rename plugin memory config to config.default.json and set memory plugin per_agent_config to false. API changes: Plugins.get_config now searches project/agent-specific configs, falls back to config.default.json, and returns loaded_path/loaded_project_name/loaded_agent_profile metadata. Helper updates: add CONFIG_DEFAULT_FILE_NAME, enhance get_plugin_config to consider project and agent profile, replace/find wrapper with find_plugin_assets which returns matching paths and scope metadata, and minor refactors in plugin path handling. WebUI: add plugin-configs.html and update plugin-settings-store.js and plugin-settings.html to expose scope info, config listing modal, and scope-mismatch messaging.

frdel committed Feb 20, 2026 at 12:51 UTC da20abc18441a70f27edb59937b7febaf08ab1f8
7 files changed +356 -76
plugins/memory/config.default.json renamed
plugins/memory/plugin.json
+1 -1
@@ -4,5 +4,5 @@
4 "version": "1.0.0",
5 "settings_sections": ["agent"],
6 "per_project_config": true,
7 - "per_agent_config": true
7 + "per_agent_config": false
8 }
python/api/plugins.py
+29 -15
@@ -12,30 +12,44 @@ class Plugins(ApiHandler):
12 action = input.get("action", "get_config")
13
14 # Accept legacy aliases during migration.
15 - if action in {"get_config", "get_settings"}:
15 + if action == "get_config":
16 plugin_name = input.get("plugin_name", "")
17 project_name = input.get("project_name", "")
18 agent_profile = input.get("agent_profile", "")
19 if not plugin_name:
20 return Response(status=400, response="Missing plugin_name")
21
22 - settings = None
23 - if project_name or agent_profile:
24 - file_path = plugins.determine_plugin_asset_path(
25 - plugin_name,
26 - project_name,
27 - agent_profile,
28 - plugins.CONFIG_FILE_NAME,
22 + result = plugins.find_plugin_assets(
23 + plugins.CONFIG_FILE_NAME,
24 + plugin_name=plugin_name,
25 + project_name=project_name,
26 + agent_profile=agent_profile,
27 + only_first=True,
28 + )
29 + if result:
30 + entry = result[0]
31 + path = entry.get("path", "")
32 + settings = files.read_file_json(path) if path else {}
33 + loaded_project_name = entry.get("project_name", "")
34 + loaded_agent_profile = entry.get("agent_profile", "")
35 + else:
36 + settings = plugins.get_plugin_config(plugin_name, agent=None) or {}
37 + default_path = files.get_abs_path(
38 + plugins.find_plugin_dir(plugin_name), plugins.CONFIG_DEFAULT_FILE_NAME
39 )
30 - if files.exists(file_path):
31 - settings = files.read_file_json(file_path)
40 + path = default_path if files.exists(default_path) else ""
41 + loaded_project_name = ""
42 + loaded_agent_profile = ""
43
33 - if settings is None:
34 - settings = plugins.get_plugin_config(plugin_name, agent=None)
44 + return {
45 + "ok": True,
46 + "loaded_path": path,
47 + "loaded_project_name": loaded_project_name,
48 + "loaded_agent_profile": loaded_agent_profile,
49 + "data": settings,
50 + }
51
36 - return {"ok": True, "data": settings or {}}
37 -
38 - if action in {"save_config", "save_settings"}:
52 + if action == "save_config":
53 plugin_name = input.get("plugin_name", "")
54 project_name = input.get("project_name", "")
55 agent_profile = input.get("agent_profile", "")
python/helpers/plugins.py
+111 -59
@@ -18,6 +18,7 @@ _META_TARGET_RE = re.compile(
18
19 META_FILE_NAME = "plugin.json"
20 CONFIG_FILE_NAME = "config.json"
21 +CONFIG_DEFAULT_FILE_NAME = "config.default.json"
22 DISABLED_FILE_NAME = ".disabled"
23 ENABLED_FILE_NAME = ".enabled"
24
@@ -140,7 +141,8 @@ def get_plugin_paths(*subpaths: str) -> List[str]:
141 )
142 return paths
143
143 -def get_enabled_plugin_paths(agent:Agent|None, *subpaths: str) -> List[str]:
144 +
145 +def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
146 enabled = get_enabled_plugins(agent)
147 paths: list[str] = []
148
@@ -167,7 +169,7 @@ def get_enabled_plugins(agent: Agent | None):
169
170 if agent:
171 from python.helpers import subagents
170 -
172 +
173 for plugin in plugins:
174 # plugins are toggled via .enabled / .disabled files
175 # every plugin is on by default, unless disabled in usr dir
@@ -175,29 +177,31 @@ def get_enabled_plugins(agent: Agent | None):
177
178 if agent:
179 agent_paths = subagents.get_paths(
178 - agent,
179 - files.PLUGINS_DIR,
180 - plugin,
181 - must_exist_completely=True,
182 - include_default=False,
183 - include_user=True,
184 - include_plugins=False,
185 - include_project=True
186 - )
180 + agent,
181 + files.PLUGINS_DIR,
182 + plugin,
183 + must_exist_completely=True,
184 + include_default=False,
185 + include_user=True,
186 + include_plugins=False,
187 + include_project=True,
188 + )
189
190 # go through agent paths in reverse order and determine the state
191 for agent_path in reversed(agent_paths):
192 if enabled:
191 - enabled = not files.exists(files.get_abs_path(agent_path, DISABLED_FILE_NAME))
193 + enabled = not files.exists(
194 + files.get_abs_path(agent_path, DISABLED_FILE_NAME)
195 + )
196 else:
193 - enabled = files.exists(files.get_abs_path(agent_path, ENABLED_FILE_NAME))
194 -
197 + enabled = files.exists(
198 + files.get_abs_path(agent_path, ENABLED_FILE_NAME)
199 + )
200
201 if enabled:
202 active.append(plugin)
198 -
199 - return active
203
204 + return active
205
206
207 def get_webui_extensions(extension_point: str, filters: List[str] | None = None):
@@ -213,9 +217,22 @@ def get_webui_extensions(extension_point: str, filters: List[str] | None = None)
217 return entries
218
219
216 -def get_plugin_config(plugin_name: str, agent: Agent | None):
217 - file_path = find_plugin_asset(plugin_name, CONFIG_FILE_NAME, agent=agent)
218 - if file_path:
220 +def get_plugin_config(plugin_name: str, agent: Agent | None, project_name:str|None=None, agent_profile:str|None=None):
221 +
222 + if project_name is None and agent is not None:
223 + from python.helpers import projects
224 + project_name = projects.get_context_project_name(agent.context)
225 + if agent_profile is None and agent is not None:
226 + agent_profile = agent.config.profile
227 +
228 + # find config.json in all possible places
229 + file_path = find_plugin_asset(plugin_name, CONFIG_FILE_NAME, project_name=project_name, agent_profile=agent_profile)
230 + # use default config if not found
231 + if not file_path:
232 + file_path = files.get_abs_path(
233 + find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
234 + )
235 + if file_path and files.exists(file_path):
236 return json.loads(files.read_file(file_path))
237 return None
238
@@ -230,73 +247,108 @@ def save_plugin_config(
247 files.write_file(file_path, json.dumps(settings))
248
249
233 -def find_plugin_asset(plugin_name: str, *subpaths: str, agent: Agent | None = None):
234 - project_name = ""
250 +def find_plugin_asset(plugin_name: str, *subpaths: str, project_name="", agent_profile=""):
251 + result = find_plugin_assets(
252 + *subpaths,
253 + plugin_name=plugin_name,
254 + project_name=project_name,
255 + agent_profile=agent_profile,
256 + only_first=True
257 + )
258 + return result[0]["path"] if result else None
259
236 - if agent:
237 - profile_name = agent.config.profile if agent and agent.config.profile else ""
260
239 - from python.helpers import projects
261 +def find_plugin_assets(
262 + *subpaths: str,
263 + plugin_name: str = "*",
264 + project_name: str = "*",
265 + agent_profile: str = "*",
266 + only_first: bool = False,
267 +) -> list[dict]:
268 + from python.helpers import projects, subagents
269
241 - project_name = projects.get_context_project_name(agent.context) or ""
270 + results: list[dict] = []
271
243 - if project_name and profile_name:
244 - # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
245 - project_agent_file = projects.get_project_meta(
272 + def _collect(path: str, proj: str, profile: str) -> bool:
273 + matched_paths = (
274 + files.find_existing_paths_by_pattern(path)
275 + if "*" in path
276 + else ([path] if files.exists(path) else [])
277 + )
278 + for matched in matched_paths:
279 + results.append(
280 + {"project_name": proj, "agent_profile": profile, "path": matched}
281 + )
282 + if only_first:
283 + return True
284 +
285 + # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
286 + if project_name:
287 + if agent_profile:
288 + path = projects.get_project_meta(
289 project_name,
290 files.AGENTS_DIR,
248 - profile_name,
291 + agent_profile,
292 files.PLUGINS_DIR,
293 plugin_name,
294 *subpaths,
295 )
253 - if files.exists(project_agent_file):
254 - return project_agent_file
255 -
256 - if project_name:
296 + if _collect(path, project_name, agent_profile):
297 + return results
298 + else:
299 # project/.a0proj/plugins/<plugin_name>/...
258 - project_file = projects.get_project_meta(
300 + path = projects.get_project_meta(
301 project_name, files.PLUGINS_DIR, plugin_name, *subpaths
302 )
261 - if files.exists(project_file):
262 - return project_file
263 -
264 - if profile_name:
265 - from python.helpers import subagents
303 + if _collect(path, project_name, ""):
304 + return results
305
267 - # usr/agents/<profile>/plugins/<plugin_name>/...
268 - path = files.get_abs_path(
269 - subagents.USER_AGENTS_DIR,
270 - profile_name,
271 - files.PLUGINS_DIR,
272 - plugin_name,
273 - *subpaths,
274 - )
275 - if files.exists(path):
276 - return path
306 + # usr/agents/<profile>/plugins/<plugin_name>/...
307 + if agent_profile:
308 + path = files.get_abs_path(
309 + subagents.USER_AGENTS_DIR,
310 + agent_profile,
311 + files.PLUGINS_DIR,
312 + plugin_name,
313 + *subpaths,
314 + )
315 + if _collect(path, "", agent_profile):
316 + return results
317
278 - # agents/<profile>/plugins/<plugin_name>/...
318 + # usr?/plugins/<any_plugin>/agents/<profile>/plugins/<plugin_name>/...
319 + for plugin_base in get_enabled_plugin_paths(None):
320 path = files.get_abs_path(
280 - subagents.DEFAULT_AGENTS_DIR,
281 - profile_name,
321 + plugin_base,
322 + files.AGENTS_DIR,
323 + agent_profile,
324 files.PLUGINS_DIR,
325 plugin_name,
326 *subpaths,
327 )
286 - if files.exists(path):
287 - return path
328 + if _collect(path, "", agent_profile):
329 + return results
330 +
331 + # agents/<profile>/plugins/<plugin_name>/...
332 + path = files.get_abs_path(
333 + subagents.DEFAULT_AGENTS_DIR,
334 + agent_profile,
335 + files.PLUGINS_DIR,
336 + plugin_name,
337 + *subpaths,
338 + )
339 + if _collect(path, "", agent_profile):
340 + return results
341
342 # usr/plugins/<plugin_name>/...
343 path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, *subpaths)
291 - if files.exists(path):
292 - return path
344 + if _collect(path, "", ""):
345 + return results
346
347 # plugins/<plugin_name>/...
348 path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, *subpaths)
296 - if files.exists(path):
297 - return path
349 + _collect(path, "", "")
350
299 - return None
351 + return results
352
353
354 def determine_plugin_asset_path(
webui/components/plugins/plugin-configs.html new
+112
@@ -0,0 +1,112 @@
1 +<html>
2 +<head>
3 + <title>Plugin Configurations</title>
4 + <script type="module">
5 + import { store } from "/components/plugins/plugin-settings-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginSettings">
11 + <div x-create="$store.pluginSettings.loadConfigList()">
12 +
13 + <div x-show="$store.pluginSettings.configsError" class="plugin-configs-error">
14 + <span class="material-symbols-outlined">error</span>
15 + <span x-text="$store.pluginSettings.configsError"></span>
16 + </div>
17 +
18 + <div x-show="$store.pluginSettings.isListingConfigs" class="plugin-configs-loading">
19 + <span class="material-symbols-outlined spinning">progress_activity</span>
20 + <span>Loading configurations...</span>
21 + </div>
22 +
23 + <div x-show="!$store.pluginSettings.isListingConfigs" class="plugin-configs-list">
24 + <template x-if="($store.pluginSettings.configs || []).length === 0">
25 + <div class="plugin-configs-empty">No configurations found.</div>
26 + </template>
27 +
28 + <template x-for="cfg in ($store.pluginSettings.configs || [])" :key="(cfg.project_name || '') + '|' + (cfg.agent_profile || '')">
29 + <div class="plugin-configs-row">
30 + <div class="plugin-configs-scope">
31 + <div class="plugin-configs-scope-title" x-text="$store.pluginSettings.projectLabel(cfg.project_name || '')"></div>
32 + <div class="plugin-configs-scope-sub" x-text="$store.pluginSettings.agentProfileLabel(cfg.agent_profile || '')"></div>
33 + </div>
34 +
35 + <div class="plugin-configs-actions">
36 + <button type="button" class="button" @click="$store.pluginSettings.switchToConfig(cfg.project_name || '', cfg.agent_profile || '')">
37 + <span class="icon material-symbols-outlined">list</span>
38 + Switch
39 + </button>
40 + <button type="button" class="button cancel" @click="$store.pluginSettings.deleteConfig(cfg.project_name || '', cfg.agent_profile || '')">
41 + <span class="icon material-symbols-outlined">delete</span>
42 + Delete
43 + </button>
44 + </div>
45 + </div>
46 + </template>
47 + </div>
48 +
49 + </div>
50 + </template>
51 + </div>
52 +
53 + <style>
54 + .plugin-configs-loading,
55 + .plugin-configs-error {
56 + display: flex;
57 + align-items: center;
58 + gap: 0.5rem;
59 + padding: 0.5rem 0.75rem;
60 + margin-bottom: 0.75rem;
61 + font-size: var(--font-size-small);
62 + }
63 +
64 + .plugin-configs-error {
65 + color: var(--color-error);
66 + background: rgba(255, 0, 0, 0.07);
67 + border: 1px solid rgba(255, 0, 0, 0.2);
68 + border-radius: 4px;
69 + }
70 +
71 + .plugin-configs-list {
72 + display: flex;
73 + flex-direction: column;
74 + gap: 0.5rem;
75 + }
76 +
77 + .plugin-configs-row {
78 + display: flex;
79 + align-items: center;
80 + justify-content: space-between;
81 + gap: 0.75rem;
82 + padding: 0.75rem;
83 + border: 1px solid var(--color-border);
84 + border-radius: 6px;
85 + background: var(--color-surface);
86 + }
87 +
88 + .plugin-configs-scope-title {
89 + font-weight: 600;
90 + }
91 +
92 + .plugin-configs-scope-sub {
93 + font-size: var(--font-size-small);
94 + color: var(--color-text-secondary);
95 + margin-top: 0.2rem;
96 + }
97 +
98 + .plugin-configs-actions {
99 + display: flex;
100 + gap: 0.5rem;
101 + flex-wrap: wrap;
102 + justify-content: flex-end;
103 + }
104 +
105 + .plugin-configs-empty {
106 + padding: 0.75rem;
107 + color: var(--color-text-secondary);
108 + font-size: var(--font-size-small);
109 + }
110 + </style>
111 +</body>
112 +</html>
webui/components/plugins/plugin-settings-store.js
+78
@@ -16,6 +16,75 @@ const model = {
16 // plugin settings data (plugins bind their fields here)
17 settings: {},
18
19 + // where the settings were actually loaded from
20 + loadedPath: "",
21 + loadedProjectName: "",
22 + loadedAgentProfile: "",
23 +
24 + projectLabel(key) {
25 + if (!key) return "Global";
26 + const found = (this.projects || []).find((p) => p.key === key);
27 + return found?.label || key;
28 + },
29 +
30 + agentProfileLabel(key) {
31 + if (!key) return "All profiles";
32 + const found = (this.agentProfiles || []).find((p) => p.key === key);
33 + return found?.label || key;
34 + },
35 +
36 + get scopeMismatchMessage() {
37 + const selectedProject = this.projectName || "";
38 + const selectedProfile = this.agentProfileKey || "";
39 + const loadedProject = this.loadedProjectName || "";
40 + const loadedProfile = this.loadedAgentProfile || "";
41 +
42 + if (!this.loadedPath) return "";
43 + if (selectedProject === loadedProject && selectedProfile === loadedProfile) return "";
44 +
45 + return `Settings do not yet exist for this combination, settings from ${this.projectLabel(loadedProject)}, ${this.agentProfileLabel(loadedProfile)} (${this.loadedPath}) will apply.`;
46 + },
47 +
48 + configs: [],
49 + isListingConfigs: false,
50 + configsError: null,
51 +
52 + async openConfigListModal() {
53 + await window.openModal?.("/components/plugins/plugin-configs.html");
54 + },
55 +
56 + async loadConfigList() {
57 + if (!this.pluginName) return;
58 + this.isListingConfigs = true;
59 + this.configsError = null;
60 + try {
61 + // TODO: list existing plugin config scopes without API calls
62 + this.configs = [];
63 + } catch (e) {
64 + this.configsError = e?.message || "Failed to load configurations";
65 + this.configs = [];
66 + } finally {
67 + this.isListingConfigs = false;
68 + }
69 + },
70 +
71 + async switchToConfig(projectName, agentProfile) {
72 + this.projectName = projectName || "";
73 + this.agentProfileKey = agentProfile || "";
74 + await this.loadSettings();
75 + await window.closeModal?.();
76 + },
77 +
78 + async deleteConfig(projectName, agentProfile) {
79 + if (!this.pluginName) return;
80 + try {
81 + // TODO: delete existing plugin config scope without API calls
82 + this.configsError = "Delete is not implemented yet";
83 + } catch (e) {
84 + this.configsError = e?.message || "Delete failed";
85 + }
86 + },
87 +
88 // 'plugin' = save to plugin settings API
89 // 'core' = save via $store.settings.saveSettings() (for plugins that surface core settings)
90 saveMode: 'plugin',
@@ -33,6 +102,9 @@ const model = {
102 this.saveMode = 'plugin';
103 this.projectName = "";
104 this.agentProfileKey = "";
105 + this.loadedPath = "";
106 + this.loadedProjectName = "";
107 + this.loadedAgentProfile = "";
108 await Promise.all([this.loadProjects(), this.loadAgentProfiles()]);
109 await this.loadSettings();
110 },
@@ -87,6 +159,9 @@ const model = {
159 });
160 const result = await response.json().catch(() => ({}));
161 this.settings = result.ok ? (result.data || {}) : {};
162 + this.loadedPath = result.loaded_path || "";
163 + this.loadedProjectName = result.loaded_project_name || "";
164 + this.loadedAgentProfile = result.loaded_agent_profile || "";
165 if (!result.ok) this.error = result.error || "Failed to load settings";
166 } catch (e) {
167 this.error = e?.message || "Failed to load settings";
@@ -138,6 +213,9 @@ const model = {
213 this.pluginName = null;
214 this.pluginMeta = null;
215 this.settings = {};
216 + this.loadedPath = "";
217 + this.loadedProjectName = "";
218 + this.loadedAgentProfile = "";
219 this.error = null;
220 },
221
webui/components/plugins/plugin-settings.html
+25 -1
@@ -42,8 +42,17 @@
42 </select>
43 </label>
44
45 + <button type="button" class="button plugin-settings-toolbar-button" @click="$store.pluginSettings.openConfigListModal()">
46 + <span class="icon material-symbols-outlined">list</span>
47 + </button>
48 +
49 </div>
50 </div>
51 +
52 + <div x-show="$store.pluginSettings.scopeMismatchMessage" class="plugin-settings-scope-info">
53 + <span class="material-symbols-outlined">info</span>
54 + <span x-text="$store.pluginSettings.scopeMismatchMessage"></span>
55 + </div>
56 </div>
57
58 <!-- Error -->
@@ -97,11 +106,17 @@
106 display: flex;
107 align-items: center;
108 gap: 0.5rem;
100 - flex: 1 1 18rem;
109 + flex: 0 1 18rem;
110 min-width: 12rem;
111 margin: 0;
112 }
113
114 + .plugin-settings-toolbar-button {
115 + flex: 0 0 auto;
116 + padding: 0.5rem 0.75rem;
117 + height: 2.5rem;
118 + }
119 +
120 .plugin-settings-toolbar-label {
121 font-weight: 600;
122 color: var(--color-text-secondary);
@@ -113,6 +128,15 @@
128 min-width: 0;
129 }
130
131 + .plugin-settings-scope-info {
132 + display: flex;
133 + align-items: flex-start;
134 + gap: 0.5rem;
135 + padding: 0 1rem 1rem 1rem;
136 + color: var(--color-text-secondary);
137 + font-size: var(--font-size-small);
138 + }
139 +
140 @media (max-width: 640px) {
141 .plugin-settings-toolbar-item {
142 flex-basis: 100%;