diff --git a/hermes-agent/.gitignore b/hermes-agent/.gitignore new file mode 100644 index 000000000..addfbfeb9 --- /dev/null +++ b/hermes-agent/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +settings.json +bridge.token \ No newline at end of file diff --git a/hermes-agent/BarWidget.qml b/hermes-agent/BarWidget.qml new file mode 100644 index 000000000..64ca4a4ca --- /dev/null +++ b/hermes-agent/BarWidget.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets +import "components" as Components + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var mainInstance: pluginApi?.mainInstance + readonly property var state: mainInstance?.state || ({}) + readonly property var bridge: state.bridge || ({}) + readonly property var hermes: state.hermes || ({}) + readonly property var session: state.session || ({}) + readonly property var summary: state.summary || ({}) + readonly property string gatewayStatus: (summary.gateway && summary.gateway.status) || "unknown" + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property bool clientOnlyMode: cfg.clientOnlyMode ?? defaults.clientOnlyMode ?? false + readonly property string hermesIconPath: pluginApi?.pluginDir ? "file://" + pluginApi.pluginDir + "/assets/hermes-icon.png" : "" + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isBarVertical: barPosition === "left" || barPosition === "right" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) + + readonly property bool hideWhenIdle: cfg.hideWhenIdle ?? defaults.hideWhenIdle ?? false + readonly property string bridgeStatus: bridge.status || "offline" + readonly property string hermesStatus: hermes.status || "unknown" + readonly property bool bridgeOnline: bridgeStatus === "online" + // When the bridge is online but Hermes has not reported a lifecycle status yet + // (no session run / status hook), fall back to the gateway: running gateway = idle. + readonly property string status: !bridgeOnline ? "offline" + : (hermesStatus !== "unknown" ? hermesStatus + : (gatewayStatus === "running" ? "idle" : "unknown")) + readonly property bool shouldHide: hideWhenIdle && status === "idle" + + readonly property string statusIcon: { + switch (status) { + case "offline": return "power"; + case "idle": return "circle-check"; + case "busy": return "loader"; + case "attention": return "bell-ringing"; + case "degraded": return "alert-circle"; + case "error": return "alert-triangle"; + default: return "sparkles"; + } + } + + readonly property color statusColor: { + switch (status) { + case "offline": return Color.mError; + case "idle": return Color.mPrimary; + case "busy": return Color.mPrimary; + case "attention": return "#f59e0b"; + case "degraded": return "#f97316"; + case "error": return Color.mError; + default: return Color.mOnSurface; + } + } + + readonly property string statusText: { + switch (status) { + case "offline": return pluginApi?.tr("status.offline"); + case "idle": return pluginApi?.tr("status.idle"); + case "busy": return pluginApi?.tr("status.busy"); + case "attention": return pluginApi?.tr("status.attention"); + case "degraded": return pluginApi?.tr("status.degraded"); + case "error": return pluginApi?.tr("status.error"); + default: return pluginApi?.tr("status.unknown"); + } + } + + readonly property string displayText: { + if (isBarVertical) return ""; + if (status === "idle") return ""; + if (status === "unknown") return ""; + if (status === "attention") return "!"; + return statusText; + } + + readonly property string tooltipText: { + var model = hermes.model ? " · " + hermes.model : ""; + var err = bridge.error ? "\n" + bridge.error : ""; + return "Hermes: " + statusText + model + err; + } + + function openHermesPanel() { + if (mainInstance && typeof mainInstance.openPreferredPanel === "function") { + mainInstance.openPreferredPanel(root.screen, root); + return; + } + pluginApi?.openPanel(root.screen, root); + } + + readonly property real contentWidth: { + if (shouldHide) return 0; + return isBarVertical ? capsuleHeight : content.implicitWidth + Style.marginM * 2; + } + readonly property real contentHeight: { + if (shouldHide) return 0; + return isBarVertical ? content.implicitHeight + Style.marginM * 2 : capsuleHeight; + } + + implicitWidth: contentWidth + implicitHeight: contentHeight + visible: !shouldHide + + Rectangle { + id: visualCapsule + x: Style.pixelAlignCenter(parent.width, width) + y: Style.pixelAlignCenter(parent.height, height) + width: root.contentWidth + height: root.contentHeight + radius: Style.radiusL + color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + Item { + id: content + anchors.centerIn: parent + implicitWidth: rowLayout.visible ? rowLayout.implicitWidth : colLayout.implicitWidth + implicitHeight: rowLayout.visible ? rowLayout.implicitHeight : colLayout.implicitHeight + + RowLayout { + id: rowLayout + visible: !root.isBarVertical + spacing: Style.marginS + + Item { + Layout.preferredWidth: root.barFontSize + 6 + Layout.preferredHeight: root.barFontSize + 6 + Layout.alignment: Qt.AlignVCenter + + Components.HermesAvatar { + anchors.fill: parent + iconPath: root.hermesIconPath + fallbackIcon: root.statusIcon + statusColor: root.statusColor + iconSize: root.barFontSize + dotSize: 7 * Style.uiScaleRatio + } + } + + NText { + visible: root.displayText !== "" + text: root.displayText + pointSize: root.barFontSize + applyUiScale: false + font.weight: Style.fontWeightSemiBold + color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface + Layout.alignment: Qt.AlignVCenter + } + } + + ColumnLayout { + id: colLayout + visible: root.isBarVertical + spacing: Style.marginXS + + Item { + Layout.preferredWidth: root.barFontSize + 8 + Layout.preferredHeight: root.barFontSize + 8 + Layout.alignment: Qt.AlignHCenter + + Components.HermesAvatar { + anchors.fill: parent + iconPath: root.hermesIconPath + fallbackIcon: root.statusIcon + statusColor: root.statusColor + iconSize: root.barFontSize + dotSize: 7 * Style.uiScaleRatio + } + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) { + summaryPopup.toggleAt(root, root.screen); + } else if (mouse.button === Qt.RightButton) { + summaryPopup.close(); + PanelService.showContextMenu(contextMenu, root, screen); + } + } + + onEntered: TooltipService.show(root, root.tooltipText, BarService.getTooltipDirection(root.screenName)) + onExited: TooltipService.hide() + } + + NPopupContextMenu { + id: contextMenu + screen: root.screen + + model: { + var items = [ + { "label": pluginApi?.tr("bar.openPanel"), "action": "open", "icon": "message-circle" }, + { "label": pluginApi?.tr("bar.newSession"), "action": "new-session", "icon": "plus" }, + { "label": pluginApi?.tr("bar.interrupt"), "action": "interrupt", "icon": "octagon" }, + { "label": pluginApi?.tr("bar.refresh"), "action": "refresh", "icon": "refresh" } + ]; + if (!root.clientOnlyMode && (root.status === "offline" || root.status === "unknown")) { + items.push({ "label": pluginApi?.tr("bar.startGateway"), "action": "start-gateway", "icon": "power" }); + } + items.push({ "label": pluginApi?.tr("settings.title"), "action": "settings", "icon": "settings" }); + return items; + } + + onTriggered: function(action) { + contextMenu.close(); + PanelService.closeContextMenu(root.screen); + if (action === "open") { + root.openHermesPanel(); + } else if (action === "new-session") { + mainInstance?.createSession(); + root.openHermesPanel(); + } else if (action === "interrupt") { + mainInstance?.interrupt(); + } else if (action === "refresh") { + mainInstance?.refreshState(); + } else if (action === "start-gateway") { + mainInstance?.startGateway(); + } else if (action === "settings") { + BarService.openPluginSettings(root.screen, pluginApi?.manifest); + } + } + } + + Components.SummaryPopup { + id: summaryPopup + pluginApi: root.pluginApi + state: root.state + screen: root.screen + + onOpenPanel: root.openHermesPanel() + onNewSession: { + mainInstance?.createSession(); + root.openHermesPanel(); + } + onInterrupt: mainInstance?.interrupt() + onRefresh: mainInstance?.refreshState() + onSettings: BarService.openPluginSettings(root.screen, pluginApi?.manifest) + } +} diff --git a/hermes-agent/LauncherProvider.qml b/hermes-agent/LauncherProvider.qml new file mode 100644 index 000000000..e4aad1532 --- /dev/null +++ b/hermes-agent/LauncherProvider.qml @@ -0,0 +1,118 @@ +import QtQuick +import qs.Commons + +Item { + id: root + + property var pluginApi: null + property var launcher: null + property string name: "Hermes" + property bool handleSearch: false + property string supportedLayouts: "list" + + readonly property var mainInstance: pluginApi?.mainInstance + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property string prefix: cfg.launcherPrefix ?? defaults.launcherPrefix ?? ">hermes" + + function handleCommand(searchText) { + return searchText.startsWith(prefix); + } + + function commands() { + return [{ + "name": prefix, + "description": pluginApi?.tr("launcher.commandDescription"), + "icon": "sparkles", + "isTablerIcon": true, + "isImage": false, + "onActivate": function() { + if (launcher) launcher.setSearchText(prefix + " "); + } + }]; + } + + function closeLauncher() { + if (launcher) launcher.close(); + } + + function openPanel() { + if (mainInstance && typeof mainInstance.openPreferredPanel === "function") { + mainInstance.openPreferredPanel(null, null); + } else { + pluginApi?.openPanel(pluginApi?.panelOpenScreen); + } + closeLauncher(); + } + + function getResults(searchText) { + if (!searchText.startsWith(prefix)) return []; + + var content = searchText.slice(prefix.length).replace(/^\s+/, ""); + var results = []; + + if (content.length > 0) { + results.push({ + "name": pluginApi?.tr("launcher.ask"), + "description": content, + "icon": "send", + "isTablerIcon": true, + "isImage": false, + "_score": 1000, + "onActivate": function() { + mainInstance?.oneShot(content); + root.openPanel(); + } + }); + } else { + results.push({ + "name": pluginApi?.tr("launcher.typePrompt"), + "description": prefix + " plan the next change", + "icon": "message-circle", + "isTablerIcon": true, + "isImage": false, + "_score": 1000, + "onActivate": function() {} + }); + } + + results.push({ + "name": pluginApi?.tr("launcher.openPanel"), + "description": pluginApi?.tr("launcher.openPanelDescription"), + "icon": "panel-right-open", + "isTablerIcon": true, + "isImage": false, + "_score": 950, + "onActivate": root.openPanel + }); + + results.push({ + "name": pluginApi?.tr("launcher.newSession"), + "description": pluginApi?.tr("launcher.newSessionDescription"), + "icon": "plus", + "isTablerIcon": true, + "isImage": false, + "_score": 900, + "onActivate": function() { + mainInstance?.createSession(); + root.openPanel(); + } + }); + + results.push({ + "name": pluginApi?.tr("launcher.resumeLatest"), + "description": pluginApi?.tr("launcher.resumeLatestDescription"), + "icon": "history", + "isTablerIcon": true, + "isImage": false, + "_score": 850, + "onActivate": function() { + var storedId = ((mainInstance?.state || {}).session || {}).stored_id || ""; + if (storedId !== "") mainInstance?.resumeSession(storedId); + root.openPanel(); + } + }); + + return results; + } +} diff --git a/hermes-agent/Main.qml b/hermes-agent/Main.qml new file mode 100644 index 000000000..f1e5a266a --- /dev/null +++ b/hermes-agent/Main.qml @@ -0,0 +1,477 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons + +Item { + id: root + visible: false + + property var pluginApi: null + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + // Mode-dependent: normal mode always uses localhost for the local bridge. + // clientOnlyMode uses the configured remote host/port. + // Without this, toggling clientOnlyMode OFF leaves bridgeHost pointing at the + // remote server, causing the plugin to send the LOCAL token to the REMOTE bridge → 403. + readonly property string bridgeHost: clientOnlyMode ? (cfg.bridgeHost ?? defaults.bridgeHost ?? "127.0.0.1") : "127.0.0.1" + readonly property int bridgePort: clientOnlyMode ? (cfg.bridgePort ?? defaults.bridgePort ?? 19777) : (defaults.bridgePort ?? 19777) + readonly property string stateFile: cfg.stateFile ?? defaults.stateFile ?? "~/.cache/noctalia-hermes/state.json" + readonly property string hermesHome: cfg.hermesHome ?? defaults.hermesHome ?? "~/.hermes" + readonly property string hermesCommand: cfg.hermesCommand ?? defaults.hermesCommand ?? "hermes" + readonly property bool autoStartBridge: cfg.autoStartBridge ?? defaults.autoStartBridge ?? true + readonly property bool autoStartGateway: cfg.autoStartGateway ?? defaults.autoStartGateway ?? true + readonly property int statusPollIntervalSec: cfg.statusPollIntervalSec ?? defaults.statusPollIntervalSec ?? 30 + readonly property bool clientOnlyMode: cfg.clientOnlyMode ?? defaults.clientOnlyMode ?? false + + // Switching to client-only at runtime: stop local gateway, tear down local bridge. + // The remote bridge (via SSH tunnel) manages the gateway on the server side. + // Switching back: ensureBridge() starts the local bridge; tokenFileView.onLoaded + // auto-starts the gateway when autoStartGateway is true and it is not running. + onClientOnlyModeChanged: { + Logger.i("hermes-agent", "clientOnlyModeChanged:", clientOnlyMode, + "bridgeHost:", bridgeHost, "bridgePort:", bridgePort, + "tokenManual len:", (cfg.bridgeTokenManual ?? "").length, + "bridgeToken len:", root.bridgeToken.length, + "bridgeTokenFromFile len:", root.bridgeTokenFromFile.length); + if (clientOnlyMode) { + if (bridgeProcess.running) { + bridgeProcess.running = false; + } + // ponytail: removed stopGateway() here — bridgeHost has already updated to + // remote, so stopGateway() would hit the REMOTE bridge and stop the REMOTE + // gateway. Local gateway is orphaned but harmless; maybeStartGateway() handles + // it when switching back to normal mode. + } + root.ensureBridge(); + } + readonly property string expandedStateFile: expandHome(stateFile) + readonly property string expandedHermesHome: expandHome(hermesHome) + readonly property string bridgeScript: (pluginApi?.pluginDir || ".") + "/scripts/hermes_bridge.py" + readonly property string bridgeTokenFile: expandedStateFile.replace(/\/[^/]*$/, "/bridge.token") + // In client-only mode the token is pasted into settings (the server prints it); + // otherwise it is read from the local bridge.token file by tokenFileView. + // readonly: imperative assignment in tokenFileView.onLoaded would break the binding, + // leaving bridgeToken stuck on the local token when switching to client-only mode. + property string bridgeTokenFromFile: "" + readonly property string bridgeToken: clientOnlyMode ? (cfg.bridgeTokenManual ?? "") : bridgeTokenFromFile + property bool bridgeOnlinePending: false + + property var state: ({ + "bridge": { "status": "offline", "error": "" }, + "hermes": { "status": "unknown", "gateway_pid": "", "model": "", "provider": "" }, + "session": { "id": "", "stored_id": "", "title": "", "running": false, "cwd": "" }, + "messages": [], + "events": [], + "approval": { "pending": false, "message": "", "tool_name": "", "request": ({}) }, + "usage": { "input": 0, "output": 0, "total": 0, "cost_usd": null }, + "summary": { + "model": { "name": "", "provider": "" }, + "models": [], + "mcp": { "enabled": 0, "total": 0, "status": "unknown" }, + "cron": { "active": 0, "total": 0, "next_run": "", "jobs": [] }, + "activity": { "tool_events": 0, "running_tools": 0, "last_tool": "" }, + "gateway": { "status": "unknown", "platforms": ({}) } + }, + "updated_at": 0 + }) + + property bool pinnedPanelRequested: cfg.panelPinned ?? defaults.panelPinned ?? false + property bool pinnedPanelVisible: cfg.panelPinned ?? defaults.panelPinned ?? false + property var pinnedPanelScreen: null + + function expandHome(path) { + if (!path) return path; + if (path === "~") return Quickshell.env("HOME") || path; + if (path.indexOf("~/") === 0) return (Quickshell.env("HOME") || "") + path.slice(1); + return path; + } + + function bridgeUrl(path) { + return "http://" + bridgeHost + ":" + bridgePort + path; + } + + property bool autoStartGatewayPending: false + + function maybeStartGateway() { + if (root.clientOnlyMode) return; + if (!root.autoStartGateway) return; + var gw = root.state?.summary?.gateway?.status || root.state?.hermes?.gateway_status || ""; + if (gw === "offline" || gw === "stopped" || gw === "unknown" || gw === "") { + root.startGateway(); + } + } + + function refreshState() { + Logger.i("hermes-agent", "refreshState fetching:", bridgeUrl("/state"), "tokenPresent:", !!root.bridgeToken, "tokenLen:", root.bridgeToken.length); + getJson("/state", function(data) { + if (data) { + Logger.i("hermes-agent", "refreshState got data, hermes.status:", data.hermes?.status, "gateway:", data.summary?.gateway?.status); + root.state = data; + if (root.autoStartGatewayPending) { + root.autoStartGatewayPending = false; + root.maybeStartGateway(); + } + } + }); + } + + function startBridge() { + if (root.clientOnlyMode) return; // remote bridge: never spawn a local subprocess + if (bridgeProcess.running) return; + bridgeProcess.command = [ + "python3", + bridgeScript, + "--host", bridgeHost, + "--port", String(bridgePort), + "--state-file", expandedStateFile, + "--hermes-home", expandedHermesHome, + "--hermes-command", hermesCommand + ]; + bridgeProcess.running = true; + } + + function onBridgeOnline() { + Logger.i("hermes-agent", "onBridgeOnline clientOnlyMode:", root.clientOnlyMode); + if (root.clientOnlyMode) { + // Token is already set from settings; go straight to state + detection. + root.bridgeOnlinePending = false; + root.refreshState(); + root.autoConfigure(); + return; + } + root.bridgeOnlinePending = true; + root.autoStartGatewayPending = true; + tokenFileView.reload(); + } + + function ensureBridge() { + Logger.i("hermes-agent", "ensureBridge ping:", bridgeUrl("/health")); + getJson("/health", function(data) { + if (data && data.bridge && data.bridge.status === "online") { + Logger.i("hermes-agent", "ensureBridge health OK"); + root.onBridgeOnline(); + } else if (root.clientOnlyMode) { + Logger.i("hermes-agent", "ensureBridge remote unreachable"); + // Remote bridge not reachable. statePollTimer will keep retrying /state + // and surface connection errors via setBridgeError. + root.setBridgeError("Remote bridge unreachable at " + bridgeHost + ":" + bridgePort); + } else if (root.autoStartBridge) { + Logger.i("hermes-agent", "ensureBridge starting local bridge"); + root.startBridge(); + bridgeRetryTimer.start(); + } + }); + } + + function startGateway() { + postJson("/gateway/start", {}, function(data) { + if (data) { + root.refreshState(); + } + }); + } + + function stopGateway() { + postJson("/gateway/stop", {}, function(data) { + if (data) { + root.refreshState(); + } + }); + } + + function autoConfigure() { + var isFirstRun = !(root.cfg.configured ?? false); + if (!isFirstRun) return; + getJson("/detect", function(data) { + if (!data) return; + var s = pluginApi?.pluginSettings || ({}); + if (data.hermesHome && data.hermesHomeExists) s.hermesHome = data.hermesHome; + if (data.hermesCommand) s.hermesCommand = data.hermesCommand; + if (data.model && data.model.name) s.defaultModel = data.model.name; + if (data.model && data.model.provider) s.defaultProvider = data.model.provider; + s.configured = true; + if (pluginApi) { + pluginApi.pluginSettings = s; + pluginApi.saveSettings(); + } + if (data.hermesHome && data.hermesHome !== root.expandedHermesHome) { + root.startBridge(); + } + if (root.autoStartGateway && data.gateway) { + var gwStatus = data.gateway.status || ""; + if (gwStatus === "offline" || gwStatus === "stopped" || gwStatus === "unknown") { + root.startGateway(); + } + } + }); + } + + function clearChat() { + var next = JSON.parse(JSON.stringify(root.state)); + next.messages = []; + next.events = []; + next.approval = { "pending": false, "message": "", "tool_name": "", "request": ({}) }; + next.session.running = false; + root.state = next; + } + + function createSession() { + Logger.i("hermes-agent", "createSession called"); + postJson("/session/create", {}, function(data) { + Logger.i("hermes-agent", "createSession response:", data ? "OK session:" + (data.session?.id || "?") : "null"); + if (data) root.state = data; + }); + } + + function resumeSession(sessionId) { + postJson("/session/resume", { "session_id": sessionId }, function(data) { + if (data) root.state = data.state || data; + }); + } + + function listSessions(callback) { + getJson("/sessions", function(data) { + callback(data ? (data.sessions || []) : []); + }); + } + + function sendPrompt(text) { + postJson("/prompt", { "text": text }, function(data) { + if (data && data.state) root.state = data.state; + }); + } + + function interrupt() { + postJson("/interrupt", {}, function(data) { + if (data && data.state) root.state = data.state; + }); + } + + function respondApproval(choice, all) { + postJson("/approval", { "choice": choice, "all": all || false }, function(data) { + if (data && data.state) root.state = data.state; + }); + } + + function setModel(provider, model, persist) { + Logger.i("hermes-agent", "setModel:", provider, model, "persist:", persist); + postJson("/model", { "provider": provider || "", "model": model || "", "persist": persist || false }, function(data) { + Logger.i("hermes-agent", "setModel response:", data ? "has state:" + !!data.state : "null"); + if (data && data.state) root.state = data.state; + }); + } + + function openPreferredPanel(screen, buttonItem) { + if (root.cfg.panelPinned ?? root.defaults.panelPinned ?? false) { + root.openPinnedPanel(screen); + return; + } + if (screen) { + root.pluginApi?.openPanel(screen, buttonItem || null); + return; + } + root.pluginApi?.withCurrentScreen(function(currentScreen) { + if (currentScreen) root.pluginApi?.openPanel(currentScreen, buttonItem || null); + }); + } + + function openPinnedPanel(screen) { + if (screen) { + root.pinnedPanelScreen = screen; + root.pinnedPanelVisible = true; + root.refreshState(); + return; + } + root.pluginApi?.withCurrentScreen(function(currentScreen) { + if (currentScreen) { + root.pinnedPanelScreen = currentScreen; + root.pinnedPanelVisible = true; + root.refreshState(); + } + }); + } + + function closePinnedPanel() { + root.pinnedPanelVisible = false; + } + + function setPinnedPanelRequested(value) { + pinnedPanelRequested = value; + if (value) { + root.openPinnedPanel(root.pluginApi?.panelOpenScreen); + if (root.pluginApi?.panelOpenScreen) root.pluginApi.closePanel(root.pluginApi.panelOpenScreen); + } else { + root.closePinnedPanel(); + } + } + + function oneShot(text) { + postJson("/oneshot", { "text": text }, function(data) { + if (data && data.state) root.state = data.state; + }); + } + + function getJson(path, callback) { + requestJson("GET", path, null, callback); + } + + function postJson(path, payload, callback) { + requestJson("POST", path, payload, callback); + } + + function requestJson(method, path, payload, callback) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return; + if (xhr.status >= 200 && xhr.status < 300) { + try { + Logger.i("hermes-agent", method.toLowerCase() + "Json", path, "OK, token set:", !!root.bridgeToken); + callback(JSON.parse(xhr.responseText)); + } catch (e) { + setBridgeError("Invalid bridge response"); + callback(null); + } + } else { + Logger.i("hermes-agent", method.toLowerCase() + "Json", path, "failed status:", xhr.status, "token set:", !!root.bridgeToken, "response:", xhr.responseText?.substring(0,100)); + var msg; + if (xhr.status === 0) { + msg = "Connection failed: " + bridgeHost + ":" + bridgePort + " unreachable"; + } else if (xhr.status === 403) { + msg = "Authentication failed: wrong bridge token"; + } else { + msg = "Bridge request failed: " + xhr.status; + } + setBridgeError(msg); + callback(null); + } + }; + xhr.open(method, bridgeUrl(path)); + if (root.bridgeToken) { + xhr.setRequestHeader("X-Bridge-Token", root.bridgeToken); + } + if (method === "POST") { + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(JSON.stringify(payload || {})); + } else { + xhr.send(); + } + } + + function setBridgeError(message) { + var next = JSON.parse(JSON.stringify(root.state)); + next.bridge = next.bridge || {}; + next.bridge.status = "offline"; + next.bridge.error = message; + root.state = next; + } + + function loadStateFromFile() { + if (root.clientOnlyMode) return; // remote bridge: never overwrite HTTP state with local file + try { + var text = stateFileView.text(); + if (!text || text.trim() === "") return; + root.state = JSON.parse(text); + } catch (e) { + setBridgeError("Invalid state file"); + } + } + + Process { + id: bridgeProcess + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: function(exitCode) { + if (exitCode !== 0) { + root.setBridgeError("Bridge exited: " + exitCode); + } + } + } + + FileView { + id: stateFileView + // Local file watching only works for a local bridge; in client-only mode + // state is fetched over HTTP by statePollTimer instead. + path: root.clientOnlyMode ? "" : root.expandedStateFile + watchChanges: !root.clientOnlyMode + printErrors: false + onFileChanged: reload() + onLoaded: root.loadStateFromFile() + onLoadFailed: root.refreshState() + } + + FileView { + id: tokenFileView + path: root.clientOnlyMode ? "" : root.bridgeTokenFile + watchChanges: !root.clientOnlyMode + printErrors: false + onLoaded: { + var text = tokenFileView.text(); + root.bridgeTokenFromFile = text ? text.trim() : ""; + if (root.bridgeOnlinePending) { + root.bridgeOnlinePending = false; + root.refreshState(); + root.autoConfigure(); + } + } + onFileChanged: reload() + onLoadFailed: {} + } + + Timer { + interval: root.statusPollIntervalSec * 1000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.ensureBridge() + } + + // Client-only mode has no local state file to watch, so poll over HTTP. + // Poll fast while a session is running (live streaming / approvals), slow when idle. + Timer { + id: statePollTimer + interval: (root.state.session && root.state.session.running) ? 1500 : root.statusPollIntervalSec * 1000 + running: root.clientOnlyMode + repeat: true + onTriggered: root.ensureBridge() + } + + Timer { + id: bridgeRetryTimer + interval: 500 + repeat: true + property int attempts: 0 + onTriggered: { + attempts++; + if (attempts > 20) { + bridgeRetryTimer.stop(); + attempts = 0; + root.setBridgeError("Bridge failed to start"); + return; + } + getJson("/health", function(data) { + if (data && data.bridge && data.bridge.status === "online") { + bridgeRetryTimer.stop(); + attempts = 0; + root.onBridgeOnline(); + } + }); + } + } + + Component.onCompleted: { + root.ensureBridge(); + if (root.pinnedPanelRequested && root.pinnedPanelVisible) { + root.openPinnedPanel(null); + } + } + + PinnedPanelWindow { + pluginApi: root.pluginApi + panelScreen: root.pinnedPanelScreen + active: root.pinnedPanelRequested && root.pinnedPanelVisible && (root.cfg.panelPinned ?? root.defaults.panelPinned ?? false) + } +} diff --git a/hermes-agent/Panel.qml b/hermes-agent/Panel.qml new file mode 100644 index 000000000..c4e97f251 --- /dev/null +++ b/hermes-agent/Panel.qml @@ -0,0 +1,368 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import "components" as Components + +Item { + id: root + + property var pluginApi: null + + readonly property var mainInstance: pluginApi?.mainInstance + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property var state: mainInstance?.state || ({}) + readonly property var bridge: state.bridge || ({}) + readonly property var hermes: state.hermes || ({}) + readonly property var session: state.session || ({}) + readonly property var approval: state.approval || ({}) + readonly property var messages: state.messages || [] + readonly property var summary: state.summary || ({}) + readonly property var activity: summary.activity || ({}) + readonly property var modelSummary: summary.model || ({}) + readonly property bool hasSession: (session.id || session.stored_id || "") !== "" + readonly property bool bridgeOnline: (bridge.status || "offline") === "online" + readonly property bool isBusy: session.running || (hermes.status || "") === "busy" + readonly property bool pinned: cfg.panelPinned ?? defaults.panelPinned ?? false + readonly property bool showToolActivity: cfg.showToolActivity ?? defaults.showToolActivity ?? false + property var sessionList: [] + property bool sessionsLoaded: false + readonly property string hermesIconPath: pluginApi?.pluginDir ? "file://" + pluginApi.pluginDir + "/assets/hermes-icon.png" : "" + readonly property string status: bridgeOnline ? (hermes.status || "unknown") : "offline" + readonly property string modelLabel: { + var provider = modelSummary.provider || hermes.provider || cfg.defaultProvider || ""; + var model = modelSummary.name || hermes.model || cfg.defaultModel || ""; + if (provider && model) return provider + " / " + model; + return model || provider || (pluginApi?.tr("panel.noModel")); + } + readonly property string activityLabel: { + var count = activity.tool_events || 0; + if (count === 0) return ""; + var running = activity.running_tools || 0; + var last = activity.last_tool || "tool"; + if (running > 0) return String(running) + " " + (pluginApi?.tr("panel.backgroundRunning")); + return String(count) + " " + (pluginApi?.tr("panel.backgroundActions")) + ", " + (pluginApi?.tr("panel.lastAction")) + ": " + last; + } + + readonly property var geometryPlaceholder: panelContainer + property real contentPreferredWidth: Math.min(1040 * Style.uiScaleRatio, Math.max(780 * Style.uiScaleRatio, width * 0.5)) + property real contentPreferredHeight: Math.max(640 * Style.uiScaleRatio, height - Style.margin2L) + readonly property bool allowAttach: false + readonly property bool panelAnchorRight: true + readonly property bool panelAnchorTop: true + readonly property bool panelAnchorBottom: true + + anchors.fill: parent + + function statusColor() { + switch (root.status) { + case "offline": return Color.mError; + case "attention": return "#f59e0b"; + case "degraded": return "#f97316"; + case "error": return Color.mError; + default: return Color.mPrimary; + } + } + + function statusText() { + switch (root.status) { + case "offline": return pluginApi?.tr("status.offline"); + case "idle": return pluginApi?.tr("status.idle"); + case "busy": return pluginApi?.tr("status.busy"); + case "attention": return pluginApi?.tr("status.attention"); + case "degraded": return pluginApi?.tr("status.degraded"); + case "error": return pluginApi?.tr("status.error"); + default: return pluginApi?.tr("status.unknown"); + } + } + + function sendComposerPrompt() { + var text = composer.text.trim(); + if (text === "") return; + composer.text = ""; + if (!root.hasSession && root.mainInstance) { + root.mainInstance.createSession(); + } + root.mainInstance?.sendPrompt(text); + } + + function setPinned(value) { + if (!pluginApi) return; + pluginApi.pluginSettings.panelPinned = value; + pluginApi.saveSettings(); + root.mainInstance?.setPinnedPanelRequested(value); + } + + function closePanel() { + if (root.pinned) { + root.mainInstance?.closePinnedPanel(); + return; + } + root.pluginApi?.closePanel(root.pluginApi?.panelOpenScreen); + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + anchors { + fill: parent + margins: Style.marginL + } + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + Item { + Layout.preferredWidth: Style.baseWidgetSize + Layout.preferredHeight: Style.baseWidgetSize + Layout.alignment: Qt.AlignVCenter + + Components.HermesAvatar { + anchors.fill: parent + iconPath: root.hermesIconPath + fallbackIcon: "sparkles" + statusColor: root.statusColor() + iconSize: Style.fontSizeXL + dotSize: 9 * Style.uiScaleRatio + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.title") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + elide: Text.ElideRight + } + + NText { + text: root.statusText() + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: root.statusColor() + } + } + + NText { + text: root.modelLabel + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + NButton { + text: root.pinned ? (pluginApi?.tr("panel.unpin")) : (pluginApi?.tr("panel.pin")) + icon: root.pinned ? "pinned-off" : "pin" + onClicked: root.setPinned(!root.pinned) + } + + NIconButton { + icon: "history" + tooltipText: pluginApi?.tr("panel.sessions") + onClicked: { + var btn = this; + root.mainInstance?.listSessions(function(list) { + root.sessionList = list; + root.sessionsLoaded = true; + sessionPopup.openNear(btn); + }); + } + } + + NIconButton { + icon: "close" + tooltipText: pluginApi?.tr("panel.close") + onClicked: root.closePanel() + } + } + + Components.ApprovalCard { + pluginApi: root.pluginApi + approval: root.approval + onApprove: function(all) { + if (mainInstance) mainInstance.respondApproval("approved", all); + } + onDeny: function() { + if (mainInstance) mainInstance.respondApproval("denied", false); + } + } + + Rectangle { + visible: root.activityLabel !== "" && (root.showToolActivity || root.isBusy) + Layout.fillWidth: true + implicitHeight: activityRow.implicitHeight + Style.margin2S + radius: Style.radiusS + color: Color.mSurfaceVariant + + RowLayout { + id: activityRow + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: Style.marginS + rightMargin: Style.marginS + } + spacing: Style.marginS + + NIcon { + icon: root.isBusy ? "loader" : "activity" + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.activityLabel + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Style.radiusS + color: Color.mSurface + + ScrollView { + id: transcriptScroll + anchors.fill: parent + anchors.margins: Style.marginM + clip: true + + ColumnLayout { + id: transcriptLayout + width: Math.max(transcriptScroll.availableWidth, 1) + spacing: Style.marginS + + ColumnLayout { + visible: root.messages.length === 0 + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: root.bridgeOnline ? "message-circle" : "power" + pointSize: Style.fontSizeXXL + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: root.bridgeOnline ? (pluginApi?.tr("panel.noSession")) : (pluginApi?.tr("panel.bridgeOffline")) + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + + Repeater { + model: root.messages + + delegate: Components.MessageBubble { + pluginApi: root.pluginApi + message: modelData + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NButton { + text: pluginApi?.tr("panel.newSession") + icon: "plus" + onClicked: mainInstance?.createSession() + } + + NButton { + text: pluginApi?.tr("panel.reset") + icon: "close" + outlined: true + enabled: root.messages.length > 0 + onClicked: mainInstance?.createSession() + } + + NTextInput { + id: composer + Layout.fillWidth: true + placeholderText: pluginApi?.tr("panel.placeholder") + enabled: root.bridgeOnline + Keys.onReturnPressed: root.sendComposerPrompt() + } + + NButton { + text: root.isBusy ? (pluginApi?.tr("panel.interrupt")) : (pluginApi?.tr("panel.send")) + icon: root.isBusy ? "octagon" : "send" + enabled: root.bridgeOnline && (root.isBusy || composer.text.trim() !== "") + onClicked: { + if (root.isBusy) { + mainInstance?.interrupt(); + } else { + root.sendComposerPrompt(); + } + } + } + } + } + } + + Connections { + target: mainInstance + enabled: mainInstance !== null + function onStateChanged() { + scrollTimer.restart(); + } + } + + Timer { + id: scrollTimer + interval: 100 + repeat: false + onTriggered: { + transcriptScroll.contentItem.contentY = Math.max(0, transcriptScroll.contentItem.contentHeight - transcriptScroll.height); + } + } + + Component.onCompleted: { + mainInstance?.setPinnedPanelRequested(root.pinned); + mainInstance?.refreshState(); + } + + Components.SessionPopup { + id: sessionPopup + pluginApi: root.pluginApi + mainInstance: root.mainInstance + screen: root.pluginApi?.panelOpenScreen + sessions: root.sessionList + onSessionSelected: function(sid) { + root.mainInstance?.resumeSession(sid); + } + } +} diff --git a/hermes-agent/PinnedPanelWindow.qml b/hermes-agent/PinnedPanelWindow.qml new file mode 100644 index 000000000..ee4fbb636 --- /dev/null +++ b/hermes-agent/PinnedPanelWindow.qml @@ -0,0 +1,56 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons + +PanelWindow { + id: root + + property var pluginApi: null + property var panelScreen: null + property bool active: false + + readonly property string screenName: panelScreen ? panelScreen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property real barHeight: Style.getBarHeightForScreen(screenName) + readonly property real sideMargin: Style.marginM + readonly property real panelWidth: { + var screenWidth = panelScreen ? panelScreen.width : 1920; + return Math.min(1080 * Style.uiScaleRatio, Math.max(860 * Style.uiScaleRatio, screenWidth * 0.52)); + } + + screen: panelScreen + visible: active && panelScreen !== null + color: "transparent" + implicitWidth: Math.round(panelWidth) + + anchors { + top: true + bottom: true + right: true + } + + margins { + top: sideMargin + (barPosition === "top" ? barHeight : 0) + bottom: sideMargin + (barPosition === "bottom" ? barHeight : 0) + right: sideMargin + (barPosition === "right" ? barHeight : 0) + } + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "noctalia-hermes-pinned-" + (screenName || "unknown") + + Rectangle { + anchors.fill: parent + color: Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + radius: Style.radiusL + } + + Panel { + anchors.fill: parent + pluginApi: root.pluginApi + } +} diff --git a/hermes-agent/README.md b/hermes-agent/README.md new file mode 100644 index 000000000..049d14d1f --- /dev/null +++ b/hermes-agent/README.md @@ -0,0 +1,179 @@ +# Noctalia Hermes Agent + +A [Noctalia](https://github.com/noctalia-dev) plugin for [Hermes Agent](https://github.com/noctalia-dev/hermes-agent). + +Shows Hermes status in the bar. Chat panel with streaming, tool activity, approval prompts, and one-shot. `>hermes` launcher integration. Drives a local or remote Hermes bridge. + +## Install + +Add the repo as a plugin source in Noctalia: + +1. Settings → Plugins → Sources +2. Add custom repository: `https://github.com/Nomadcxx/noctalia-hermes-agent` +3. Settings → Plugins → Install — pick Hermes Agent + +Or copy manually: + +```bash +git clone https://github.com/Nomadcxx/noctalia-hermes-agent +cp -r noctalia-hermes-agent/hermes-agent/ ~/.config/noctalia/plugins/noctalia-hermes-agent/ +``` + +Restart Noctalia. + +## Architecture + +The plugin runs in one of two modes: + +**Normal mode (default).** A Python bridge process spawns locally on `127.0.0.1:19777`. The QML UI sends HTTP requests to it. The bridge talks to your Hermes gateway via RPC and reads your config and model catalog from `~/.hermes`. + +**Client-only mode.** You run the bridge on a remote server. Forward its port to your local machine over SSH. The QML UI hits `127.0.0.1:19777` just like normal mode, but the SSH tunnel routes requests to the remote bridge. The remote bridge connects to the remote gateway. You paste the bridge token in Settings to authenticate. + +``` +Normal mode: Client-only mode: + +QML UI QML UI + │ │ + ▼ ▼ +Bridge (local) SSH tunnel (local port) + │ │ + ▼ ▼ (SSH) +Gateway (local) Bridge (remote) + │ + ▼ + Gateway (remote) +``` + +## Model picker + +The Settings dropdown lists providers and models from three sources: + +1. **Provider config models** — your `config.yaml` providers section (e.g. `ollama-local.models`) +2. **Model catalog cache** — Hermes downloads this from its model catalog (`provider_models_cache.json`) +3. **Favorites** — models you previously used (`models.json`) + +The list mirrors what `hermes --tui` shows. A provider may appear even without explicit config if Hermes ships a built-in overlay for it (like `minimax-oauth` or `kimi-for-coding`). Whether the model actually works depends on your API key and credentials. + +To change the active model, select a provider/model in Settings and click Apply. This calls `config.set` through the bridge, which updates the Hermes config. If you check Persist, the change is global; otherwise it applies to the current session only. + +## New Session and Reset + +**New Session** tells the bridge to start a fresh Hermes RPC session. Chat history clears. This is the equivalent of `/session new` in the TUI. + +**Reset** clears the local chat pane without a server roundtrip. Visible when the pane has messages. Does not create a new Hermes session. + +## Client-only mode (remote bridge) + +Run Hermes on a powerful server. Control it from your laptop. The bridge stays on the server bound to `127.0.0.1`. Your laptop reaches it through an SSH tunnel. + +### Concepts + +The **bridge** is a Python HTTP server on `127.0.0.1:19777` that sits between the QML UI and the Hermes gateway. It authenticates requests with a **bridge token** — a random secret generated on first launch and stored in `~/.cache/noctalia-hermes/bridge.token`. + +In normal mode everything runs locally and the token is read from disk. In client-only mode you copy the token from the server and paste it into Settings. The plugin sends it in the `X-Bridge-Token` header with every request. + +The bridge token is 43 characters of base64. It lives in the same directory as the state file and persists across restarts. Run `hermes-bridge-serve.sh` on the server to start the bridge and print the token and a ready-to-use SSH tunnel command. + +### Setup + +**On the server** (where Hermes and the gateway live): + +```bash +cd hermes-agent/scripts +./hermes-bridge-serve.sh 19777 +``` + +The script starts the bridge, prints the token, and shows the SSH command to run on your laptop. Example output: + +``` +Bridge env written to /home/user/.config/noctalia/plugins/.bridge.env +Token: I4ZvQnT0Bgf0FxL0azZgwYXb0KCruAMfVbGHH6WD7U + +SSH tunnel command: +ssh -N -L 19777:127.0.0.1:19777 user@server +``` + +If you already have a token file, the script uses the existing one. Delete `~/.cache/noctalia-hermes/bridge.token` on the server to regenerate it. Only do this if you suspect the token leaked. + +**On the laptop** (where Noctalia runs): + +Open an SSH tunnel. Keep it alive while you use the plugin: + +```bash +ssh -N -L 19777:127.0.0.1:19777 user@server +``` + +For a server on a different local port (e.g. a socat forwarder on `192.168.0.10:19778`): + +```bash +ssh -N -L 19777:192.168.0.10:19778 user@gateway-host +``` + +**Plugin Settings** (Advanced section): + +1. Enable **Client-only mode** +2. Set **Bridge host** to `127.0.0.1` (the tunnel endpoint) and **Bridge port** to `19777` +3. Paste the **Bridge token** from the server +4. Click **Apply** + +The bar pill should turn green within a few seconds. + +### Finding your token + +- The bridge prints it on startup when you run `hermes-bridge-serve.sh` +- It lives in `~/.cache/noctalia-hermes/bridge.token` on the server +- Test Connection in Settings only checks `/health` (no token needed). A green result means the bridge is reachable. Use `curl` for a real `/state` check: + +```bash +curl -H "X-Bridge-Token: " http://127.0.0.1:19777/state +``` + +A 403 means the token is wrong. Compare character by character — leading dashes and trailing newlines are common mistakes. + +### Troubleshooting + +**Port already in use.** A local bridge process from before you turned on client-only mode, or a stale tunnel. + +```bash +ss -ltnp | grep 19777 +pkill -f hermes_bridge.py +``` + +**Bar pill grey or Offline.** The bridge reports `unknown` until a session runs. If the gateway is running the pill shows idle. Verify: + +```bash +# Health (no token needed — confirms bridge is reachable) +curl -s 127.0.0.1:19777/health + +# State (needs token — confirms auth and gateway status) +curl -s -H "X-Bridge-Token: " 127.0.0.1:19777/state +``` + +**Toggle cycle (normal → client-only → normal).** Mode-dependent bridge host/port prevents the local token from hitting the remote bridge on toggle. Normal mode always uses `127.0.0.1:19777`. Client-only mode uses your configured host/port. + +**Test Connection succeeds but bar shows offline.** The Test button only hits `/health`, which does not require a token. Check for a 403 on `/state` — wrong token, wrong host, or wrong port. + +## Settings + +| Setting | Default | Description | +|---|---|---| +| `bridgeHost` | `127.0.0.1` | Bridge host | +| `bridgePort` | `19777` | Bridge port | +| `stateFile` | `~/.cache/noctalia-hermes/state.json` | Shared state file | +| `hermesHome` | `~/.hermes` | Hermes home directory | +| `hermesCommand` | `hermes` | Hermes executable | +| `autoStartBridge` | `true` | Start local bridge at Noctalia load | +| `autoStartGateway` | `true` | Start gateway when bridge comes online | +| `clientOnlyMode` | `false` | Connect to a remote bridge over SSH | +| `bridgeTokenManual` | _(empty)_ | Bridge token (required in client-only mode) | +| `statusPollIntervalSec` | `30` | Status poll interval in seconds | +| `hideWhenIdle` | `false` | Hide bar pill when Hermes is idle | +| `launcherPrefix` | `>hermes` | Launcher command prefix | +| `panelPinned` | `false` | Pin panel as persistent side window | +| `showToolActivity` | `false` | Show compact tool-activity line | +| `defaultProvider` | _(empty)_ | Default provider | +| `defaultModel` | _(empty)_ | Default model | + +## License + +MIT diff --git a/hermes-agent/Settings.qml b/hermes-agent/Settings.qml new file mode 100644 index 000000000..3dcaf26d2 --- /dev/null +++ b/hermes-agent/Settings.qml @@ -0,0 +1,396 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property var mainInstance: pluginApi?.mainInstance + readonly property var summary: mainInstance?.state?.summary || ({}) + readonly property var detectedModel: summary.model || ({}) + readonly property var availableModels: summary.models || [] + + property string valueBridgeHost: cfg.bridgeHost ?? defaults.bridgeHost ?? "127.0.0.1" + property int valueBridgePort: cfg.bridgePort ?? defaults.bridgePort ?? 19777 + property string valueStateFile: cfg.stateFile ?? defaults.stateFile ?? "~/.cache/noctalia-hermes/state.json" + property string valueHermesHome: cfg.hermesHome ?? defaults.hermesHome ?? "~/.hermes" + property string valueHermesCommand: cfg.hermesCommand ?? defaults.hermesCommand ?? "hermes" + property bool valueAutoStartBridge: cfg.autoStartBridge ?? defaults.autoStartBridge ?? true + property bool valueAutoStartGateway: cfg.autoStartGateway ?? defaults.autoStartGateway ?? true + property bool valueClientOnlyMode: cfg.clientOnlyMode ?? defaults.clientOnlyMode ?? false + property string valueBridgeTokenManual: cfg.bridgeTokenManual ?? defaults.bridgeTokenManual ?? "" + property int valueStatusPollIntervalSec: cfg.statusPollIntervalSec ?? defaults.statusPollIntervalSec ?? 30 + property bool valueHideWhenIdle: cfg.hideWhenIdle ?? defaults.hideWhenIdle ?? false + property string valueLauncherPrefix: cfg.launcherPrefix ?? defaults.launcherPrefix ?? ">hermes" + property bool valuePanelPinned: cfg.panelPinned ?? defaults.panelPinned ?? false + property bool valueShowToolActivity: cfg.showToolActivity ?? defaults.showToolActivity ?? false + property string valueDefaultProvider: (detectedModel.provider || cfg.defaultProvider || defaults.defaultProvider || "") + property string valueDefaultModel: (detectedModel.name || cfg.defaultModel || defaults.defaultModel || "") + readonly property string selectedModelKey: modelKey(valueDefaultProvider, valueDefaultModel) + property bool showAdvanced: false + property string testResult: "" + property color testResultColor: Color.mOnSurface + + spacing: Style.marginL + + NText { + text: pluginApi?.tr("settings.title") + pointSize: Style.fontSizeXL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginM + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.providerSelect") + model: root.providerOptions() + currentKey: root.valueDefaultProvider + minimumWidth: 180 + onSelected: function(key) { + root.valueDefaultProvider = key; + if (root.valueDefaultModel !== "" && root.findModel(root.valueDefaultProvider, root.valueDefaultModel) === null) { + var first = root.firstModelForProvider(key); + if (first) root.valueDefaultModel = first.model; + } + } + } + + NComboBox { + Layout.fillWidth: true + label: pluginApi?.tr("settings.modelSelect") + model: root.modelOptions(root.valueDefaultProvider) + currentKey: root.selectedModelKey + minimumWidth: 320 + onSelected: function(key) { + var item = root.findModelByKey(key); + if (!item) return; + root.valueDefaultProvider = item.provider || ""; + root.valueDefaultModel = item.model || ""; + } + } + } + + NButton { + text: pluginApi?.tr("settings.applyModel") + icon: "refresh" + enabled: root.valueDefaultModel.trim() !== "" + onClicked: { + root.saveSettings(); + root.mainInstance?.setModel(root.valueDefaultProvider.trim(), root.valueDefaultModel.trim(), false); + } + } + + NToggle { + label: pluginApi?.tr("settings.hideWhenIdle") + description: pluginApi?.tr("settings.hideWhenIdleDescription") + checked: root.valueHideWhenIdle + onToggled: root.valueHideWhenIdle = checked + } + + NToggle { + label: pluginApi?.tr("settings.panelPinned") + description: pluginApi?.tr("settings.panelPinnedDescription") + checked: root.valuePanelPinned + onToggled: root.valuePanelPinned = checked + } + + NToggle { + label: pluginApi?.tr("settings.showToolActivity") + description: pluginApi?.tr("settings.showToolActivityDescription") + checked: root.valueShowToolActivity + onToggled: root.valueShowToolActivity = checked + } + + NButton { + text: root.showAdvanced ? pluginApi?.tr("settings.advancedHide") : pluginApi?.tr("settings.advancedShow") + icon: root.showAdvanced ? "chevron-up" : "chevron-down" + outlined: true + onClicked: root.showAdvanced = !root.showAdvanced + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: root.showAdvanced + + NToggle { + label: pluginApi?.tr("settings.clientOnlyMode") + description: pluginApi?.tr("settings.clientOnlyModeDescription") + checked: root.valueClientOnlyMode + onToggled: root.valueClientOnlyMode = checked + } + + NTextInput { + Layout.fillWidth: true + visible: root.valueClientOnlyMode + label: pluginApi?.tr("settings.bridgeToken") + description: pluginApi?.tr("settings.bridgeTokenDescription") + text: root.valueBridgeTokenManual + onTextChanged: { + // Guard: only update when visible. Some QML components clear text + // when hidden, which would wipe the saved token on toggle OFF. + if (root.valueClientOnlyMode) root.valueBridgeTokenManual = text; + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.valueClientOnlyMode + spacing: Style.marginM + + NButton { + text: pluginApi?.tr("settings.testConnection") + onClicked: root.testConnection() + } + + NLabel { + label: root.testResult + labelColor: root.testResultColor + } + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.bridgeHost") + text: root.valueBridgeHost + onTextChanged: root.valueBridgeHost = text + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.bridgePort") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + } + + NSpinBox { + from: 1024 + to: 65535 + value: root.valueBridgePort + stepSize: 1 + onValueChanged: root.valueBridgePort = value + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.statusPollIntervalSec") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + } + + NSpinBox { + from: 5 + to: 300 + value: root.valueStatusPollIntervalSec + stepSize: 5 + onValueChanged: root.valueStatusPollIntervalSec = value + } + } + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.stateFile") + text: root.valueStateFile + onTextChanged: root.valueStateFile = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.hermesHome") + text: root.valueHermesHome + onTextChanged: root.valueHermesHome = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.hermesCommand") + text: root.valueHermesCommand + onTextChanged: root.valueHermesCommand = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.launcherPrefix") + text: root.valueLauncherPrefix + onTextChanged: root.valueLauncherPrefix = text + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultProvider") + text: root.valueDefaultProvider + onTextChanged: root.valueDefaultProvider = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.defaultModel") + text: root.valueDefaultModel + onTextChanged: root.valueDefaultModel = text + } + } + + NToggle { + label: pluginApi?.tr("settings.autoStartBridge") + description: pluginApi?.tr("settings.autoStartBridgeDescription") + visible: !root.valueClientOnlyMode + checked: root.valueAutoStartBridge + onToggled: root.valueAutoStartBridge = checked + } + + NToggle { + label: pluginApi?.tr("settings.autoStartGateway") + description: pluginApi?.tr("settings.autoStartGatewayDescription") + visible: !root.valueClientOnlyMode + checked: root.valueAutoStartGateway + onToggled: root.valueAutoStartGateway = checked + } + } + } + + function saveSettings() { + if (!pluginApi) return; + Logger.i("hermes-agent", "saveSettings: tokenManual len:", root.valueBridgeTokenManual.length, "clientOnlyMode:", root.valueClientOnlyMode); + pluginApi.pluginSettings.bridgeHost = root.valueBridgeHost; + pluginApi.pluginSettings.bridgePort = root.valueBridgePort; + pluginApi.pluginSettings.stateFile = root.valueStateFile; + pluginApi.pluginSettings.hermesHome = root.valueHermesHome; + pluginApi.pluginSettings.hermesCommand = root.valueHermesCommand; + pluginApi.pluginSettings.autoStartBridge = root.valueAutoStartBridge; + pluginApi.pluginSettings.autoStartGateway = root.valueAutoStartGateway; + pluginApi.pluginSettings.clientOnlyMode = root.valueClientOnlyMode; + pluginApi.pluginSettings.bridgeTokenManual = root.valueBridgeTokenManual; + pluginApi.pluginSettings.statusPollIntervalSec = root.valueStatusPollIntervalSec; + pluginApi.pluginSettings.hideWhenIdle = root.valueHideWhenIdle; + pluginApi.pluginSettings.launcherPrefix = root.valueLauncherPrefix; + pluginApi.pluginSettings.panelPinned = root.valuePanelPinned; + pluginApi.pluginSettings.showToolActivity = root.valueShowToolActivity; + pluginApi.pluginSettings.defaultProvider = root.valueDefaultProvider; + pluginApi.pluginSettings.defaultModel = root.valueDefaultModel; + pluginApi.saveSettings(); + root.mainInstance?.setPinnedPanelRequested(root.valuePanelPinned); + root.mainInstance?.setModel(root.valueDefaultProvider, root.valueDefaultModel, false); + } + + function testConnection() { + root.testResult = pluginApi?.tr("settings.testing"); + root.testResultColor = Color.mOnSurface; + var host = root.valueBridgeHost; + var port = root.valueBridgePort; + var token = root.valueBridgeTokenManual; + var url = "http://" + host + ":" + port + "/health"; + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return; + if (xhr.status === 0) { + root.testResult = pluginApi?.tr("settings.testFailedUnreachable"); + root.testResultColor = Color.mError; + } else if (xhr.status === 403) { + root.testResult = pluginApi?.tr("settings.testFailedAuth"); + root.testResultColor = Color.mError; + } else if (xhr.status >= 200 && xhr.status < 300) { + root.testResult = pluginApi?.tr("settings.testSuccess"); + root.testResultColor = Color.mPrimary; + } else { + root.testResult = pluginApi?.tr("settings.testFailed") + " " + xhr.status; + root.testResultColor = Color.mError; + } + }; + xhr.open("GET", url); + if (token) xhr.setRequestHeader("X-Bridge-Token", token); + xhr.send(); + } + + function modelKey(provider, model) { + return (provider || "") + "::" + (model || ""); + } + + function providerOptions() { + var seen = {}; + var items = [{ "key": "", "name": pluginApi?.tr("settings.providerCurrent") }]; + for (var i = 0; i < root.availableModels.length; i++) { + var provider = root.availableModels[i].provider || ""; + if (provider === "" || seen[provider]) continue; + seen[provider] = true; + items.push({ "key": provider, "name": provider }); + } + if (root.valueDefaultProvider !== "" && !seen[root.valueDefaultProvider]) { + items.push({ "key": root.valueDefaultProvider, "name": root.valueDefaultProvider }); + } + return items; + } + + function modelOptions(provider) { + var items = []; + for (var i = 0; i < root.availableModels.length; i++) { + var item = root.availableModels[i]; + if (provider !== "" && item.provider !== provider) continue; + items.push({ + "key": root.modelKey(item.provider || "", item.model || ""), + "name": item.name || item.model || "" + }); + } + if (root.valueDefaultModel !== "" && root.findModel(root.valueDefaultProvider, root.valueDefaultModel) === null) { + items.unshift({ + "key": root.selectedModelKey, + "name": root.valueDefaultModel + (root.valueDefaultProvider ? " (" + root.valueDefaultProvider + ")" : "") + }); + } + return items; + } + + function findModel(provider, model) { + for (var i = 0; i < root.availableModels.length; i++) { + var item = root.availableModels[i]; + if ((item.provider || "") === (provider || "") && (item.model || "") === (model || "")) return item; + } + return null; + } + + function findModelByKey(key) { + for (var i = 0; i < root.availableModels.length; i++) { + var item = root.availableModels[i]; + if (root.modelKey(item.provider || "", item.model || "") === key) return item; + } + return null; + } + + function firstModelForProvider(provider) { + for (var i = 0; i < root.availableModels.length; i++) { + var item = root.availableModels[i]; + if (provider === "" || item.provider === provider) return item; + } + return null; + } +} diff --git a/hermes-agent/assets/hermes-icon.png b/hermes-agent/assets/hermes-icon.png new file mode 100644 index 000000000..e0f04fe72 Binary files /dev/null and b/hermes-agent/assets/hermes-icon.png differ diff --git a/hermes-agent/components/ApprovalCard.qml b/hermes-agent/components/ApprovalCard.qml new file mode 100644 index 000000000..76c1b1e9a --- /dev/null +++ b/hermes-agent/components/ApprovalCard.qml @@ -0,0 +1,95 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +Rectangle { + id: root + + property var pluginApi: null + property var approval: ({}) + signal approve(bool all) + signal deny() + + readonly property bool pending: approval.pending || false + readonly property string toolName: approval.tool_name || approval.tool || "" + readonly property string message: approval.message || approval.reason || "" + + Layout.fillWidth: true + visible: pending + radius: Style.radiusS + color: Qt.rgba(0.96, 0.62, 0.04, 0.14) + border.color: "#f59e0b" + border.width: Style.borderS + implicitHeight: approvalLayout.implicitHeight + Style.marginL * 2 + + ColumnLayout { + id: approvalLayout + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginL + } + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NIcon { + icon: "shield-question" + pointSize: Style.fontSizeXL + color: "#f59e0b" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NText { + text: toolName !== "" ? toolName : pluginApi?.tr("panel.approvalRequired") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + Layout.fillWidth: true + elide: Text.ElideRight + } + + NText { + visible: message !== "" + text: message + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Item { Layout.fillWidth: true } + + NButton { + text: pluginApi?.tr("panel.deny") + icon: "x" + onClicked: root.deny() + } + + NButton { + text: pluginApi?.tr("panel.approve") + icon: "check" + onClicked: root.approve(false) + } + + NButton { + text: pluginApi?.tr("panel.approveSession") + icon: "checks" + onClicked: root.approve(true) + } + } + } +} diff --git a/hermes-agent/components/HermesAvatar.qml b/hermes-agent/components/HermesAvatar.qml new file mode 100644 index 000000000..c95963d15 --- /dev/null +++ b/hermes-agent/components/HermesAvatar.qml @@ -0,0 +1,45 @@ +import QtQuick +import qs.Commons +import qs.Widgets + +Item { + id: root + + property string iconPath: "" + property string fallbackIcon: "sparkles" + property color statusColor: Color.mOnSurface + property real iconSize: Style.fontSizeXL + property real dotSize: 8 * Style.uiScaleRatio + + Image { + id: avatarImage + anchors.fill: parent + source: root.iconPath + sourceSize.width: width + sourceSize.height: height + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + visible: status === Image.Ready + } + + NIcon { + anchors.centerIn: parent + visible: avatarImage.status !== Image.Ready + icon: root.fallbackIcon + pointSize: root.iconSize + applyUiScale: false + color: root.statusColor + } + + Rectangle { + width: root.dotSize + height: width + radius: width / 2 + anchors.right: parent.right + anchors.bottom: parent.bottom + color: root.statusColor + border.width: Style.borderS + border.color: Color.mSurface + } +} \ No newline at end of file diff --git a/hermes-agent/components/MessageBubble.qml b/hermes-agent/components/MessageBubble.qml new file mode 100644 index 000000000..5e1b37e4e --- /dev/null +++ b/hermes-agent/components/MessageBubble.qml @@ -0,0 +1,58 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +Rectangle { + id: root + + property var pluginApi: null + property var message: ({}) + + readonly property string role: message.role || message.type || "assistant" + readonly property string content: message.content || message.text || "" + readonly property bool fromUser: role === "user" + + Layout.fillWidth: true + radius: Style.radiusS + color: fromUser ? Color.mPrimary : Color.mSurface + implicitHeight: bubbleLayout.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: bubbleLayout + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginM + } + spacing: Style.marginXS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: root.fromUser ? "user" : "sparkles" + pointSize: Style.fontSizeM + color: root.fromUser ? Color.mOnPrimary : Color.mPrimary + } + + NText { + text: root.fromUser ? (pluginApi?.tr("bubble.you")) : (pluginApi?.tr("bubble.hermes")) + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: root.fromUser ? Color.mOnPrimary : Color.mOnSurface + Layout.fillWidth: true + } + } + + NText { + text: root.content + pointSize: Style.fontSizeM + color: root.fromUser ? Color.mOnPrimary : Color.mOnSurface + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } +} diff --git a/hermes-agent/components/SessionPopup.qml b/hermes-agent/components/SessionPopup.qml new file mode 100644 index 000000000..7d3aeddbe --- /dev/null +++ b/hermes-agent/components/SessionPopup.qml @@ -0,0 +1,150 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +Popup { + id: root + modal: true + dim: false + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + anchors.centerIn: Overlay.overlay + + property var pluginApi: null + property var mainInstance: null + property var screen: null + property var sessions: [] + readonly property real maxHeight: (screen ? screen.height : 800) * 0.5 + readonly property real rowHeight: 44 * Style.uiScaleRatio + + signal sessionSelected(string sessionId) + + implicitWidth: Math.min(680 * Style.uiScaleRatio, (screen ? screen.width * 0.7 : 680)) + implicitHeight: Math.min(Math.max(sessions.length * rowHeight, rowHeight) + headerHeight + padding * 2, maxHeight) + padding: Style.marginM + + readonly property real headerHeight: 32 * Style.uiScaleRatio + + background: Rectangle { + radius: Style.radiusL + color: Color.mSurface + border.color: Color.mPrimary + border.width: Style.borderM + } + + contentItem: ColumnLayout { + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: root.headerHeight + + NText { + text: pluginApi?.tr("panel.sessions") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: root.sessions.length > 0 ? root.sessions.length + " sessions" : "" + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + Layout.bottomMargin: Style.marginS + } + + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + + ColumnLayout { + width: parent ? parent.availableWidth : root.width + spacing: Style.marginXXS + + Repeater { + model: root.sessions + + ItemDelegate { + Layout.fillWidth: true + Layout.preferredHeight: root.rowHeight + background: Rectangle { + radius: Style.radiusM + color: hovered ? Qt.alpha(Color.mPrimary, 0.08) : "transparent" + } + onClicked: { + root.sessionSelected(modelData.id); + root.close(); + } + + contentItem: RowLayout { + spacing: Style.marginM + + NText { + text: String(index + 1) + "." + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: Color.mPrimary + Layout.preferredWidth: 30 * Style.uiScaleRatio + } + + NText { + text: modelData.title || modelData.preview || modelData.id?.substring(0, 8) || "Session" + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: _relativeTime(modelData.started_at) + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + Layout.preferredWidth: 48 * Style.uiScaleRatio + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.sessions.length === 0 + + NText { + anchors.centerIn: parent + text: pluginApi?.tr("panel.noSessions") + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + } + } + } + } + + function _relativeTime(ts) { + if (!ts || ts <= 0) return ""; + var diff = Date.now() - ts * 1000; + var minutes = Math.floor(diff / 60000); + if (minutes < 1) return "now"; + if (minutes < 60) return minutes + "m"; + var hours = Math.floor(minutes / 60); + if (hours < 24) return hours + "h"; + return Math.floor(hours / 24) + "d"; + } + + function openNear(_buttonItem) { + open(); + } +} diff --git a/hermes-agent/components/SummaryPopup.qml b/hermes-agent/components/SummaryPopup.qml new file mode 100644 index 000000000..5369371ae --- /dev/null +++ b/hermes-agent/components/SummaryPopup.qml @@ -0,0 +1,423 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +PopupWindow { + id: root + + property var pluginApi: null + property var state: ({}) + property ShellScreen screen: null + property var anchorItem: null + + readonly property var bridge: state.bridge || ({}) + readonly property var hermes: state.hermes || ({}) + readonly property var session: state.session || ({}) + readonly property var approval: state.approval || ({}) + readonly property var summary: state.summary || ({}) + readonly property var model: summary.model || ({}) + readonly property var mcp: summary.mcp || ({}) + readonly property var cron: summary.cron || ({}) + readonly property var cronJobs: cron.jobs || [] + readonly property var activity: summary.activity || ({}) + readonly property var gateway: summary.gateway || ({}) + readonly property string hermesIconPath: pluginApi?.pluginDir ? "file://" + pluginApi.pluginDir + "/assets/hermes-icon.png" : "" + readonly property string bridgeStatus: bridge.status || "offline" + readonly property string hermesStatus: bridgeStatus === "online" ? (hermes.status || "unknown") : "offline" + readonly property string statusLabel: statusText(hermesStatus) + readonly property string modelLabel: { + var provider = model.provider || hermes.provider || ""; + var name = model.name || hermes.model || ""; + if (provider && name) return provider + " / " + name; + return name || provider || (pluginApi?.tr("summary.notSet")); + } + readonly property string cronLabel: String(cron.active || 0) + " active / " + String(cron.total || 0) + " total" + readonly property string mcpLabel: { + if ((mcp.total || 0) === 0) return (pluginApi?.tr("summary.noMcpServers")); + return String(mcp.enabled || 0) + " enabled / " + String(mcp.total || 0) + " total"; + } + readonly property string activityLabel: { + var count = activity.tool_events || 0; + if (count === 0) return (pluginApi?.tr("summary.noBackgroundActions")); + var running = activity.running_tools || 0; + if (running > 0) return String(running) + " running"; + return String(count) + " background actions"; + } + readonly property string gatewayLabel: { + var platforms = gateway.platforms || ({}); + var names = Object.keys(platforms); + if (names.length === 0) return gateway.status || (pluginApi?.tr("summary.unknown")); + return names.map(function(name) { return name + ": " + platforms[name]; }).join(", "); + } + readonly property bool hasError: (bridge.error || "") !== "" + readonly property bool hasApproval: approval.pending === true + + signal openPanel() + signal newSession() + signal interrupt() + signal refresh() + signal settings() + + implicitWidth: 400 * Style.uiScaleRatio + implicitHeight: popupContent.implicitHeight + visible: false + color: "transparent" + + anchor.item: anchorItem + anchor.rect.x: { + if (!anchorItem || !screen) return 0; + var barPosition = Settings.getBarPositionForScreen(screen.name); + if (barPosition === "right") return -implicitWidth - Style.marginM; + if (barPosition === "left") return anchorItem.width + Style.marginM; + var anchorGlobal = anchorItem.mapToItem(null, 0, 0); + var centered = (anchorItem.width / 2) - (implicitWidth / 2); + var screenX = anchorGlobal.x + centered; + if (screenX < Style.marginM) return centered + (Style.marginM - screenX); + if (screenX + implicitWidth > screen.width - Style.marginM) { + return centered - ((screenX + implicitWidth) - (screen.width - Style.marginM)); + } + return centered; + } + anchor.rect.y: { + if (!anchorItem || !screen) return 0; + var barPosition = Settings.getBarPositionForScreen(screen.name); + var barHeight = Style.getBarHeightForScreen(screen.name); + if (barPosition === "bottom") return -implicitHeight - Style.marginS; + if (barPosition === "top") { + var anchorGlobal = anchorItem.mapToItem(null, 0, 0); + return barHeight + Style.marginS - anchorGlobal.y; + } + return Math.max(Style.marginM, (screen.height - implicitHeight) / 2) - anchorItem.mapToItem(null, 0, 0).y; + } + + function statusText(status) { + switch (status) { + case "offline": return pluginApi?.tr("status.offline"); + case "idle": return pluginApi?.tr("status.idle"); + case "busy": return pluginApi?.tr("status.busy"); + case "attention": return pluginApi?.tr("status.attention"); + case "degraded": return pluginApi?.tr("status.degraded"); + case "error": return pluginApi?.tr("status.error"); + default: return pluginApi?.tr("status.unknown"); + } + } + + function statusColor(status) { + switch (status) { + case "offline": return Color.mError; + case "busy": return Color.mPrimary; + case "attention": return "#f59e0b"; + case "degraded": return "#f97316"; + case "error": return Color.mError; + default: return Color.mPrimary; + } + } + + function toggleAt(item, itemScreen) { + anchorItem = item; + screen = itemScreen || null; + visible = !visible; + if (visible) { + Qt.callLater(function() { root.anchor.updateAnchor(); }); + } + } + + function close() { + visible = false; + } + + Rectangle { + id: popupContent + width: root.implicitWidth + implicitHeight: layout.implicitHeight + Style.margin2M + color: Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + radius: Style.radiusM + + ColumnLayout { + id: layout + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Style.marginM + } + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + Item { + Layout.preferredWidth: Style.fontSizeXXL + Layout.preferredHeight: Style.fontSizeXXL + Layout.alignment: Qt.AlignVCenter + + HermesAvatar { + anchors.fill: parent + iconPath: root.hermesIconPath + fallbackIcon: root.hermesStatus === "busy" ? "loader" : "sparkles" + statusColor: root.statusColor(root.hermesStatus) + iconSize: Style.fontSizeXL + dotSize: 8 * Style.uiScaleRatio + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: (pluginApi?.tr("summary.hermes")) + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: root.statusLabel + pointSize: Style.fontSizeS + color: root.statusColor(root.hermesStatus) + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + NButton { + text: pluginApi?.tr("bar.openPanel") + icon: "message-circle" + onClicked: { + root.close(); + root.openPanel(); + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + opacity: 0.5 + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: Style.marginM + rowSpacing: Style.marginS + + NText { + text: (pluginApi?.tr("summary.model")) + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.modelLabel + pointSize: Style.fontSizeS + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: (pluginApi?.tr("summary.cron")) + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.cronLabel + pointSize: Style.fontSizeS + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: (pluginApi?.tr("summary.mcp")) + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.mcpLabel + pointSize: Style.fontSizeS + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: (pluginApi?.tr("summary.gateway")) + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.gatewayLabel + pointSize: Style.fontSizeS + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: (pluginApi?.tr("summary.activity")) + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + + NText { + text: root.activityLabel + pointSize: Style.fontSizeS + color: Color.mOnSurface + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + ColumnLayout { + visible: root.cronJobs.length > 0 + Layout.fillWidth: true + spacing: Style.marginS + + NText { + text: pluginApi?.tr("bar.cronJobs") + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + Repeater { + model: root.cronJobs + + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: modelData.active ? "circle-check" : "circle-off" + pointSize: Style.fontSizeS + color: modelData.active ? Color.mPrimary : Color.mOnSurfaceVariant + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + NText { + text: modelData.name || (pluginApi?.tr("summary.unnamedJob")) + pointSize: Style.fontSizeS + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: (modelData.next_run || modelData.schedule || modelData.state || "").replace("T", " ") + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + NText { + text: modelData.last_status || modelData.state || "" + pointSize: Style.fontSizeXS + color: modelData.active ? Color.mPrimary : Color.mOnSurfaceVariant + elide: Text.ElideRight + } + } + } + } + + Rectangle { + visible: root.hasApproval || root.hasError + Layout.fillWidth: true + implicitHeight: alertRow.implicitHeight + Style.margin2S + radius: Style.radiusS + color: root.hasApproval ? Qt.rgba(0.96, 0.62, 0.04, 0.16) : Qt.rgba(0.94, 0.18, 0.18, 0.16) + + RowLayout { + id: alertRow + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: Style.marginS + rightMargin: Style.marginS + } + spacing: Style.marginS + + NIcon { + icon: root.hasApproval ? "bell-ringing" : "alert-triangle" + pointSize: Style.fontSizeS + color: root.hasApproval ? "#f59e0b" : Color.mError + } + + NText { + text: root.hasApproval ? (approval.message || (pluginApi?.tr("summary.approvalRequired"))) : bridge.error + pointSize: Style.fontSizeS + color: Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NButton { + text: pluginApi?.tr("bar.newSession") + icon: "plus" + Layout.fillWidth: true + onClicked: { + root.close(); + root.newSession(); + } + } + + NButton { + text: pluginApi?.tr("bar.interrupt") + icon: "octagon" + enabled: session.running || root.hermesStatus === "busy" + Layout.fillWidth: true + onClicked: root.interrupt() + } + + NButton { + text: pluginApi?.tr("bar.refresh") + icon: "refresh" + Layout.fillWidth: true + onClicked: root.refresh() + } + } + + NButton { + text: pluginApi?.tr("settings.title") + icon: "settings" + Layout.fillWidth: true + onClicked: { + root.close(); + root.settings(); + } + } + } + } +} diff --git a/hermes-agent/i18n/en.json b/hermes-agent/i18n/en.json new file mode 100644 index 000000000..069d54d30 --- /dev/null +++ b/hermes-agent/i18n/en.json @@ -0,0 +1,126 @@ +{ + "status": { + "offline": "Offline", + "idle": "Online", + "busy": "Working", + "attention": "Needs You", + "degraded": "Degraded", + "error": "Error", + "unknown": "Unknown" + }, + "bar": { + "openPanel": "Open Hermes", + "newSession": "New session", + "oneShot": "One-shot prompt", + "interrupt": "Interrupt", + "refresh": "Refresh", + "clearAttention": "Clear attention", + "cronJobs": "Cron jobs", + "startGateway": "Start gateway", + "gatewayStarting": "Gateway starting..." + }, + "panel": { + "title": "Hermes", + "send": "Send", + "interrupt": "Interrupt", + "approve": "Allow", + "approveSession": "Allow Session", + "deny": "Deny", + "newSession": "New Session", + "sessions": "Sessions", + "sessionsCount": "sessions", + "noSessions": "No recent sessions", + "reset": "Reset", + "resumeLatest": "Resume Latest", + "placeholder": "Ask Hermes...", + "bridgeOffline": "Bridge offline", + "noSession": "No active session", + "approvalRequired": "Approval required", + "pin": "Pin", + "pinned": "Pinned", + "unpin": "Unpin", + "close": "Close", + "noModel": "No model selected", + "backgroundRunning": "background action running", + "backgroundActions": "background actions", + "lastAction": "last" + }, + "launcher": { + "command": ">hermes", + "commandDescription": "Control Hermes", + "openPanel": "Open Hermes panel", + "openPanelDescription": "Show the persistent Hermes side panel", + "newSession": "Start Hermes session", + "newSessionDescription": "Create a new Hermes RPC session", + "resumeLatest": "Resume latest Hermes session", + "resumeLatestDescription": "Resume the stored session when available", + "ask": "Ask Hermes", + "typePrompt": "Type a prompt for Hermes" + }, + "settings": { + "title": "Hermes Settings", + "bridgeHost": "Bridge host", + "bridgePort": "Bridge port", + "stateFile": "State file", + "hermesHome": "Hermes home", + "hermesCommand": "Hermes command", + "clientOnlyMode": "Client-only mode (remote bridge)", + "clientOnlyModeDescription": "Connect to a Hermes bridge running on a remote server (over SSH) instead of starting one locally. Set host/port to the forwarded address and paste the bridge token below.", + "bridgeToken": "Bridge token", + "bridgeTokenDescription": "Token printed by the server helper (hermes-bridge-serve.sh). Required in client-only mode.", + "autoStartBridge": "Auto-start bridge", + "autoStartBridgeDescription": "Start the local bridge when Noctalia loads", + "autoStartGateway": "Auto-start gateway", + "autoStartGatewayDescription": "Start the Hermes gateway when it is not running", + "statusPollIntervalSec": "Status poll interval", + "hideWhenIdle": "Hide when idle", + "hideWhenIdleDescription": "Only show the bar pill when Hermes is active or needs attention", + "launcherPrefix": "Launcher prefix", + "defaultProvider": "Default provider", + "defaultModel": "Default model", + "providerSelect": "Provider", + "modelSelect": "Model", + "providerCurrent": "Current/default", + "applyModel": "Apply model to session", + "panelPinned": "Pin side panel", + "panelPinnedDescription": "Keep the Hermes panel in persistent side-chat mode", + "showToolActivity": "Show tool activity", + "showToolActivityDescription": "Keep tool calls collapsed into a compact activity line", + "advancedShow": "Advanced settings", + "advancedHide": "Hide advanced", + "testConnection": "Test connection", + "testing": "Testing...", + "testSuccess": "Connected", + "testFailed": "Failed:", + "testFailedUnreachable": "Unreachable: wrong host/port or bridge not running", + "testFailedAuth": "Wrong token" + }, + "summary": { + "hermes": "Hermes", + "model": "Model", + "cron": "Cron", + "mcp": "MCP", + "gateway": "Gateway", + "gatewayStart": "Start gateway", + "gatewayRestart": "Restart gateway", + "gatewayStarting": "Starting...", + "gatewayNotInstalled": "Not installed \u2014 run 'hermes gateway install' in a terminal", + "gatewayStartFailed": "Failed to start gateway", + "activity": "Activity", + "notSet": "Not set", + "unknown": "Unknown", + "noBackgroundActions": "No background actions", + "noMcpServers": "No MCP servers", + "approvalRequired": "Approval required", + "unnamedJob": "Unnamed job" + }, + "bubble": { + "you": "You", + "hermes": "Hermes" + }, + "errors": { + "emptyPrompt": "Prompt is empty", + "bridgeRequestFailed": "Bridge request failed", + "invalidState": "Invalid bridge state" + } +} diff --git a/hermes-agent/manifest.json b/hermes-agent/manifest.json new file mode 100644 index 000000000..d9e61fc2a --- /dev/null +++ b/hermes-agent/manifest.json @@ -0,0 +1,48 @@ +{ + "id": "hermes-agent", + "name": "Hermes Agent", + "version": "1.1.0", + "minNoctaliaVersion": "4.4.1", + "author": "nomadx, FelipeMayerDev", + "license": "MIT", + "description": "Native Noctalia status and side-panel interface for Hermes Agent. Supports a client-only mode to drive a Hermes bridge running on a remote server over SSH.", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "tags": [ + "Bar", + "Panel", + "Launcher", + "AI" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "launcherProvider": "LauncherProvider.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "commandPrefix": "hermes", + "defaultSettings": { + "bridgeHost": "127.0.0.1", + "bridgePort": 19777, + "stateFile": "~/.cache/noctalia-hermes/state.json", + "hermesHome": "~/.hermes", + "hermesCommand": "hermes", + "autoStartBridge": true, + "autoStartGateway": true, + "clientOnlyMode": false, + "bridgeTokenManual": "", + "statusPollIntervalSec": 30, + "hideWhenIdle": false, + "launcherPrefix": ">hermes", + "panelPinned": false, + "showToolActivity": false, + "defaultProvider": "", + "defaultModel": "", + "configured": false + } + } +} diff --git a/hermes-agent/preview.png b/hermes-agent/preview.png new file mode 100644 index 000000000..348dd4b2e Binary files /dev/null and b/hermes-agent/preview.png differ diff --git a/hermes-agent/screenshots/bar-popup.png b/hermes-agent/screenshots/bar-popup.png new file mode 100644 index 000000000..dd60224b9 Binary files /dev/null and b/hermes-agent/screenshots/bar-popup.png differ diff --git a/hermes-agent/screenshots/chat-panel.png b/hermes-agent/screenshots/chat-panel.png new file mode 100644 index 000000000..058209bc6 Binary files /dev/null and b/hermes-agent/screenshots/chat-panel.png differ diff --git a/hermes-agent/screenshots/settings.png b/hermes-agent/screenshots/settings.png new file mode 100644 index 000000000..9b0985afb Binary files /dev/null and b/hermes-agent/screenshots/settings.png differ diff --git a/hermes-agent/scripts/hermes-bridge-serve.sh b/hermes-agent/scripts/hermes-bridge-serve.sh new file mode 100755 index 000000000..a841bddc5 --- /dev/null +++ b/hermes-agent/scripts/hermes-bridge-serve.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh +# Run the Hermes bridge on a server so a remote Noctalia client (client-only mode) +# can drive it over an SSH tunnel. +# +# Usage: +# ./hermes-bridge-serve.sh [port] # default port 19777 +# +# The bridge binds to 127.0.0.1 only. Reach it from the client with an SSH tunnel: +# ssh -L :127.0.0.1: @ +# then in the plugin: enable "Client-only mode", host 127.0.0.1, port , +# and paste the token printed below. +set -eu + +PORT="${1:-19777}" +DIR="$(cd "$(dirname "$0")" && pwd)" +TOKEN_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/noctalia-hermes/bridge.token" + +# Start fresh so the token printed matches the running bridge. +rm -f "$TOKEN_FILE" 2>/dev/null || true + +echo "Starting Hermes bridge on 127.0.0.1:$PORT ..." +trap 'kill "$BRIDGE_PID" 2>/dev/null || true' INT TERM EXIT +python3 "$DIR/hermes_bridge.py" --host 127.0.0.1 --port "$PORT" & +BRIDGE_PID=$! + +# Wait for the bridge to generate its token. +i=0 +while [ ! -f "$TOKEN_FILE" ] && [ "$i" -lt 100 ]; do + if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then + echo "Error: Bridge process $BRIDGE_PID exited during startup." >&2 + exit 1 + fi + sleep 0.1 + i=$((i + 1)) +done + +echo +echo "Bridge PID : $BRIDGE_PID" +echo "Port : $PORT" +if [ -f "$TOKEN_FILE" ]; then + echo "Token : $(cat "$TOKEN_FILE")" +else + echo "Token : (not generated yet — check $TOKEN_FILE)" +fi +echo +echo "On the client run:" +echo " ssh -L $PORT:127.0.0.1:$PORT @" +echo +echo "Press Ctrl+C to stop the bridge." +wait "$BRIDGE_PID" diff --git a/hermes-agent/scripts/hermes_bridge.py b/hermes-agent/scripts/hermes_bridge.py new file mode 100755 index 000000000..abc727f2c --- /dev/null +++ b/hermes-agent/scripts/hermes_bridge.py @@ -0,0 +1,1126 @@ +#!/usr/bin/env python3 +"""Local bridge state model for the Noctalia Hermes plugin.""" + +from __future__ import annotations + +import json +import os +import secrets +import shutil +import subprocess +import sys +import time +import uuid +import argparse +from copy import deepcopy +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +FALSE_VALUES = {"false", "0", "no", "off", "disabled", "paused"} + + +def detect_hermes_home() -> str: + """Find the Hermes home directory. Checks env, then common paths.""" + env_home = os.environ.get("HERMES_HOME") + if env_home: + p = Path(env_home).expanduser() + if p.exists() and (p / "config.yaml").exists(): + return str(p) + + candidates = [ + Path.home() / ".hermes", + Path.home() / ".config" / "hermes", + Path.home() / ".local" / "share" / "hermes", + Path("/etc/hermes"), + ] + for c in candidates: + if c.exists() and (c / "config.yaml").exists(): + return str(c) + + for c in candidates: + if c.exists(): + return str(c) + + return str(Path.home() / ".hermes") + + +def detect_hermes_command() -> str: + """Find the hermes binary. Checks PATH, then common install locations.""" + found = shutil.which("hermes") + if found: + return found + + candidates = [ + Path.home() / ".local" / "bin" / "hermes", + Path.home() / ".hermes" / "bin" / "hermes", + Path("/usr/local/bin/hermes"), + Path("/usr/bin/hermes"), + ] + for c in candidates: + if c.exists() and os.access(c, os.X_OK): + return str(c) + + return "hermes" + + +def detect_gateway(hermes_home: str | Path) -> dict[str, Any]: + """Read gateway_state.json and return gateway status + PID.""" + path = Path(hermes_home).expanduser() / "gateway_state.json" + if not path.exists(): + return {"status": "offline", "pid": "", "platforms": {}} + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return {"status": "unknown", "pid": "", "platforms": {}} + pid = str(data.get("pid") or "") + state = str(data.get("gateway_state") or data.get("status") or "unknown") + platforms = data.get("platforms") or {} + if isinstance(platforms, dict): + platforms = { + str(k): str(v.get("state") or v.get("status") or "unknown") + for k, v in platforms.items() + if isinstance(v, dict) + } + else: + platforms = {} + return {"status": state, "pid": pid, "platforms": platforms} + + +def detect_model(hermes_home: str | Path) -> dict[str, str]: + """Read config.yaml and return the default model + provider.""" + config = scan_config_summary(hermes_home) + return config.get("model", {"name": "", "provider": ""}) + + +def detect_all(hermes_home: str | Path | None = None, hermes_command: str | None = None) -> dict[str, Any]: + """Run all detection checks and return a combined result.""" + home = hermes_home or detect_hermes_home() + cmd = hermes_command or detect_hermes_command() + gateway = detect_gateway(home) + model = detect_model(home) + home_path = Path(home).expanduser() + return { + "hermesHome": str(home_path), + "hermesCommand": cmd, + "hermesHomeExists": home_path.exists(), + "configExists": (home_path / "config.yaml").exists(), + "gateway": gateway, + "model": model, + "bridgeHost": "127.0.0.1", + "bridgePort": 19777, + "stateFile": "~/.cache/noctalia-hermes/state.json", + } + + +def now_ts() -> float: + return time.time() + + +def default_summary() -> dict[str, Any]: + return { + "model": {"name": "", "provider": ""}, + "models": [], + "mcp": {"enabled": 0, "total": 0, "status": "unknown"}, + "cron": {"active": 0, "total": 0, "next_run": "", "jobs": []}, + "activity": {"tool_events": 0, "running_tools": 0, "last_tool": ""}, + "gateway": {"status": "unknown", "platforms": {}}, + } + + +def default_state() -> dict[str, Any]: + return { + "bridge": {"status": "offline", "error": ""}, + "hermes": {"status": "unknown", "gateway_pid": "", "model": "", "provider": ""}, + "session": {"id": "", "stored_id": "", "title": "", "running": False, "cwd": ""}, + "messages": [], + "events": [], + "approval": {"pending": False, "message": "", "tool_name": "", "request": {}}, + "usage": {"input": 0, "output": 0, "total": 0, "cost_usd": None}, + "summary": default_summary(), + "updated_at": 0, + } + + +def refresh_summary(state: dict[str, Any], hermes_home: Path | str | None = None) -> dict[str, Any]: + next_state = deepcopy(state) + summary = default_summary() + home = Path(hermes_home or "~/.hermes").expanduser() + + config_summary = scan_config_summary(home) + summary["model"].update(config_summary["model"]) + summary["mcp"].update(config_summary["mcp"]) + summary["cron"].update(scan_cron_summary(home)) + summary["models"] = scan_model_options(home) + summary["gateway"].update(scan_gateway_summary(home)) + summary["activity"].update(activity_summary(next_state.get("events", []))) + + hermes = next_state.get("hermes") if isinstance(next_state.get("hermes"), dict) else {} + if hermes.get("model"): + summary["model"]["name"] = str(hermes["model"]) + if hermes.get("provider"): + summary["model"]["provider"] = str(hermes["provider"]) + + next_state["summary"] = summary + return next_state + + +def scan_config_summary(hermes_home: Path | str) -> dict[str, Any]: + path = Path(hermes_home).expanduser() / "config.yaml" + model = {"name": "", "provider": ""} + mcp = {"enabled": 0, "total": 0, "status": "unknown"} + if not path.exists(): + return {"model": model, "mcp": mcp} + + parsed = load_yaml_like(path) + model_section = parsed.get("model", {}) if isinstance(parsed.get("model"), dict) else {} + if isinstance(model_section, dict): + model["provider"] = str(model_section.get("provider") or "") + model["name"] = str( + model_section.get("default") + or model_section.get("name") + or model_section.get("model") + or "" + ) + + mcp_section = ( + parsed.get("mcp") + or parsed.get("mcps") + or parsed.get("mcp_servers") + or parsed.get("mcpServers") + ) + if isinstance(mcp_section, dict): + total = 0 + enabled = 0 + for value in mcp_section.values(): + total += 1 + if section_enabled(value): + enabled += 1 + mcp["enabled"] = enabled + mcp["total"] = total + mcp["status"] = "enabled" if enabled else "disabled" + + return {"model": model, "mcp": mcp} + + +def scan_cron_summary(hermes_home: Path | str) -> dict[str, Any]: + cron_dir = Path(hermes_home).expanduser() / "cron" + summary = {"active": 0, "total": 0, "next_run": "", "jobs": []} + if not cron_dir.exists(): + return summary + + jobs_path = cron_dir / "jobs.json" + if jobs_path.exists(): + try: + data = json.loads(jobs_path.read_text()) + except (OSError, json.JSONDecodeError): + data = {} + jobs = data.get("jobs") if isinstance(data, dict) else [] + if isinstance(jobs, list): + next_runs: list[str] = [] + normalized_jobs: list[dict[str, Any]] = [] + for job in jobs: + if not isinstance(job, dict): + continue + summary["total"] += 1 + active = section_enabled(job) + if active: + summary["active"] += 1 + next_run = str(job.get("next_run_at") or "") + if next_run: + next_runs.append(next_run) + normalized_jobs.append(normalize_cron_job(job, active)) + summary["next_run"] = sorted(next_runs)[0] if next_runs else "" + summary["jobs"] = sorted( + normalized_jobs, + key=lambda item: ( + 0 if item.get("active") else 1, + str(item.get("next_run") or "9999"), + str(item.get("name") or ""), + ), + )[:4] + return summary + + for path in cron_dir.iterdir(): + if path.name.startswith(".") or not path.is_file() or path.suffix.lower() not in {".json", ".yaml", ".yml"}: + continue + data = load_data_file(path) + summary["total"] += 1 + if section_enabled(data): + summary["active"] += 1 + return summary + + +def normalize_cron_job(job: dict[str, Any], active: bool) -> dict[str, Any]: + schedule = job.get("schedule") + schedule_display = "" + if isinstance(schedule, dict): + schedule_display = str(schedule.get("display") or schedule.get("expr") or "") + return { + "id": str(job.get("id") or ""), + "name": str(job.get("name") or job.get("id") or "Unnamed job"), + "active": active, + "state": str(job.get("state") or ("scheduled" if active else "paused")), + "schedule": str(job.get("schedule_display") or schedule_display), + "next_run": str(job.get("next_run_at") or ""), + "last_status": str(job.get("last_status") or ""), + } + + +def scan_model_options(hermes_home: Path | str) -> list[dict[str, str]]: + """Return available model options for the plugin settings picker. + + Merges all three sources Hermes uses: + 1. Provider config ``models:`` sections from config.yaml + 2. Provider models cache (Hermes model catalog) + 3. models.json (user favorites/history) + """ + home = Path(hermes_home).expanduser() + options: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + + # 1. Models from configured provider definitions in config.yaml. + config = load_yaml_like(home / "config.yaml") + providers = config.get("providers", {}) if isinstance(config, dict) else {} + if isinstance(providers, dict): + for name, cfg in providers.items(): + if not isinstance(cfg, dict): + continue + models = cfg.get("models") + if isinstance(models, dict): + for model_name in models: + add_model_option(options, seen, str(name), str(model_name), str(model_name)) + + # 2. Models from the Hermes model catalog cache. + cache_path = home / "provider_models_cache.json" + if cache_path.exists(): + try: + cache_data = json.loads(cache_path.read_text()) + except (OSError, json.JSONDecodeError): + cache_data = {} + if isinstance(cache_data, dict): + for provider, payload in cache_data.items(): + models = payload.get("models") if isinstance(payload, dict) else [] + if not isinstance(models, list): + continue + for model in models: + add_model_option(options, seen, str(provider), str(model), str(model)) + + # 3. models.json favorites. + models_path = home / "models.json" + if models_path.exists(): + try: + models_data = json.loads(models_path.read_text()) + except (OSError, json.JSONDecodeError): + models_data = [] + if isinstance(models_data, list): + for entry in models_data: + if not isinstance(entry, dict): + continue + add_model_option( + options, seen, + str(entry.get("provider") or ""), + str(entry.get("model") or ""), + str(entry.get("name") or entry.get("model") or ""), + ) + + return options + + +def add_model_option( + options: list[dict[str, str]], + seen: set[tuple[str, str]], + provider: str, + model: str, + name: str, +) -> None: + if not model: + return + key = (provider, model) + if key in seen: + return + seen.add(key) + label = name or model + if provider: + label = f"{label} ({provider})" + options.append({"provider": provider, "model": model, "name": label}) + + +def scan_gateway_summary(hermes_home: Path | str) -> dict[str, Any]: + path = Path(hermes_home).expanduser() / "gateway_state.json" + summary = {"status": "unknown", "platforms": {}} + if not path.exists(): + return summary + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return summary + if not isinstance(data, dict): + return summary + summary["status"] = str(data.get("gateway_state") or data.get("status") or "unknown") + platforms = data.get("platforms") + if isinstance(platforms, dict): + summary["platforms"] = { + str(name): str(value.get("state") or value.get("status") or "unknown") + for name, value in platforms.items() + if isinstance(value, dict) + } + return summary + + +def activity_summary(events: Any) -> dict[str, Any]: + if not isinstance(events, list): + return {"tool_events": 0, "running_tools": 0, "last_tool": ""} + + tool_events = [event for event in events if isinstance(event, dict) and str(event.get("type", "")).startswith("tool.")] + starts: dict[str, int] = {} + completes: dict[str, int] = {} + last_tool = "" + for event in tool_events: + name = str(event.get("name") or "") + if name: + last_tool = name + if event.get("type") == "tool.start": + starts[name] = starts.get(name, 0) + 1 + elif event.get("type") == "tool.complete": + completes[name] = completes.get(name, 0) + 1 + + running = 0 + for name, count in starts.items(): + running += max(0, count - completes.get(name, 0)) + return {"tool_events": len(tool_events), "running_tools": running, "last_tool": last_tool} + + +def load_data_file(path: Path) -> Any: + if path.suffix.lower() == ".json": + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return {} + return load_yaml_like(path) + + +def load_yaml_like(path: Path) -> dict[str, Any]: + text = path.read_text() + try: + import yaml # type: ignore + except Exception: + try: + from ruamel.yaml import YAML # type: ignore + except Exception: + return parse_simple_yaml(text) + data = YAML(typ="safe").load(text) or {} + return data if isinstance(data, dict) else {} + data = yaml.safe_load(text) or {} + return data if isinstance(data, dict) else {} + + +def parse_simple_yaml(text: str) -> dict[str, Any]: + root: dict[str, Any] = {} + stack: list[tuple[int, dict[str, Any]]] = [(-1, root)] + for raw_line in text.splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip() or ":" not in line: + continue + indent = len(line) - len(line.lstrip(" ")) + key, value = line.strip().split(":", 1) + key = key.strip().strip("'\"") + value = value.strip() + while stack and indent <= stack[-1][0]: + stack.pop() + parent = stack[-1][1] + if value == "": + child: dict[str, Any] = {} + parent[key] = child + stack.append((indent, child)) + else: + parent[key] = parse_scalar(value) + return root + + +def parse_scalar(value: str) -> Any: + cleaned = value.strip().strip("'\"") + lowered = cleaned.lower() + if lowered in {"true", "yes", "on"}: + return True + if lowered in FALSE_VALUES: + return False + return cleaned + + +def section_enabled(value: Any) -> bool: + if isinstance(value, dict): + if "enabled" in value: + return bool(value["enabled"]) + if "disabled" in value: + return not bool(value["disabled"]) + if "paused" in value: + return not bool(value["paused"]) + if str(value.get("state") or "").lower() in FALSE_VALUES: + return False + if value.get("paused_at"): + return False + return True + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() not in FALSE_VALUES + return value is not None + + +def health_payload(state: dict[str, Any]) -> dict[str, Any]: + snapshot = deepcopy(state) + snapshot["bridge"]["status"] = "online" + return {"bridge": snapshot["bridge"]} + + +def atomic_write_json(path: Path | str, data: dict[str, Any]) -> None: + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_name(f"{target.name}.{uuid.uuid4().hex}.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n") + os.chmod(tmp, 0o600) + tmp.replace(target) + + +def load_or_create_token(token_path: Path) -> str: + """Load the bridge auth token, or generate one if none exists.""" + token_path.parent.mkdir(parents=True, exist_ok=True) + try: + existing = token_path.read_text().strip() + if existing: + return existing + except (FileNotFoundError, OSError): + pass + token = secrets.token_urlsafe(32) + tmp = token_path.with_name(f"{token_path.name}.{uuid.uuid4().hex}.tmp") + tmp.write_text(token + "\n") + os.chmod(tmp, 0o600) + tmp.replace(token_path) + os.chmod(token_path, 0o600) + return token + + +def preferred_hermes_python( + hermes_home: Path | str, + current_executable: str | Path | None = None, +) -> Path | None: + venv_python = Path(hermes_home).expanduser() / "hermes-agent" / "venv" / "bin" / "python" + if not venv_python.exists() or not os.access(venv_python, os.X_OK): + return None + + current = Path(current_executable or sys.executable) + try: + if current.resolve() == venv_python.resolve(): + return None + except OSError: + if str(current) == str(venv_python): + return None + return venv_python + + +def reexec_with_hermes_python(args: argparse.Namespace, argv: list[str]) -> None: + if os.environ.get("NOCTALIA_HERMES_BRIDGE_REEXEC") == "1": + return + python = preferred_hermes_python(args.hermes_home) + if python is None: + return + env = os.environ.copy() + env["NOCTALIA_HERMES_BRIDGE_REEXEC"] = "1" + os.execve(str(python), [str(python), str(Path(__file__).resolve()), *argv], env) + + +class HermesState: + def __init__(self, state_file: Path | str, hermes_home: Path | str | None = None): + self.state_file = Path(state_file).expanduser() + self.hermes_home = Path(hermes_home or "~/.hermes").expanduser() + self._state = default_state() + self._active_assistant_id: str | None = None + + def snapshot(self) -> dict[str, Any]: + return refresh_summary(self._state, self.hermes_home) + + def write(self) -> None: + self._state["updated_at"] = now_ts() + atomic_write_json(self.state_file, self.snapshot()) + + def apply_event(self, obj: dict[str, Any]) -> None: + params = obj.get("params", {}) if isinstance(obj, dict) else {} + event_type = params.get("type", "") + payload = params.get("payload", {}) or {} + if not isinstance(payload, dict): + payload = {"text": str(payload)} + + if event_type == "message.start": + self._start_message() + elif event_type == "message.delta": + self._append_message_delta(str(payload.get("text") or "")) + elif event_type == "message.complete": + self._complete_message() + elif event_type in {"tool.start", "tool.complete"}: + self._append_tool_event(event_type, payload) + elif event_type == "approval.request": + self._set_approval(payload) + elif event_type == "session.info": + self._apply_session_info(payload) + elif event_type == "status.update": + self._append_status_event(payload) + elif event_type == "error": + self._state["bridge"]["error"] = str(payload.get("message") or "") + self._append_status_event({"kind": "error", "text": self._state["bridge"]["error"]}) + + self._state["updated_at"] = now_ts() + + def _start_message(self) -> None: + message = { + "id": f"msg-{uuid.uuid4().hex}", + "role": "assistant", + "text": "", + "streaming": True, + "ts": now_ts(), + } + self._state["messages"].append(message) + self._active_assistant_id = message["id"] + self._state["session"]["running"] = True + self._state["hermes"]["status"] = "busy" + + def _append_message_delta(self, text: str) -> None: + if not self._active_assistant_id: + self._start_message() + message = self._find_message(self._active_assistant_id) + if message is not None: + message["text"] += text + + def _complete_message(self) -> None: + message = self._find_message(self._active_assistant_id) + if message is not None: + message["streaming"] = False + self._active_assistant_id = None + self._state["session"]["running"] = False + if not self._state["approval"]["pending"]: + self._state["hermes"]["status"] = "idle" + + def _find_message(self, message_id: str | None) -> dict[str, Any] | None: + if not message_id: + return None + for message in self._state["messages"]: + if message.get("id") == message_id: + return message + return None + + def _append_tool_event(self, event_type: str, payload: dict[str, Any]) -> None: + self._state["events"].append({ + "id": f"evt-{uuid.uuid4().hex}", + "type": event_type, + "name": str(payload.get("name") or payload.get("tool_name") or ""), + "text": str(payload.get("text") or payload.get("message") or ""), + "ts": now_ts(), + }) + self._state["hermes"]["status"] = "busy" + + def _set_approval(self, payload: dict[str, Any]) -> None: + self._state["approval"] = { + "pending": True, + "message": str(payload.get("message") or ""), + "tool_name": str(payload.get("tool_name") or payload.get("name") or ""), + "request": deepcopy(payload), + } + self._state["hermes"]["status"] = "attention" + + def _apply_session_info(self, payload: dict[str, Any]) -> None: + info = payload.get("info", payload) + if not isinstance(info, dict): + return + self._state["hermes"]["model"] = str(info.get("model") or self._state["hermes"]["model"]) + self._state["hermes"]["provider"] = str(info.get("provider") or self._state["hermes"]["provider"]) + self._state["session"]["cwd"] = str(info.get("cwd") or self._state["session"]["cwd"]) + + def _append_status_event(self, payload: dict[str, Any]) -> None: + text = str(payload.get("text") or payload.get("message") or "") + if not text: + return + self._state["events"].append({ + "id": f"evt-{uuid.uuid4().hex}", + "type": "status.update", + "name": str(payload.get("kind") or "status"), + "text": text, + "ts": now_ts(), + }) + + def set_session_from_create(self, result: dict[str, Any]) -> None: + self._state["session"]["id"] = str(result.get("session_id") or "") + self._state["session"]["stored_id"] = str(result.get("stored_session_id") or "") + self._state["session"]["running"] = False + info = result.get("info") or {} + if isinstance(info, dict): + self._state["session"]["cwd"] = str(info.get("cwd") or self._state["session"]["cwd"]) + self._state["hermes"]["model"] = str(info.get("model") or self._state["hermes"]["model"]) + self._state["hermes"]["provider"] = str(info.get("provider") or self._state["hermes"]["provider"]) + self._state["hermes"]["status"] = "idle" + self._state["messages"] = [] + self._state["events"] = [] + self._state["approval"] = {"pending": False, "message": "", "tool_name": "", "request": {}} + self._state["updated_at"] = now_ts() + + def set_session_from_resume(self, result: dict[str, Any]) -> None: + self.set_session_from_create(result) + messages = result.get("messages") + if isinstance(messages, list): + normalized = [] + for item in messages: + if not isinstance(item, dict): + continue + normalized.append({ + "id": f"msg-{uuid.uuid4().hex}", + "role": str(item.get("role") or ""), + "text": str(item.get("text") or item.get("content") or ""), + "streaming": False, + "ts": now_ts(), + }) + self._state["messages"] = normalized + + def append_user_message(self, text: str) -> None: + self._state["messages"].append({ + "id": f"msg-{uuid.uuid4().hex}", + "role": "user", + "text": text, + "streaming": False, + "ts": now_ts(), + }) + self._state["session"]["running"] = True + self._state["hermes"]["status"] = "busy" + self._state["updated_at"] = now_ts() + + def append_assistant_message(self, text: str) -> None: + self._state["messages"].append({ + "id": f"msg-{uuid.uuid4().hex}", + "role": "assistant", + "text": text, + "streaming": False, + "ts": now_ts(), + }) + self._state["session"]["running"] = False + if not self._state["approval"]["pending"]: + self._state["hermes"]["status"] = "idle" + self._state["updated_at"] = now_ts() + + def clear_approval(self) -> None: + self._state["approval"] = {"pending": False, "message": "", "tool_name": "", "request": {}} + if not self._state["session"]["running"]: + self._state["hermes"]["status"] = "idle" + self._state["updated_at"] = now_ts() + + def set_model(self, provider: str, model: str) -> None: + self._state["hermes"]["provider"] = provider + self._state["hermes"]["model"] = model + self._state["updated_at"] = now_ts() + + def fail_request(self, message: str) -> None: + self._state["bridge"]["error"] = message + self._state["session"]["running"] = False + self._state["hermes"]["status"] = "error" + self._append_status_event({"kind": "error", "text": message}) + self._state["updated_at"] = now_ts() + + +class StateTransport: + def __init__(self, state: HermesState): + self.state = state + + def write(self, obj: dict[str, Any]) -> bool: + self.state.apply_event(obj) + self.state.write() + return True + + +class HermesRpcClient: + def __init__(self, state: HermesState, hermes_home: str | Path | None = None): + self.state = state + self.hermes_home = Path(hermes_home or "~/.hermes").expanduser() + self.transport = StateTransport(state) + self._server = None + + def dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + server = self._load_server() + request = { + "jsonrpc": "2.0", + "id": f"noctalia-{uuid.uuid4().hex}", + "method": method, + "params": params, + } + response = server.dispatch(request, self.transport) + return response or {"result": {"status": "accepted"}} + + def _load_server(self): + if self._server is not None: + return self._server + + agent_dir = self.hermes_home / "hermes-agent" + if agent_dir.exists(): + sys.path.insert(0, str(agent_dir)) + os.environ.setdefault("HERMES_HOME", str(self.hermes_home)) + + try: + from tui_gateway import server as tui_server + except Exception as exc: # pragma: no cover - depends on local Hermes install + raise RuntimeError(f"hermes_unavailable: {exc}") from exc + + self._server = tui_server + return self._server + + +class BridgeRequestHandler(BaseHTTPRequestHandler): + server_version = "HermesNoctaliaBridge/0.1" + + MAX_BODY_BYTES = 1_048_576 # ponytail: 1MB cap, prevents memory exhaustion + + def _check_token(self) -> bool: + token = getattr(self.server, "bridge_token", "") + if not token: + return True + provided = self.headers.get("X-Bridge-Token", "") + return secrets.compare_digest(token, provided) + + def do_GET(self) -> None: + if self.path == "/health": + self._send_json(200, health_payload(self.server.state.snapshot())) + return + if self.path == "/detect": + detected = detect_all( + self.server.state.hermes_home, + self.server.hermes_command, + ) + self._send_json(200, detected) + return + if not self._check_token(): + self._send_json(403, {"error": "forbidden"}) + return + if self.path == "/state": + self._send_json(200, self.server.state.snapshot()) + return + if self.path == "/sessions": + try: + response = self.server.rpc.dispatch("session.list", {"limit": 10}) + result = response.get("result", response) + self._send_json(200, result if isinstance(result, dict) else {"sessions": []}) + except Exception: + self._send_json(200, {"sessions": []}) + return + self._send_json(404, {"error": "not_found"}) + + def do_POST(self) -> None: + if not self._check_token(): + self._send_json(403, {"error": "forbidden"}) + return + payload = self._read_json() + if payload is None: + return + + try: + if self.path == "/session/create": + self._handle_session_create(payload) + elif self.path == "/session/resume": + self._handle_session_resume(payload) + elif self.path == "/prompt": + self._handle_prompt(payload) + elif self.path == "/interrupt": + self._handle_interrupt() + elif self.path == "/approval": + self._handle_approval(payload) + elif self.path == "/model": + self._handle_model(payload) + elif self.path == "/oneshot": + self._handle_oneshot(payload) + elif self.path == "/refresh": + self.server.state.write() + self._send_json(200, self.server.state.snapshot()) + elif self.path == "/gateway/start": + self._handle_gateway_start() + elif self.path == "/gateway/stop": + self._handle_gateway_stop() + else: + self._send_json(404, {"error": "not_found"}) + except Exception as exc: + self.server.state.fail_request(str(exc)) + self.server.state.write() + self._send_json(500, {"error": "bridge_error"}) + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def _handle_session_create(self, payload: dict[str, Any]) -> None: + params = {} + if payload.get("cwd"): + params["cwd"] = str(payload["cwd"]) + response = self.server.rpc.dispatch("session.create", params) + result = response.get("result", response) + if isinstance(result, dict): + self.server.state.set_session_from_create(result) + self.server.state.write() + self._send_json(200, self.server.state.snapshot()) + + def _handle_session_resume(self, payload: dict[str, Any]) -> None: + session_id = str(payload.get("session_id") or "").strip() + if not session_id: + self._send_json(400, {"error": "missing_session_id"}) + return + # Load messages directly from the SessionDB for instant UI feedback. + db_path = self.server.state.hermes_home / "state.db" + loaded = False + if db_path.exists(): + try: + import sqlite3 + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT role, content FROM messages WHERE session_id = ? ORDER BY timestamp", + (session_id,), + ).fetchall() + if rows: + normalized = [ + { + "id": f"msg-{uuid.uuid4().hex}", + "role": str(dict(r).get("role") or ""), + "text": str(dict(r).get("content") or ""), + "streaming": False, + "ts": 0, + } + for r in rows + ] + self.server.state._state["messages"] = normalized + self.server.state._state["session"]["id"] = "" + self.server.state._state["session"]["stored_id"] = session_id + self.server.state._state["session"]["running"] = False + self.server.state._state["hermes"]["status"] = "idle" + self.server.state._state["approval"] = ( + {"pending": False, "message": "", "tool_name": "", "request": {}} + ) + loaded = True + except Exception: + pass + # Also dispatch session.resume to the gateway so it creates a proper + # session object. The gateway needs this for prompt.submit to work. + self.server.rpc.dispatch("session.resume", {"session_id": session_id}) + self.server.state.write() + self._send_json(200, {"state": self.server.state.snapshot()}) + + def _handle_prompt(self, payload: dict[str, Any]) -> None: + text = str(payload.get("text") or "").strip() + if not text: + self._send_json(400, {"error": "empty_prompt"}) + return + + session_id = self.server.state.snapshot()["session"]["id"] + existing_messages = list(self.server.state._state["messages"]) + if not session_id: + create_response = self.server.rpc.dispatch("session.create", {}) + create_result = create_response.get("result", create_response) + if isinstance(create_result, dict): + self.server.state.set_session_from_create(create_result) + session_id = self.server.state.snapshot()["session"]["id"] + # Restore messages from a resumed session that were loaded from DB. + # set_session_from_create clears messages so only the new prompt + # would appear; we want the full history. + if existing_messages: + self.server.state._state["messages"] = existing_messages + + params = {"session_id": session_id, "text": text} + self.server.state.append_user_message(text) + self.server.state.write() + response = self.server.rpc.dispatch("prompt.submit", params) + self._send_json(200, {"result": response.get("result", response), "state": self.server.state.snapshot()}) + + def _handle_interrupt(self) -> None: + session_id = self.server.state.snapshot()["session"]["id"] + response = self.server.rpc.dispatch("session.interrupt", {"session_id": session_id}) + self.server.state._state["session"]["running"] = False + self.server.state._state["hermes"]["status"] = "idle" + self.server.state.write() + self._send_json(200, {"result": response.get("result", response), "state": self.server.state.snapshot()}) + + def _handle_approval(self, payload: dict[str, Any]) -> None: + session_id = self.server.state.snapshot()["session"]["id"] + params = { + "session_id": session_id, + "choice": str(payload.get("choice") or "deny"), + "all": bool(payload.get("all", False)), + } + response = self.server.rpc.dispatch("approval.respond", params) + self.server.state.clear_approval() + self.server.state.write() + self._send_json(200, {"result": response.get("result", response), "state": self.server.state.snapshot()}) + + def _handle_model(self, payload: dict[str, Any]) -> None: + model = str(payload.get("model") or "").strip() + provider = str(payload.get("provider") or "").strip() + persist = bool(payload.get("persist", False)) + if not model: + self._send_json(400, {"error": "empty_model"}) + return + + value_parts = [model] + if provider: + value_parts.extend(["--provider", provider]) + if persist: + value_parts.append("--global") + + params = { + "key": "model", + "value": " ".join(value_parts), + } + session_id = self.server.state.snapshot()["session"]["id"] + if session_id: + params["session_id"] = session_id + + response = self.server.rpc.dispatch("config.set", params) + result = response.get("result", response) + if isinstance(result, dict): + self.server.state.set_model(provider, str(result.get("value") or model)) + else: + self.server.state.set_model(provider, model) + self.server.state.write() + self._send_json(200, {"result": result, "state": self.server.state.snapshot()}) + + def _handle_oneshot(self, payload: dict[str, Any]) -> None: + text = str(payload.get("text") or "").strip() + if not text: + self._send_json(400, {"error": "empty_prompt"}) + return + self.server.state.append_user_message(text) + self.server.state.write() + result = self.server.command_runner( + [self.server.hermes_command, "-z", text], + capture_output=True, + text=True, + timeout=600, + ) + if result.returncode != 0: + message = (result.stderr or result.stdout or "oneshot failed").strip() + self.server.state._state["session"]["running"] = False + self.server.state._state["bridge"]["error"] = message + self.server.state.write() + self._send_json(500, {"error": "oneshot_failed", "message": message, "state": self.server.state.snapshot()}) + return + self.server.state.append_assistant_message((result.stdout or "").strip()) + self.server.state.write() + self._send_json(200, {"result": {"status": "completed"}, "state": self.server.state.snapshot()}) + + def _handle_gateway_start(self) -> None: + result = self.server.command_runner( + [self.server.hermes_command, "gateway", "start"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + msg = (result.stderr or result.stdout or "gateway start failed").strip() + self._send_json(500, {"error": "gateway_start_failed", "message": msg}) + return + self._send_json(200, {"result": {"status": "started"}}) + + def _handle_gateway_stop(self) -> None: + result = self.server.command_runner( + [self.server.hermes_command, "gateway", "stop"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + msg = (result.stderr or result.stdout or "gateway stop failed").strip() + self._send_json(500, {"error": "gateway_stop_failed", "message": msg}) + return + self._send_json(200, {"result": {"status": "stopped"}}) + + def _read_json(self) -> dict[str, Any] | None: + length = int(self.headers.get("Content-Length", "0") or "0") + if length == 0: + return {} + if length > self.MAX_BODY_BYTES: + self._send_json(413, {"error": "payload_too_large"}) + return None + raw = self.rfile.read(length) + try: + data = json.loads(raw.decode()) + except json.JSONDecodeError: + self._send_json(400, {"error": "invalid_json"}) + return None + if not isinstance(data, dict): + self._send_json(400, {"error": "invalid_payload"}) + return None + return data + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +def create_server( + host: str, + port: int, + state: HermesState, + rpc: Any, + hermes_command: str, +) -> ThreadingHTTPServer: + httpd = ThreadingHTTPServer((host, port), BridgeRequestHandler) + httpd.state = state + httpd.rpc = rpc + httpd.hermes_command = hermes_command + httpd.command_runner = subprocess.run + httpd.bridge_token = "" # set by run_server after token load + state._state["bridge"]["status"] = "online" + state._state["bridge"]["error"] = "" + state._state["updated_at"] = now_ts() + return httpd + + +def run_server( + host: str, + port: int, + state_file: str | Path, + hermes_home: str | Path, + hermes_command: str, +) -> None: + if not hermes_home or hermes_home == "~/.hermes": + hermes_home = detect_hermes_home() + if not hermes_command or hermes_command == "hermes": + hermes_command = detect_hermes_command() + state = HermesState(state_file, hermes_home) + rpc = HermesRpcClient(state, hermes_home) + httpd = create_server(host, port, state, rpc, hermes_command) + token_path = state.state_file.parent / "bridge.token" + token = load_or_create_token(token_path) + httpd.bridge_token = token + state.write() + httpd.serve_forever() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Noctalia bridge for Hermes Agent") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=19777) + parser.add_argument("--state-file", default="~/.cache/noctalia-hermes/state.json") + parser.add_argument("--hermes-home", default="~/.hermes") + parser.add_argument("--hermes-command", default="hermes") + parser.add_argument("--once-health", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + args = parse_args(argv) + reexec_with_hermes_python(args, argv) + if args.once_health: + print(json.dumps(health_payload(default_state()), ensure_ascii=False)) + return 0 + run_server(args.host, args.port, args.state_file, args.hermes_home, args.hermes_command) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/registry.json b/registry.json index a8d0725e1..63d45f901 100644 --- a/registry.json +++ b/registry.json @@ -14,7 +14,7 @@ "Desktop", "Fun" ], - "lastUpdated": "2026-02-04T23:08:01-07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "agent-session-status", @@ -91,7 +91,7 @@ "Productivity", "Network" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "asus-um5606-fan-state", @@ -224,7 +224,7 @@ "Panel", "Fun" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "claude-code-panel", @@ -293,7 +293,7 @@ "Network", "Panel" ], - "lastUpdated": "2026-04-11T18:01:28+07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "coin-flip", @@ -370,7 +370,7 @@ "tags": [ "Launcher" ], - "lastUpdated": "2026-03-27T20:43:49+07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "custom-sticker", @@ -411,14 +411,14 @@ "version": "2.0.2", "official": false, "author": "HAVEKziad", - "description": "Audio visualizer desktop widget — bars, wave, or mirror. Supports all directions. Transparent, follows color scheme.", + "description": "Audio visualizer desktop widget \u2014 bars, wave, or mirror. Supports all directions. Transparent, follows color scheme.", "minNoctaliaVersion": "4.6.6", "license": "MIT", "tags": [ "Desktop", "Audio" ], - "lastUpdated": "2026-03-20T22:21:12+01:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "display-settings", @@ -455,7 +455,7 @@ "System", "Utility" ], - "lastUpdated": "2026-04-13T17:24:58-06:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "dns-switcher", @@ -471,7 +471,7 @@ "Bar", "Network" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "ds4-colors", @@ -504,7 +504,7 @@ "Desktop", "Audio" ], - "lastUpdated": "2026-03-27T20:43:49+07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "file-search", @@ -623,7 +623,25 @@ "tags": [ "Development" ], - "lastUpdated": "2026-03-30T17:33:26+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" + }, + { + "id": "hermes-agent", + "name": "Hermes Agent", + "version": "1.1.0", + "official": false, + "author": "nomadx, FelipeMayerDev", + "description": "Native Noctalia status, chat panel, and launcher provider for Hermes Agent. Bar widget with traffic-light status + summary popup, streaming chat with tool-call approvals, session management, and a >hermes launcher command. Authenticated HTTP bridge to Hermes, with a client-only mode to drive a bridge running on a remote server over SSH.", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "minNoctaliaVersion": "4.4.1", + "license": "MIT", + "tags": [ + "Bar", + "Panel", + "Launcher", + "AI" + ], + "lastUpdated": "2026-06-21T13:30:00+00:00" }, { "id": "hot-corners", @@ -639,7 +657,7 @@ "Desktop", "Productivity" ], - "lastUpdated": "2026-03-18T17:51:21+01:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "hyprland-steam-overlay", @@ -647,7 +665,7 @@ "version": "2.2.0", "official": false, "author": "blacku", - "description": "[DEPRECATED — not maintained from Hyprland 0.55+] Steam overlay with automatic window management for Hyprland. Due to Hyprland 0.55 deprecating hyprlang in favour of Lua (changing hyprctl dispatch parsing and popup/xdg handling), this plugin is no longer actively maintained. The 2.2.0 release adds a best-effort dual-mode dispatch fix and an optional custom Lua layout, but no further updates are planned. Automatically moves all Steam windows (except fullscreen games) to an overlay workspace; main 3 windows (Friends, Client, Chat) positioned in a row, fully responsive percentage-based layout. Requires Hyprland window manager.", + "description": "[DEPRECATED \u2014 not maintained from Hyprland 0.55+] Steam overlay with automatic window management for Hyprland. Due to Hyprland 0.55 deprecating hyprlang in favour of Lua (changing hyprctl dispatch parsing and popup/xdg handling), this plugin is no longer actively maintained. The 2.2.0 release adds a best-effort dual-mode dispatch fix and an optional custom Lua layout, but no further updates are planned. Automatically moves all Steam windows (except fullscreen games) to an overlay workspace; main 3 windows (Friends, Client, Chat) positioned in a row, fully responsive percentage-based layout. Requires Hyprland window manager.", "repository": "https://github.com/noctalia-dev/noctalia-plugins", "minNoctaliaVersion": "4.1.2", "license": "MIT", @@ -690,7 +708,7 @@ "Panel", "System" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "ip-monitor", @@ -705,7 +723,7 @@ "tags": [ "Development" ], - "lastUpdated": "2026-04-14T20:44:12+07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "kagi-quick-search", @@ -721,7 +739,7 @@ "Launcher", "Productivity" ], - "lastUpdated": "2026-03-10T11:27:20-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "kaomoji-provider", @@ -737,7 +755,7 @@ "Launcher", "Fun" ], - "lastUpdated": "2026-02-10T10:12:36-05:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "kde-connect", @@ -755,7 +773,7 @@ "Utility", "System" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "keep-awake-plus", @@ -835,7 +853,7 @@ "version": "1.0.2", "official": false, "author": "Borhaneddine GUEMIDI ", - "description": "Network latency monitor — multi-host, windowed averages, sparkline graphs", + "description": "Network latency monitor \u2014 multi-host, windowed averages, sparkline graphs", "repository": "https://github.com/noctalia-dev/noctalia-plugins", "minNoctaliaVersion": "4.1.0", "license": "MIT", @@ -925,7 +943,7 @@ "Bar", "Panel" ], - "lastUpdated": "2026-04-15T16:35:10+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mimeapp-gui", @@ -943,7 +961,7 @@ "Utility", "System" ], - "lastUpdated": "2026-04-20T21:30:27+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mini-docker", @@ -1032,7 +1050,7 @@ "Sway", "Hyprland" ], - "lastUpdated": "2026-04-16T23:55:46-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mpd", @@ -1049,7 +1067,7 @@ "Audio", "Music" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mpris-lyric", @@ -1065,7 +1083,7 @@ "Bar", "Music" ], - "lastUpdated": "2026-04-19T11:52:26+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mpvpaper", @@ -1083,7 +1101,7 @@ "Fun", "System" ], - "lastUpdated": "2026-04-18T21:56:28+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "mullvad", @@ -1119,7 +1137,7 @@ "Audio", "Music" ], - "lastUpdated": "2026-04-18T22:02:45+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "netbird", @@ -1188,7 +1206,7 @@ "Network", "Fun" ], - "lastUpdated": "2026-04-18T23:08:10+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "niri-animation-picker", @@ -1206,7 +1224,7 @@ "Theming", "Utility" ], - "lastUpdated": "2026-04-08T18:53:05+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "niri-auto-tile", @@ -1224,7 +1242,7 @@ "System", "Niri" ], - "lastUpdated": "2026-03-23T17:28:07-03:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "niri-overview-launcher", @@ -1239,7 +1257,7 @@ "tags": [ "System" ], - "lastUpdated": "2026-02-05T11:41:54-08:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "niri-screensaver", @@ -1280,7 +1298,7 @@ "name": "Niri Workspaces", "version": "1.0.0", "official": false, - "author": "Kohányi Róbert", + "author": "Koh\u00e1nyi R\u00f3bert", "description": "Filter, jump to, rename or reset Niri workspaces from the launcher.", "repository": "https://github.com/noctalia-dev/noctalia-plugins", "minNoctaliaVersion": "4.4.1", @@ -1324,7 +1342,7 @@ "Panel", "System" ], - "lastUpdated": "2026-02-23T20:48:07-05:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "not-just-text", @@ -1340,7 +1358,7 @@ "Bar", "Fun" ], - "lastUpdated": "2026-04-13T22:31:42Z" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "notes-scratchpad", @@ -1357,7 +1375,7 @@ "Panel", "Productivity" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "ntfy-notifications", @@ -1375,7 +1393,7 @@ "Network", "Indicator" ], - "lastUpdated": "2026-04-10T15:49:56+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "nvibrant", @@ -1408,7 +1426,7 @@ "Launcher", "Development" ], - "lastUpdated": "2026-03-21T21:19:00+01:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "obs-control", @@ -1426,7 +1444,7 @@ "Indicator", "Utility" ], - "lastUpdated": "2026-04-18T22:24:44+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "obsidian-provider", @@ -1458,7 +1476,7 @@ "Bar", "Panel" ], - "lastUpdated": "2026-04-01T18:21:46+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "osk-toggle", @@ -1523,7 +1541,7 @@ "System", "Security" ], - "lastUpdated": "2026-03-27T20:43:49+07:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "pomodoro", @@ -1539,7 +1557,7 @@ "Bar", "Productivity" ], - "lastUpdated": "2026-02-01T21:52:23+05:30" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "port-monitor", @@ -1558,7 +1576,7 @@ "System", "Utility" ], - "lastUpdated": "2026-04-19T11:51:23+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "privacy-indicator", @@ -1607,7 +1625,7 @@ "Bar", "Panel" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "rss-feed", @@ -1620,7 +1638,7 @@ "minNoctaliaVersion": "3.6.0", "license": "MIT", "tags": [], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "ru-menu", @@ -1636,7 +1654,7 @@ "Bar", "Panel" ], - "lastUpdated": "2026-04-10T17:07:23+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "saved-desktop-widgets", @@ -1652,7 +1670,7 @@ "Fun", "System" ], - "lastUpdated": "2026-03-22T18:59:32+01:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "screen-recorder", @@ -1668,7 +1686,7 @@ "Bar", "Utility" ], - "lastUpdated": "2026-04-02T10:26:27+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "screen-shot-and-record", @@ -1721,7 +1739,7 @@ "Bar", "Utility" ], - "lastUpdated": "2026-04-19T00:00:51+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "shell-profiles", @@ -1755,7 +1773,7 @@ "Indicator", "System" ], - "lastUpdated": "2026-04-09T18:01:49+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "simple-notes", @@ -1772,7 +1790,7 @@ "Panel", "Productivity" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "slowbongo", @@ -1789,7 +1807,7 @@ "Audio", "Fun" ], - "lastUpdated": "2026-04-15T09:46:06+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "special-workspaces", @@ -1806,7 +1824,7 @@ "Productivity", "Hyprland" ], - "lastUpdated": "2026-04-18T22:57:42+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "squeekboard-toggle", @@ -1823,7 +1841,7 @@ "Bar", "Utility" ], - "lastUpdated": "2026-04-13T18:41:37+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "ssh-sessions", @@ -1842,7 +1860,7 @@ "Utility", "Development" ], - "lastUpdated": "2026-04-13T20:17:59+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "steam-price-watcher", @@ -1855,7 +1873,7 @@ "minNoctaliaVersion": "3.6.0", "license": "MIT", "tags": [], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "sticky-notes", @@ -1890,7 +1908,7 @@ "System", "Utility" ], - "lastUpdated": "2026-03-24T10:16:52-03:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "sys-info-widget", @@ -1974,7 +1992,7 @@ "Bar", "Utility" ], - "lastUpdated": "2026-04-01T18:03:19+08:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "todo", @@ -2007,7 +2025,7 @@ "tags": [ "Launcher" ], - "lastUpdated": "2026-04-11T00:50:22+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "unicode-picker", @@ -2023,7 +2041,7 @@ "Launcher", "Utility" ], - "lastUpdated": "2026-02-10T10:12:36-05:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "update-count", @@ -2039,7 +2057,7 @@ "Bar", "System" ], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "usb-drive-manager", @@ -2108,7 +2126,7 @@ "Launcher", "Development" ], - "lastUpdated": "2026-04-08T22:23:54+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "wallcards", @@ -2172,7 +2190,7 @@ "Panel", "Productivity" ], - "lastUpdated": "2026-04-01T22:22:03+02:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "workspace-overview", @@ -2203,7 +2221,7 @@ "minNoctaliaVersion": "3.6.0", "license": "MIT", "tags": [], - "lastUpdated": "2026-03-29T19:05:12-04:00" + "lastUpdated": "2026-04-21T15:49:39Z" }, { "id": "zed-provider",