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..16a9cf9df --- /dev/null +++ b/hermes-status/Main.qml @@ -0,0 +1,171 @@ +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 + + // Path to the status check script (~ is expanded by sh) + readonly property string scriptPath: { + var cfg = pluginApi?.pluginSettings || {}; + var defaults = pluginApi?.manifest?.metadata?.defaultSettings || {}; + return cfg.statusScript ?? defaults.statusScript ?? "~/.config/noctalia/hermes-status-check"; + } + + QtObject { + id: hermesService + + property string status: "idle" + property string gatewayPid: "" + property string cliPid: "" + property bool cliActive: false + property bool needsAttention: false + property var platforms: ({}) + property string fetchState: "idle" + property string errorMessage: "" + property string signalEvent: "" + property string signalTs: "" + property var usage: ({}) + + property bool hasError: { + for (var key in platforms) { + if (platforms[key] && platforms[key].state !== "connected") return true; + } + return false; + } + + function refresh() { + fetchState = "loading"; + // sh -c expands ~ to home directory + statusProcess.command = ["sh", "-c", root.scriptPath]; + statusProcess.running = true; + } + + function clearAttention() { + needsAttention = false; + 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) { + if (exitCode !== 0) { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "Script failed (exit " + exitCode + ")"; + return; + } + + var response = stdout.text; + if (!response || response.trim() === "") { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "Empty response"; + 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.needsAttention = data.needs_attention || false; + hermesService.platforms = data.platforms || {}; + hermesService.signalEvent = data.signal_event || ""; + hermesService.signalTs = data.signal_ts || ""; + hermesService.usage = data.usage || {}; + hermesService.fetchState = "success"; + hermesService.errorMessage = ""; + } catch (e) { + hermesService.fetchState = "error"; + hermesService.status = "error"; + hermesService.errorMessage = "JSON parse error: " + e; + } + } + } + + // Clear attention process + Process { + id: clearAttentionProcess + stdout: StdioCollector {} + } + + // 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(); + } + 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..6e53c410b --- /dev/null +++ b/hermes-status/Panel.qml @@ -0,0 +1,280 @@ +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: 240 * Style.uiScaleRatio + property real contentPreferredHeight: 150 * Style.uiScaleRatio + readonly property bool allowAttach: true + + readonly property string status: hermesService?.status ?? "unknown" + readonly property bool cliActive: hermesService?.cliActive ?? false + 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 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") + }; + 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)) + + 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 + } + + // Row 2: Gateway + 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 + } + } + + // Row 3: Session + RowLayout { + spacing: Style.marginS + + NText { + text: pluginApi?.tr("panel.session") + pointSize: Style.fontSizeS + color: Color.mOnSurface + opacity: 0.5 + Layout.preferredWidth: 60 + } + + NText { + text: cliActive ? pluginApi?.tr("panel.active") : pluginApi?.tr("panel.none") + pointSize: Style.fontSizeS + opacity: cliActive ? 1.0 : 0.4 + } + } + + + // Row 4: Tokens + 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.mOnSurface + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + // Row 5: 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 + } + } + + // Row 6+: 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 + } + } + } + } + } + } +} diff --git a/hermes-status/README.md b/hermes-status/README.md new file mode 100644 index 000000000..0cbc12680 --- /dev/null +++ b/hermes-status/README.md @@ -0,0 +1,230 @@ +# noctalia-hermes + +[noctalia-shell](https://github.com/noctalia-dev/noctalia-shell) 插件 — 在状态栏实时显示 [Hermes Agent](https://github.com/nousresearch/hermes-agent) 的运行状态。 + +通过 hermes shell hooks 记录 Hermes 生命周期事件,noctalia 插件监听状态文件变化并立即刷新;同时保留低频轮询作为兜底。 +Hook 写入通常低于 1 秒;状态栏 UI 会在信号文件变化后快速刷新,默认每 30 秒额外兜底检查一次 Gateway/平台状态。 + +## 效果 +![](README_20260529162507901.png) + +![](README_20260529154926688.png) + +状态栏显示一个交通灯图标,随 hermes 状态实时变化: + +| 图标 | 颜色 | 状态 | 触发条件 | +|------|------|------|----------| +| ✓ | 绿色 | Online | Gateway 运行中,空闲 | +| ⟳ | 蓝色 | Busy | 正在思考、执行工具、处理中 | +| 🔔 | 琥珀色 | Needs You | 等待用户审批命令或回答问题 | +| ⚠ | 橙色 | Degraded | 平台连接异常(如 Telegram 断线) | +| ⏻ | 红色 | Offline | Gateway 未运行 | + +点击图标弹出详情面板,显示 Gateway PID、会话状态、平台连接。 + +## 架构 + +``` +hermes hooks (事件记录) + │ + ▼ +hermes-status-hook ← 写信号文件 ~/.hermes/status_signal + │ + ▼ +hermes-status-check ← 综合检测脚本,输出 JSON + │ + ▼ +noctalia plugin (QML) ← 监听 status_signal 变化,默认每 30 秒兜底轮询 +``` + +### 状态检测优先级 + +1. **Hook 信号** — hermes 生命周期事件实时写入(最高优先级) +2. **进程检测** — 检查 CLI 会话和 Gateway 进程是否存在 +3. **平台状态** — 读取 gateway_state.json 检测连接异常 +4. **Manual Attention 标志** — `hermes-attention` 手动设置的提醒文件 + +busy 信号超过 30 秒未更新自动回退到 idle,防止 hermes 异常退出后状态卡住。attention 信号会保持到 Hermes 发出 `post_approval_response`,手动 attention 则保持到 `hermes-attention clear`。 + +## 安装 + +### 方式 A:一键安装 + +```bash +git clone https://github.com/Mel-SRK/noctalia-hermes ~/.local/share/noctalia-hermes +cd ~/.local/share/noctalia-hermes +./install.sh +``` + +`install.sh` 会: + +- 把插件链接到 `~/.config/noctalia/plugins/hermes-status` +- 安装 `hermes-status-check` 到 `~/.config/noctalia/hermes-status-check` +- 安装 `hermes-status-hook` 和 `hermes-attention` 到 `~/.local/bin` + +之后仍需按下面的“配置 hermes hooks”步骤修改 `~/.hermes/config.yaml`。 + +### 方式 B:手动安装 + +#### 1. 克隆项目 + +把项目放到任意你喜欢的位置即可,下面用 `~/.local/share/noctalia-hermes` 作为示例: + +```bash +git clone https://github.com/Mel-SRK/noctalia-hermes ~/.local/share/noctalia-hermes +cd ~/.local/share/noctalia-hermes +``` + +如果你使用 fork,请把上面的仓库地址替换成自己的 fork 地址。 + +#### 2. 安装插件到 noctalia + +```bash +mkdir -p ~/.config/noctalia/plugins +ln -sfn ~/.local/share/noctalia-hermes/hermes-status ~/.config/noctalia/plugins/hermes-status +``` + +#### 3. 安装辅助脚本 + +```bash +mkdir -p ~/.config/noctalia ~/.local/bin + +# 状态检测脚本(noctalia 插件调用) +install -m 755 ~/.local/share/noctalia-hermes/hermes-status-check ~/.config/noctalia/hermes-status-check + +# Hook 脚本(hermes 调用) +install -m 755 ~/.local/share/noctalia-hermes/hermes-status-hook ~/.local/bin/hermes-status-hook + +# 可选:手动设置 attention 标志的工具 +install -m 755 ~/.local/share/noctalia-hermes/hermes-attention ~/.local/bin/hermes-attention +``` + +确保 `~/.local/bin` 在 `PATH` 中: + +```bash +case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile ;; +esac +``` + +### 配置 hermes hooks + +在 `~/.hermes/config.yaml` 的 `hooks:` 段添加: + +```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" +``` + +### 重启服务 + +```bash +# 重启 noctalia-shell 加载插件 +pkill -x qs +qs -c noctalia-shell -d + +# 重启 hermes gateway 加载 hooks +hermes gateway restart +``` + +之后新启动的 `hermes chat` 会话会自动加载 hooks。 + +## 文件说明 + +``` +noctalia-hermes/ +├── README.md +├── hermes-status/ ← noctalia 插件(放到 plugins/ 目录) +│ ├── manifest.json ← 插件元数据和默认配置 +│ ├── Main.qml ← 后台逻辑(监听信号文件 + 兜底轮询) +│ ├── BarWidget.qml ← 状态栏图标(交通灯) +│ ├── Panel.qml ← 点击弹出的详情面板 +│ └── Settings.qml ← 插件设置界面 +├── hermes-status-check ← 状态检测脚本(单 Python 进程综合判断) +├── hermes-status-hook ← Hook 脚本(hermes 事件记录) +└── hermes-attention ← 手动设置 attention 标志的工具 +``` + +### hermes-status-check + +被 noctalia 插件在信号文件变化时调用,并默认每 30 秒兜底调用一次,输出 JSON 状态: + +```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 + +被 hermes hooks 系统调用,根据事件类型写入信号文件: + +| Hook 事件 | 信号状态 | 含义 | +|-----------|----------|------| +| `pre_llm_call` | busy | 开始调用 LLM | +| `post_llm_call` | busy | LLM 返回结果 | +| `pre_tool_call` | busy | 即将执行工具 | +| `post_tool_call` | busy | 工具执行完成 | +| `on_session_start` | busy | 会话开始 | +| `pre_approval_request` | attention | 等待用户审批 | +| `post_approval_response` | idle | 用户已响应 | +| `on_session_end` | idle | 会话结束 | +| `on_session_finalize` | idle | 会话清理完成 | + +### hermes-attention + +手动管理 attention 标志的工具: + +```bash +hermes-attention set # 设置黄色铃铛 +hermes-attention clear # 清除 +hermes-attention status # 查看状态 +``` + +## 配置 + +在 noctalia Settings → Plugins → Hermes Agent 中可调整: + +| 选项 | 默认值 | 说明 | +|------|--------|------| +| Status check script | `~/.config/noctalia/hermes-status-check` | 检测脚本路径 | +| Poll interval | 30s | 兜底轮询间隔;hook 状态变化会通过文件监听立即刷新 | +| Signal file | `~/.hermes/status_signal` | Hermes hook 写入的状态信号文件 | +| Hide when idle | false | 正常运行时隐藏图标 | + +## 依赖 + +- [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell) — Wayland 桌面 shell +- [Hermes Agent](https://github.com/nousresearch/hermes-agent) — AI 助手 +- python3 + +## License + +MIT diff --git a/hermes-status/Settings.qml b/hermes-status/Settings.qml new file mode 100644 index 000000000..54980ed58 --- /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: "~/.config/noctalia/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..205745096 --- /dev/null +++ b/hermes-status/i18n/en.json @@ -0,0 +1,46 @@ +{ + "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" + }, + "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" + }, + "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..89445a99c --- /dev/null +++ b/hermes-status/i18n/zh-CN.json @@ -0,0 +1,46 @@ +{ + "menu": { + "refresh": "刷新", + "clear-attention": "清除提醒" + }, + "status": { + "offline": "离线", + "idle": "在线", + "busy": "工作中", + "attention": "需要你", + "degraded": "降级", + "error": "错误", + "unknown": "未知" + }, + "event": { + "thinking": "思考中", + "processing": "处理中", + "tool_call": "调用工具", + "tool_done": "工具完成", + "awaiting_approval": "等待审批", + "started": "已开始", + "ended": "已结束" + }, + "panel": { + "hermes": "Hermes", + "gateway": "网关", + "session": "会话", + "tokens": "Token", + "cost": "费用", + "stopped": "已停止", + "active": "活跃", + "none": "无", + "online": "在线", + "offline": "离线", + "unknown": "未知", + "actual": "实际", + "estimated": "预估" + }, + "settings": { + "statusScript": "状态检查脚本", + "pollInterval": "轮询间隔(秒)", + "signalFile": "信号文件", + "hideWhenIdle": "空闲时隐藏", + "hideWhenIdleDesc": "仅在网关离线、工作中或需要关注时显示" + } +} diff --git a/hermes-status/manifest.json b/hermes-status/manifest.json new file mode 100644 index 000000000..b1b03e09b --- /dev/null +++ b/hermes-status/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "hermes-status", + "name": "Hermes Agent", + "version": "1.4.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). Shows online/busy/attention/degraded/offline states in the status bar with real-time hook-based updates.", + "tags": [ + "Bar", + "Panel", + "AI", + "Indicator", + "Niri" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "statusScript": "~/.config/noctalia/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