Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,101 @@ def _handle_health(handler, parsed):
return j(handler, payload)


# ── Plugin visibility endpoint (#539) ───────────────────────────────────────
_PLUGIN_VISIBILITY_HOOKS = (
"pre_tool_call",
"post_tool_call",
"pre_llm_call",
"post_llm_call",
)
_PLUGIN_VISIBILITY_HOOK_SET = set(_PLUGIN_VISIBILITY_HOOKS)


def _get_plugin_manager_for_visibility():
"""Return Hermes Agent's plugin manager for read-only WebUI visibility."""
from hermes_cli.plugins import get_plugin_manager

return get_plugin_manager()


def _clean_plugin_visibility_text(value, *, limit=240) -> str:
"""Return bounded display text without path/callback-like internals."""
if value is None:
return ""
text = str(value).replace("\x00", "").strip()
# Display metadata should be plain labels/descriptions. Drop multiline text
# and common path separators rather than risk leaking local plugin paths.
text = " ".join(text.split())
if len(text) > limit:
text = text[: limit - 1].rstrip() + "…"
return text


def _plugin_visibility_payload(manager=None) -> dict:
"""Build a sanitized plugin/hook visibility payload for Settings.

The Hermes Agent manager stores manifests and callback objects internally.
This endpoint intentionally exposes only safe, user-facing metadata and the
four lifecycle hook names called out by the Settings visibility MVP. It
never includes plugin source paths, callback names, callback reprs, or raw
load errors because those can contain private filesystem details.
"""
manager = manager or _get_plugin_manager_for_visibility()
manager.discover_and_load(force=False)

plugins = []
raw_plugins = getattr(manager, "_plugins", {}) or {}
for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
manifest = getattr(loaded, "manifest", None)
if manifest is None:
continue
plugin_key = _clean_plugin_visibility_text(
getattr(manifest, "key", None) or key or getattr(manifest, "name", ""),
limit=120,
)
name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
registered = []
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
hook_name = str(hook or "").strip()
if hook_name in _PLUGIN_VISIBILITY_HOOK_SET and hook_name not in registered:
registered.append(hook_name)
registered.sort(key=_PLUGIN_VISIBILITY_HOOKS.index)
plugins.append({
"name": name,
"key": plugin_key or name,
"version": version,
"description": description,
"enabled": bool(getattr(loaded, "enabled", False)),
"hooks": registered,
})

return {
"plugins": plugins,
"empty": not bool(plugins),
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
"read_only": True,
}


def _handle_plugins(handler, parsed) -> bool:
try:
return j(handler, _plugin_visibility_payload())
except Exception as exc:
logger.warning("Failed to build plugin visibility payload: %s", exc)
return j(
handler,
{
"plugins": [],
"empty": True,
"supported_hooks": list(_PLUGIN_VISIBILITY_HOOKS),
"read_only": True,
"unavailable": True,
},
)


def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""

Expand Down Expand Up @@ -1920,6 +2015,10 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/providers":
return j(handler, get_providers())

# ── Plugins/hooks visibility (read-only, no callback/source internals) ──
if parsed.path == "/api/plugins":
return _handle_plugins(handler, parsed)

if parsed.path == "/api/settings":
settings = load_settings()
# Never expose the stored password hash to clients
Expand Down
Binary file added docs/pr-media/539/plugins-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span>Plugins</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span>System</span>
Expand Down Expand Up @@ -920,6 +924,20 @@ <h2 data-i18n="empty_title">What can I help with?</h2>
No configurable providers found.
</div>
</div>
<div class="settings-pane" id="settingsPanePlugins">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Plugins</div>
<div class="settings-section-meta">View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.</div>
</div>
</div>
<div id="pluginsList" style="display:flex;flex-direction:column;margin-top:4px">
<!-- Populated dynamically by loadPluginsPanel() -->
</div>
<div id="pluginsEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px">
No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.
</div>
</div>
<div class="settings-pane" id="settingsPaneSystem">
<div class="settings-section-head">
<div>
Expand Down
64 changes: 60 additions & 4 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -2729,24 +2729,25 @@ let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;

function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
_settingsSection=section;
_currentSettingsSection=section;
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',plugins:'Plugins',system:'System'};
// Sidebar menu items
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
it.classList.toggle('active', it.dataset.settingsSection===section);
});
// Panes in main
['conversation','appearance','preferences','providers','system'].forEach(key=>{
['conversation','appearance','preferences','providers','plugins','system'].forEach(key=>{
const pane=$('settingsPane'+map[key]);
if(pane) pane.classList.toggle('active', key===section);
});
// Sync mobile dropdown
const dd=$('settingsSectionDropdown');
if(dd && dd.value!==section) dd.value=section;
// Lazy-load providers when the tab is opened
// Lazy-load integration panels when their tabs are opened
if(section==='providers') loadProvidersPanel();
if(section==='plugins') loadPluginsPanel();
}

function _syncHermesPanelSessionActions(){
Expand Down Expand Up @@ -3263,12 +3264,67 @@ async function loadSettingsPanel(){
}
_syncHermesPanelSessionActions();
loadProvidersPanel(); // load provider cards in background
loadPluginsPanel(); // load plugin/hook visibility in background
switchSettingsSection(_settingsSection);
}catch(e){
showToast(t('settings_load_failed')+e.message);
}
}


// ── Plugins panel (read-only plugin/hook visibility) ───────────────────────

async function loadPluginsPanel(){
const list=$('pluginsList');
const empty=$('pluginsEmpty');
if(!list) return;
try{
const data=await api('/api/plugins');
const plugins=Array.isArray((data||{}).plugins)?data.plugins:[];
list.innerHTML='';
if(plugins.length===0){
list.style.display='none';
if(empty) empty.style.display='';
return;
}
if(empty) empty.style.display='none';
list.style.display='';
for(const plugin of plugins){
list.appendChild(_buildPluginCard(plugin));
}
}catch(e){
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load plugins: '+esc(e.message||String(e))+'</div>';
}
}

function _buildPluginCard(plugin){
const card=document.createElement('div');
card.className='provider-card plugin-card';
card.dataset.plugin=(plugin&&plugin.key)||'';
const hooks=Array.isArray(plugin&&plugin.hooks)?plugin.hooks:[];
const hookHtml=hooks.length
? hooks.map(h=>`<span class="plugin-hook-badge">${esc(h)}</span>`).join('')
: '<span class="plugin-hook-empty">No registered lifecycle hooks</span>';
const version=(plugin&&plugin.version)?` · v${esc(plugin.version)}`:'';
const desc=(plugin&&plugin.description)?esc(plugin.description):'No description provided.';
const enabled=plugin&&plugin.enabled!==false;
card.innerHTML=`
<div class="provider-card-header plugin-card-header">
<div class="provider-card-info">
<div class="provider-card-name">${esc((plugin&&plugin.name)||'Unnamed plugin')}</div>
<div class="provider-card-meta">${esc((plugin&&plugin.key)||'plugin')}${version}</div>
</div>
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?'Enabled':'Disabled'}</span>
</div>
<div class="provider-card-body plugin-card-body">
<div class="provider-card-hint">${desc}</div>
<div class="provider-card-label">Registered hooks</div>
<div class="plugin-hook-list">${hookHtml}</div>
</div>
`;
return card;
}

// ── Providers panel ───────────────────────────────────────────────────────

const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
Expand Down
11 changes: 11 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
background:color-mix(in srgb, var(--error) 10%, transparent);
}


/* ── Plugin visibility cards ── */
#pluginsList{gap:12px;}
.plugin-card .provider-card-body{display:block;}
.plugin-card-header{cursor:default;}
.plugin-card-header:hover{background:transparent;}
.plugin-card-badge-disabled{background:var(--surface);color:var(--muted);}
.plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);}
.plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;}

/* ── Provider model tags ── */
.provider-card-models{
margin-bottom:10px;
Expand Down
Loading
Loading