diff --git a/hermes-status/BarWidget.qml b/hermes-status/BarWidget.qml new file mode 100644 index 000000000..65de1e827 --- /dev/null +++ b/hermes-status/BarWidget.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Modules.Bar.Extras +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + property var pluginApi: null + property var hermesService: pluginApi?.mainInstance?.hermesService || null + + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + readonly property bool hideWhenIdle: cfg.hideWhenIdle ?? defaults.hideWhenIdle ?? false + readonly property string status: hermesService?.status ?? "loading" + readonly property var usage: hermesService?.usage ?? ({}) + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + + // ── Traffic light: icon + color per status ── + readonly property string currentIcon: { + 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 "help-circle"; + } + } + + readonly property color iconColor: { + 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 displayText: { + if (status === "attention") return "!"; + if (status === "degraded") return "!"; + // Conversation ended / idle: keep the green status icon, but hide stale + // token usage from the previous turn/session. + if (status === "idle") return ""; + return root.usageText; + } + + function formatTokens(value) { + var n = Number(value || 0); + if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + "k"; + return String(Math.round(n)); + } + + function formatCost(value) { + if (value === undefined || value === null) return ""; + var n = Number(value); + if (!isFinite(n) || n <= 0) return ""; + if (n < 0.0001) return "$" + n.toFixed(6); + if (n < 0.01) return "$" + n.toFixed(4); + return "$" + n.toFixed(2); + } + + readonly property string usageText: { + if (!usage || !usage.available || !usage.total_tokens) return ""; + var text = formatTokens(usage.total_tokens) + " tok"; + var cost = formatCost(usage.actual_cost_usd ?? usage.estimated_cost_usd); + if (cost !== "") text += " · " + cost; + return text; + } + + readonly property bool shouldHide: hideWhenIdle && status === "idle" + + implicitWidth: shouldHide ? 0 : pill.width + implicitHeight: shouldHide ? 0 : pill.height + visible: !shouldHide + + BarPill { + id: pill + screen: root.screen + oppositeDirection: BarService.getPillDirection(root) + icon: root.currentIcon + text: root.displayText + forceOpen: root.displayText !== "" + autoHide: true + customTextIconColor: root.iconColor + + onClicked: { + if (pluginApi) { + if (hermesService && hermesService.needsAttention) { + hermesService.clearAttention(); + } + pluginApi.openPanel(root.screen, root); + } + } + + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen); + } + } + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("menu.refresh"), + "action": "refresh", + "icon": "refresh" + }, + { + "label": pluginApi?.tr("menu.clear-attention"), + "action": "clear-attention", + "icon": "bell-off" + } + ] + + onTriggered: function(action) { + contextMenu.close(); + PanelService.closeContextMenu(screen); + if (action === "refresh") { + hermesService?.refresh(); + } else if (action === "clear-attention") { + hermesService?.clearAttention(); + } + } + } +} diff --git a/hermes-status/Main.qml b/hermes-status/Main.qml new file mode 100644 index 000000000..9c98bbce9 --- /dev/null +++ b/hermes-status/Main.qml @@ -0,0 +1,254 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Item { + id: root + + property var pluginApi: null + + // Expose service to bar widget + property alias hermesService: hermesService + // Expose process model for Panel/BarWidget + property alias processModel: processListModel + + // Path to the status check script (~ expanded to home directory) + readonly property string scriptPath: { + var cfg = pluginApi?.pluginSettings || {}; + var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {}; + var raw = cfg.statusScript ?? defaults.statusScript ?? "~/.cache/noctalia/plugins/hermes-status/hermes-status-check"; + return expandHome(raw); + } + + // Shared ListModel for process list (lives at Item level, not inside QtObject) + ListModel { + id: processListModel + } + + QtObject { + id: hermesService + + property string status: "idle" + property string gatewayPid: "" + property string cliPid: "" + property bool cliActive: false + property int activeCliCount: 0 + property bool needsAttention: false + property var platforms: ({}) + property string fetchState: "idle" + property string errorMessage: "" + property string signalEvent: "" + property string signalTs: "" + property var usage: ({}) + property var processes: ([]) + property ListModel processModel: processListModel + // Pending refresh: set when a refresh is requested while one is in-flight, + // so the UI does not miss status transitions during rapid hook updates. + property bool pendingRefresh: false + + property bool hasError: { + for (var key in platforms) { + if (platforms[key] && platforms[key].state !== "connected") return true; + } + return false; + } + + function refresh(force) { + // If a fetch is already in-flight, mark pending so onExited will re-trigger. + // force=true bypasses the stuck guard (used by IPC manual refresh). + if (fetchState === "loading" || statusProcess.running) { + if (force) { + // Force-reset stuck state: kill the old process and start fresh. + statusProcess.running = false; + fetchState = "idle"; + pendingRefresh = false; + } else { + pendingRefresh = true; + return; + } + } + fetchState = "loading"; + // sh -c allows shell features in custom statusScript settings (env vars, wrappers, args) + statusProcess.command = ["sh", "-c", root.scriptPath]; + statusProcess.running = true; + fetchWatchdog.restart(); + } + + function clearAttention() { + // Clear the flag only after the rm process succeeds. + clearAttentionProcess.command = ["rm", "-f", Quickshell.env("HOME") + "/.hermes/needs_attention"]; + clearAttentionProcess.running = true; + } + } + + 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; + } + + readonly property string signalFilePath: { + var cfg = pluginApi?.pluginSettings || {}; + var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {}; + return expandHome(cfg.signalFile ?? defaults.signalFile ?? "~/.hermes/status_signal"); + } + + // Watch the hook signal file for near-instant UI refresh. The timer below is + // still kept as a low-frequency safety net for process/gateway changes that do + // not rewrite status_signal. + FileView { + id: signalFileView + path: root.signalFilePath + printErrors: false + watchChanges: true + + onFileChanged: { + reload(); + refreshDebounce.restart(); + } + + onLoaded: refreshDebounce.restart() + onLoadFailed: refreshDebounce.restart() + } + + Timer { + id: refreshDebounce + interval: 100 + repeat: false + onTriggered: hermesService.refresh() + } + + // Status check process + Process { + id: statusProcess + stdout: StdioCollector {} + + onExited: function(exitCode) { + fetchWatchdog.stop(); + if (exitCode !== 0) { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "Script failed (exit " + exitCode + ")"; + if (hermesService.pendingRefresh) { + hermesService.pendingRefresh = false; + hermesService.refresh(); + } + return; + } + + var response = stdout.text; + if (!response || response.trim() === "") { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "Empty response"; + if (hermesService.pendingRefresh) { + hermesService.pendingRefresh = false; + hermesService.refresh(); + } + return; + } + + try { + var data = JSON.parse(response); + hermesService.status = data.status || "unknown"; + hermesService.gatewayPid = data.gateway_pid || ""; + hermesService.cliPid = data.cli_pid || ""; + hermesService.cliActive = data.cli_active || false; + hermesService.activeCliCount = data.active_cli_count || 0; + hermesService.needsAttention = data.needs_attention || false; + hermesService.platforms = data.platforms || {}; + hermesService.signalEvent = data.signal_event || ""; + hermesService.signalTs = data.signal_ts || ""; + hermesService.usage = data.usage || {}; + hermesService.processes = data.processes || []; + hermesService.fetchState = "success"; + hermesService.errorMessage = ""; + + // Update ListModel for QML Repeaters + processListModel.clear(); + var procs = data.processes || []; + for (var i = 0; i < procs.length; i++) { + var p = procs[i]; + processListModel.append({ + "pid": p.pid || "", + "source": p.source || "unknown", + "sessionId": p.session_id || "", + "state": p.state || "idle", + "event": p.event || "", + "ts": p.ts || "", + "signalAge": p.signal_age !== undefined ? p.signal_age : -1, + "platform": p.platform || "", + "alive": p.alive !== false + }); + } + } catch (e) { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "JSON parse error: " + e; + } + + // If another refresh was requested while we were loading, re-trigger now. + if (hermesService.pendingRefresh) { + hermesService.pendingRefresh = false; + hermesService.refresh(); + } + } + } + + // Clear attention process + Process { + id: clearAttentionProcess + stdout: StdioCollector {} + onExited: function(exitCode) { + if (exitCode === 0) { + hermesService.needsAttention = false; + } + } + } + + // Watchdog: if fetchState stays "loading" for >15s, force-reset. + // Handles cases where onExited doesn't fire (process crash, QML bug). + Timer { + id: fetchWatchdog + interval: 15000 + repeat: false + onTriggered: { + if (hermesService.fetchState === "loading") { + statusProcess.running = false; + hermesService.fetchState = "idle"; + hermesService.pendingRefresh = false; + } + } + } + + // Poll timer + Timer { + id: pollTimer + repeat: true + running: true + triggeredOnStart: true + interval: { + var cfg = pluginApi?.pluginSettings || {}; + var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {}; + var secs = cfg.pollInterval ?? defaults.pollInterval ?? 10; + return secs * 1000; + } + onTriggered: hermesService.refresh() + } + + IpcHandler { + target: "plugin:hermes-status" + function refresh() { + hermesService.refresh(true); // force: bypass stuck guard + } + function toggle() { + if (pluginApi) { + pluginApi.withCurrentScreen(function(screen) { + pluginApi.togglePanel(screen); + }); + } + } + } +} diff --git a/hermes-status/Panel.qml b/hermes-status/Panel.qml new file mode 100644 index 000000000..e96c522a7 --- /dev/null +++ b/hermes-status/Panel.qml @@ -0,0 +1,360 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +Item { + id: root + + property var pluginApi: null + property var hermesService: pluginApi?.mainInstance?.hermesService || null + + property ShellScreen screen + readonly property var geometryPlaceholder: panelContainer + property real contentPreferredWidth: 280 * Style.uiScaleRatio + property real contentPreferredHeight: 200 * Style.uiScaleRatio + readonly property bool allowAttach: true + + readonly property string status: hermesService?.status ?? "unknown" + readonly property bool cliActive: hermesService?.cliActive ?? false + readonly property int activeCliCount: hermesService?.activeCliCount ?? 0 + readonly property string gatewayPid: hermesService?.gatewayPid ?? "" + readonly property string signalEvent: hermesService?.signalEvent ?? "" + readonly property var platforms: hermesService?.platforms ?? ({}) + readonly property var usage: hermesService?.usage ?? ({}) + readonly property var processModel: hermesService?.processModel ?? null + + 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 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 "help-circle"; + } + } + + 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 eventText: { + var map = { + "pre_llm_call": pluginApi?.tr("event.thinking"), + "post_llm_call": pluginApi?.tr("event.processing"), + "pre_tool_call": pluginApi?.tr("event.tool_call"), + "post_tool_call": pluginApi?.tr("event.tool_done"), + "pre_approval_request": pluginApi?.tr("event.awaiting_approval"), + "on_session_start": pluginApi?.tr("event.started"), + "on_session_end": pluginApi?.tr("event.ended"), + "on_session_finalize": pluginApi?.tr("event.finalizing"), + "on_session_reset": pluginApi?.tr("event.reset") + }; + return map[signalEvent] || ""; + } + + function formatTokens(value) { + var n = Number(value || 0); + if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + "k"; + return String(Math.round(n)); + } + + function formatCost(value) { + if (value === undefined || value === null) return ""; + var n = Number(value); + if (!isFinite(n) || n <= 0) return ""; + if (n < 0.0001) return "$" + n.toFixed(6); + if (n < 0.01) return "$" + n.toFixed(4); + return "$" + n.toFixed(2); + } + + readonly property string tokensText: { + if (!usage || !usage.available) return pluginApi?.tr("panel.none"); + return formatTokens(usage.total_tokens) + + " in " + formatTokens(usage.input_tokens) + + " / out " + formatTokens(usage.output_tokens) + + " / cache " + formatTokens(usage.cache_tokens); + } + + readonly property string costText: { + if (!usage || !usage.available) return pluginApi?.tr("panel.unknown"); + var actual = formatCost(usage.actual_cost_usd); + if (actual !== "") return actual + " " + pluginApi?.tr("panel.actual"); + var estimated = formatCost(usage.estimated_cost_usd); + if (estimated !== "") return estimated + " " + pluginApi?.tr("panel.estimated"); + return pluginApi?.tr("panel.unknown"); + } + + readonly property bool costIsUnknown: !usage || !usage.available || (!formatCost(usage.actual_cost_usd) && !formatCost(usage.estimated_cost_usd)) + + // Process state → icon helper + function processStateIcon(state) { + switch (state) { + case "busy": return "loader"; + case "attention": return "bell-ringing"; + case "idle": return "circle-check"; + case "error": return "alert-triangle"; + default: return "help-circle"; + } + } + + // Process state → color helper + function processStateColor(state) { + switch (state) { + case "busy": return Color.mPrimary; + case "attention": return "#f59e0b"; + case "idle": return Color.mPrimary; + case "error": return Color.mError; + default: return Color.mOnSurface; + } + } + + // Source label (i18n) + function sourceLabel(source) { + switch (source) { + case "gateway": return pluginApi?.tr("panel.gw"); + case "cli": return pluginApi?.tr("panel.cli"); + case "cron": return pluginApi?.tr("panel.cron"); + default: return "?"; + } + } + + // Format short session id (first 8 chars) + function shortSessionId(sid) { + if (!sid) return ""; + if (sid.length <= 8) return sid; + return sid.substring(0, 8) + "\u2026"; + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + NBox { + anchors.fill: parent + anchors.margins: Style.marginS + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginXS + + // Row 1: icon + name + status + RowLayout { + spacing: Style.marginS + + NIcon { + icon: root.statusIcon + color: root.statusColor + pointSize: Style.fontSizeM + } + + NText { + text: pluginApi?.tr("panel.hermes") + font.weight: Font.Bold + pointSize: Style.fontSizeS + color: Color.mOnSurface + } + + NText { + text: root.statusText + pointSize: Style.fontSizeS + color: root.statusColor + } + + Item { Layout.fillWidth: true } + + NText { + text: root.eventText + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + visible: text !== "" + } + } + + // Separator + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + opacity: 0.2 + } + + // Gateway row (always shown) + RowLayout { + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.gateway") + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 60 + } + + NText { + text: gatewayPid ? "PID " + gatewayPid : pluginApi?.tr("panel.stopped") + pointSize: Style.fontSizeS + color: gatewayPid ? Color.mOnSurface : Color.mError + } + } + + // Process list (non-gateway processes) + Repeater { + model: { + if (!root.processModel) return []; + var items = []; + for (var i = 0; i < root.processModel.count; i++) { + var p = root.processModel.get(i); + if (p.source !== "gateway") { + items.push(p); + } + } + return items; + } + + delegate: RowLayout { + spacing: Style.marginS + + NIcon { + icon: processStateIcon(modelData.state) + color: processStateColor(modelData.state) + pointSize: Style.fontSizeXS + } + + NText { + text: sourceLabel(modelData.source) + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 24 + } + + NText { + text: shortSessionId(modelData.sessionId) || ("PID " + modelData.pid) + pointSize: Style.fontSizeS + color: Color.mOnSurface + Layout.fillWidth: true + elide: Text.ElideRight + } + + NText { + text: modelData.alive ? "" : pluginApi?.tr("panel.dead") + pointSize: Style.fontSizeS + color: Color.mError + opacity: 0.6 + } + } + } + + // Separator before platforms + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Color.mOutline + opacity: 0.2 + } + + // Platforms + Repeater { + model: { + var items = []; + for (var key in root.platforms) { + items.push({ + "name": key.charAt(0).toUpperCase() + key.slice(1), + "ok": root.platforms[key]?.state === "connected" + }); + } + return items; + } + + delegate: RowLayout { + spacing: Style.marginS + + NText { + text: modelData.name + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 60 + } + + NText { + text: modelData.ok ? pluginApi?.tr("panel.online") : pluginApi?.tr("panel.offline") + pointSize: Style.fontSizeS + color: modelData.ok ? Color.mPrimary : Color.mError + } + } + } + + // Token usage (always visible below platforms) + RowLayout { + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.tokens") + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 60 + } + + NText { + text: root.tokensText + pointSize: Style.fontSizeS + color: Color.mPrimary + opacity: 0.8 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + // Cost + RowLayout { + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.cost") + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 60 + } + + NText { + text: root.costText + pointSize: Style.fontSizeS + color: root.costIsUnknown ? Color.mOnSurface : Color.mPrimary + opacity: root.costIsUnknown ? 0.45 : 1.0 + } + } + } + } + } +} diff --git a/hermes-status/README.md b/hermes-status/README.md new file mode 100644 index 000000000..3900d73dd --- /dev/null +++ b/hermes-status/README.md @@ -0,0 +1,231 @@ +# noctalia-hermes + +A [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell) plugin that displays real-time [Hermes Agent](https://github.com/nousresearch/hermes-agent) status in the status bar. + +Hermes shell hooks record lifecycle events, which the noctalia plugin listens to via a signal file for near-instant UI refresh. A low-frequency polling fallback is also included. +Hook writes typically complete in under 1 second; the status bar UI refreshes shortly after the signal file changes, with a default 30-second fallback check for Gateway/platform status. + +## Preview +![](preview.png) + +The status bar shows a traffic-light icon that changes in real time with Hermes status: + +| Icon | Color | Status | Trigger | +|------|-------|--------|---------| +| ✓ | Green | Online | Gateway running, idle | +| ⟳ | Blue | Busy | Thinking, tool call, processing | +| 🔔 | Amber | Needs You | Awaiting user approval | +| ⚠ | Orange | Degraded | Platform connection issue (e.g. Telegram disconnected) | +| ⏻ | Red | Offline | Gateway not running | + +Click the icon to open a detail panel showing Gateway PID, session state, and platform connections. + +## Architecture + +``` +hermes hooks (lifecycle events) + │ + ▼ +hermes-status-hook ← writes signal file ~/.hermes/status_signal + │ + ▼ +hermes-status-check ← combined detection script, outputs JSON + │ + ▼ +noctalia plugin (QML) ← watches status_signal changes, 30s fallback polling +``` + +### Status Detection Priority + +1. **Hook signals** — real-time hermes lifecycle events (highest priority) +2. **Process detection** — checks for CLI session and Gateway processes +3. **Platform status** — reads gateway_state.json to detect connection issues +4. **Manual Attention flag** — reminder file set by `hermes-attention` + +The busy signal persists while the CLI process is active, regardless of time. If the CLI exits abnormally (without triggering `on_session_end`), the busy signal auto-expires after 60 seconds and falls back to process detection to prevent stale state. The attention signal persists until Hermes emits `post_approval_response`; manual attention persists until `hermes-attention clear`. + +## Installation + +### Method A: One-click install + +```bash +git clone https://github.com/Mel-SRK/noctalia-hermes ~/.local/share/noctalia-hermes +cd ~/.local/share/noctalia-hermes +./install.sh +``` + +`install.sh` will: + +- Link the plugin to `~/.config/noctalia/plugins/hermes-status` +- Install `hermes-status-check` to `~/.cache/noctalia/plugins/hermes-status/hermes-status-check` +- Install `hermes-status-hook` and `hermes-attention` to `~/.local/bin` + +You still need to configure hermes hooks as described below. + +### Method B: Manual installation + +#### 1. Clone the repository + +Place the project anywhere you like; the example below uses `~/.local/share/noctalia-hermes`: + +```bash +git clone https://github.com/Mel-SRK/noctalia-hermes ~/.local/share/noctalia-hermes +cd ~/.local/share/noctalia-hermes +``` + +If you're using a fork, replace the repository URL above with your fork URL. + +#### 2. Install the plugin to noctalia + +```bash +mkdir -p ~/.config/noctalia/plugins +ln -sfn ~/.local/share/noctalia-hermes/hermes-status ~/.config/noctalia/plugins/hermes-status +``` + +#### 3. Install helper scripts + +```bash +mkdir -p ~/.cache/noctalia/plugins/hermes-status ~/.local/bin + +# Status detection script (called by noctalia plugin) +install -m 755 ~/.local/share/noctalia-hermes/hermes-status-check ~/.cache/noctalia/plugins/hermes-status/hermes-status-check + +# Hook scripts (called by hermes) +install -m 755 ~/.local/share/noctalia-hermes/hermes-status-hook ~/.local/bin/hermes-status-hook + +# Optional: manual attention flag tool +install -m 755 ~/.local/share/noctalia-hermes/hermes-attention ~/.local/bin/hermes-attention +``` + +Make sure `~/.local/bin` is in your `PATH`: + +```bash +case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile ;; +esac +``` + +### Configure hermes hooks + +Add the following to the `hooks:` section in `~/.hermes/config.yaml`: + +```yaml +hooks: + pre_llm_call: + - command: "~/.local/bin/hermes-status-hook pre_llm_call" + post_llm_call: + - command: "~/.local/bin/hermes-status-hook post_llm_call" + pre_tool_call: + - command: "~/.local/bin/hermes-status-hook pre_tool_call" + post_tool_call: + - command: "~/.local/bin/hermes-status-hook post_tool_call" + pre_approval_request: + - command: "~/.local/bin/hermes-status-hook pre_approval_request" + post_approval_response: + - command: "~/.local/bin/hermes-status-hook post_approval_response" + on_session_start: + - command: "~/.local/bin/hermes-status-hook on_session_start" + on_session_end: + - command: "~/.local/bin/hermes-status-hook on_session_end" + on_session_finalize: + - command: "~/.local/bin/hermes-status-hook on_session_finalize" + on_session_reset: + - command: "~/.local/bin/hermes-status-hook on_session_reset" +``` + +### Restart services + +```bash +# Restart noctalia-shell to load the plugin +pkill -x qs +qs -c noctalia-shell -d + +# Restart hermes gateway to load hooks +hermes gateway restart +``` + +New `hermes chat` sessions started after this will automatically load the hooks. + +## File Structure + +``` +noctalia-hermes/ +├── README.md +├── hermes-status/ ← noctalia plugin (place in plugins/ directory) +│ ├── manifest.json ← plugin metadata and default settings +│ ├── Main.qml ← background logic (signal file watcher + fallback polling) +│ ├── BarWidget.qml ← status bar icon (traffic light) +│ ├── Panel.qml ← click-to-expand detail panel +│ └── Settings.qml ← plugin settings UI +├── hermes-status-check ← status detection script (single Python process) +├── hermes-status-hook ← hook script (hermes event recorder) +└── hermes-attention ← manual attention flag tool +``` + +### hermes-status-check + +Called by the noctalia plugin when the signal file changes and as a 30-second fallback, outputs JSON status: + +```json +{ + "status": "idle", + "gateway_running": true, + "gateway_pid": "203245", + "cli_active": true, + "cli_pid": "9329", + "needs_attention": false, + "signal_event": "post_tool_call", + "signal_ts": "2026-05-29T14:00:00+08:00", + "signal_age": 3, + "platforms": {"telegram": {"state": "connected"}} +} +``` + +### hermes-status-hook + +Called by the hermes hooks system, writes signal file based on event type: + +| Hook Event | Signal State | Meaning | +|-----------|----------|------| +| `pre_llm_call` | busy | LLM call started | +| `post_llm_call` | busy | LLM returned result | +| `pre_tool_call` | busy | About to execute tool | +| `post_tool_call` | busy | Tool execution complete | +| `on_session_start` | busy | Session started | +| `pre_approval_request` | attention | Awaiting user approval | +| `post_approval_response` | idle | User has responded | +| `on_session_end` | idle | Session ended | +| `on_session_finalize` | idle | Session cleanup complete | +| `on_session_reset` | idle | Session reset | + +### hermes-attention + +Manual attention flag management tool: + +```bash +hermes-attention set # Set amber bell +hermes-attention clear # Clear +hermes-attention status # Check status +``` + +## Configuration + +In noctalia Settings → Plugins → Hermes Agent, you can adjust: + +| Option | Default | Description | +|--------|---------|-------------| +| Status check script | `~/.cache/noctalia/plugins/hermes-status/hermes-status-check` | Detection script path | +| Poll interval | 30s | Fallback polling interval; hook status changes refresh immediately via file watching | +| Signal file | `~/.hermes/status_signal` | Hermes hook status signal file | +| Hide when idle | false | Hide icon when running normally | + +## Dependencies + +- [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell) — Wayland desktop shell +- [Hermes Agent](https://github.com/nousresearch/hermes-agent) — AI assistant +- python3 + +## License + +MIT diff --git a/hermes-status/Settings.qml b/hermes-status/Settings.qml new file mode 100644 index 000000000..5d5691cc2 --- /dev/null +++ b/hermes-status/Settings.qml @@ -0,0 +1,93 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var cfg: pluginApi?.pluginSettings || ({}) + + spacing: Style.marginM + + // statusScript + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.statusScript") + font.pixelSize: Style.fontSizeS + font.weight: Font.DemiBold + color: Color.mOnSurface + } + + NTextInput { + Layout.fillWidth: true + text: cfg.statusScript ?? pluginApi?.manifest?.metadata?.defaultSettings?.statusScript ?? "" + placeholderText: "~/.cache/noctalia/plugins/hermes-status/hermes-status-check" + onEditingFinished: { + pluginApi.setPluginSetting("statusScript", text); + } + } + } + + // pollInterval + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.pollInterval") + font.pixelSize: Style.fontSizeS + font.weight: Font.DemiBold + color: Color.mOnSurface + } + + NSpinBox { + Layout.fillWidth: true + from: 5 + to: 300 + value: cfg.pollInterval ?? pluginApi?.manifest?.metadata?.defaultSettings?.pollInterval ?? 30 + onValueModified: { + pluginApi.setPluginSetting("pollInterval", value); + } + } + } + + // signalFile + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NText { + text: pluginApi?.tr("settings.signalFile") + font.pixelSize: Style.fontSizeS + font.weight: Font.DemiBold + color: Color.mOnSurface + } + + NTextInput { + Layout.fillWidth: true + text: cfg.signalFile ?? pluginApi?.manifest?.metadata?.defaultSettings?.signalFile ?? "" + placeholderText: "~/.hermes/status_signal" + onEditingFinished: { + pluginApi.setPluginSetting("signalFile", text); + } + } + } + + // hideWhenIdle + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.hideWhenIdle") + description: pluginApi?.tr("settings.hideWhenIdleDesc") + checked: cfg.hideWhenIdle ?? pluginApi?.manifest?.metadata?.defaultSettings?.hideWhenIdle ?? false + onToggled: checked => { + pluginApi.setPluginSetting("hideWhenIdle", checked); + } + defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.hideWhenIdle ?? false + } +} diff --git a/hermes-status/i18n/en.json b/hermes-status/i18n/en.json new file mode 100644 index 000000000..79907a472 --- /dev/null +++ b/hermes-status/i18n/en.json @@ -0,0 +1,52 @@ +{ + "menu": { + "refresh": "Refresh", + "clear-attention": "Clear Attention" + }, + "status": { + "offline": "Offline", + "idle": "Online", + "busy": "Working", + "attention": "Needs You", + "degraded": "Degraded", + "error": "Error", + "unknown": "Unknown" + }, + "event": { + "thinking": "Thinking", + "processing": "Processing", + "tool_call": "Tool call", + "tool_done": "Tool done", + "awaiting_approval": "Awaiting approval", + "started": "Started", + "ended": "Ended", + "finalizing": "Finalizing", + "reset": "Reset" + }, + "panel": { + "hermes": "Hermes", + "gateway": "Gateway", + "session": "Session", + "tokens": "Tokens", + "cost": "Cost", + "stopped": "Stopped", + "active": "Active", + "none": "None", + "online": "Online", + "offline": "Offline", + "unknown": "unknown", + "actual": "actual", + "estimated": "estimated", + "gw": "GW", + "cli": "CLI", + "cron": "Cron", + "dead": "dead" + }, + "settings": { + "statusScript": "Status check script", + "pollInterval": "Poll interval (seconds)", + "signalFile": "Signal file", + "hideWhenIdle": "Hide when idle", + "hideWhenIdleDesc": "Only show when gateway is offline, busy, or needs attention" + } +} diff --git a/hermes-status/i18n/zh-CN.json b/hermes-status/i18n/zh-CN.json new file mode 100644 index 000000000..63931eec2 --- /dev/null +++ b/hermes-status/i18n/zh-CN.json @@ -0,0 +1,52 @@ +{ + "menu": { + "refresh": "刷新", + "clear-attention": "清除提醒" + }, + "status": { + "offline": "离线", + "idle": "在线", + "busy": "工作中", + "attention": "需要你", + "degraded": "降级", + "error": "错误", + "unknown": "未知" + }, + "event": { + "thinking": "思考中", + "processing": "处理中", + "tool_call": "调用工具", + "tool_done": "工具完成", + "awaiting_approval": "等待审批", + "started": "已开始", + "ended": "已结束", + "finalizing": "清理中", + "reset": "重置" + }, + "panel": { + "hermes": "Hermes", + "gateway": "网关", + "session": "会话", + "tokens": "Token", + "cost": "费用", + "stopped": "已停止", + "active": "活跃", + "none": "无", + "online": "在线", + "offline": "离线", + "unknown": "未知", + "actual": "实际", + "estimated": "预估", + "gw": "GW", + "cli": "CLI", + "cron": "Cron", + "dead": "已断开" + }, + "settings": { + "statusScript": "状态检查脚本", + "pollInterval": "轮询间隔(秒)", + "signalFile": "信号文件", + "hideWhenIdle": "空闲时隐藏", + "hideWhenIdleDesc": "仅在网关离线、工作中或需要关注时显示" + } +} diff --git a/hermes-status/manifest.json b/hermes-status/manifest.json new file mode 100644 index 000000000..fbc245017 --- /dev/null +++ b/hermes-status/manifest.json @@ -0,0 +1,28 @@ +{ + "id": "hermes-status", + "name": "Hermes Agent Status", + "version": "2.0.0", + "minNoctaliaVersion": "4.1.2", + "author": "srk", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Traffic-light status indicator for Hermes Agent (CLI + Gateway). Supports multiple concurrent Hermes processes with per-session tracking. Shows online/busy/attention/degraded/offline states in the status bar with real-time hook-based updates.", + "tags": ["Bar", "Panel", "AI", "Indicator"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "statusScript": "~/.cache/noctalia/plugins/hermes-status/hermes-status-check", + "pollInterval": 30, + "signalFile": "~/.hermes/status_signal", + "hideWhenIdle": false + } + } +} diff --git a/hermes-status/preview.png b/hermes-status/preview.png new file mode 100644 index 000000000..f6c9da743 Binary files /dev/null and b/hermes-status/preview.png differ