用 Quickshell 给状态栏写一个「OpenClaw 监控」插件:从 manifest 到余额红警

本文介绍 luaws 的自研 OpenClaw 状态栏插件实现:它把一个常驻的 OpenClaw 图标放进 Omarchy 状态栏右侧,单击弹出下拉面板,实时展示 DeepSeek API 余额、OpenClaw 服务健康、运行容器资源,并把「余额不足」直观地做成图标变红、呼吸红圈、桌面通知、面板警示条四层提醒。全文会从插件目录出发,逐层拆解它的 manifest 声明、QML 界面骨架、Shell 取数管线,以及它是如何被注册进状态栏并热重载的。

一、这个插件做了什么

插件本体是一个 bar-widget(状态栏小组件),代码位于 ~/.config/omarchy/plugins/luaws.openclaw/,目录里只有几个文件:

文件 职责
manifest.json 插件的「身份证 + 说明书」,声明类型、入口、默认配置
OpenClawWidget.qml 全部界面:状态栏按钮 + 下拉面板 + 交互逻辑
balance-check.sh 取数脚本①:服务健康 / 当前模型 / DeepSeek 余额
openclaw-stats.sh 取数脚本②:OpenClaw 容器 CPU / 内存(远程 SSH)
openclaw-lobster.svg 按钮图标
deepseek.key 密钥文件:只感知其存在,权限 600,仅供取数脚本读取
*.bak.* 备份文件 作者改脚本前留的带时间戳备份,说明改动习惯

插件回答用户三个问题:

  1. OpenClaw 服务还活着吗 —— 探测 Web 服务的 /health 端点;
  2. 我这个月的 API 钱还剩多少 —— 调 DeepSeek 官方余额接口,并区分「账户可用 / 不可用」与充值余额;
  3. 跑的容器吃多少资源 —— 通过 SSH 到宿主机执行 docker stats 拿到 CPU / 内存。

面板里再配上当前模型、模型列表、余额历史折线、消费趋势、一键打开 Web UI / 会话 / 智能体页 / 充值页。本质上它是个「只看一眼就能决策」的运维信息中心。

二、manifest.json:插件如何被宿主理解

Omarchy 的状态栏跑在 Quickshell 里,第三方插件的入口就是 manifest.json。宿主(shell 进程)启动时会扫描 ~/.config/omarchy/plugins/ 下每个子目录,只认两种形态:顶层 manifest.json(第三方插件)或 *.manifest.json(内置插件)。扫描到的每个 manifest 都要过一遍校验:schemaVersion 必须是 1、id/name/version/kinds/entryPoints 缺一不可、id 不能含 /..kinds 非空,并且所有 entryPoint 必须是插件目录内的相对路径(防路径穿越,把插件限制在自己的沙箱目录里)。

这个插件的 manifest 结构示意:

{
  "schemaVersion": 1,
  "id": "luaws.openclaw",            // 全局唯一 id,也是 shell.json 里引用它的键
  "name": "OpenClaw",
  "version": "1.1.0",
  "kinds": ["bar-widget"],            // 告诉宿主:我是一个状态栏小组件
  "entryPoints": { "barWidget": "OpenClawWidget.qml" },  // 入口文件

  "barWidget": {                      // 这个 kind 专属的元信息
    "displayName": "OpenClaw",
    "category": "Apps",
    "defaultSection": "right",        // 首次加入时默认放右侧
    "allowMultiple": false,           // 整条状态栏只允许一份
    "defaults": {                     // 可配置项的默认值
      "url": "http://192.168.XX.XX:18789",
      "balanceInterval": 600000,      // 余额刷新间隔:10 分钟
      "lowBalanceThreshold": 20       // 低余额阈值:¥20
    },
    "schema": [                       // 每个字段的类型/范围/步进/标签
      { "key": "url",                "type": "string",  "label": "OpenClaw Web UI 地址" },
      { "key": "balanceInterval",    "type": "integer", "label": "余额刷新间隔(毫秒)",
        "min": 60000, "max": 3600000, "step": 60000, "defaultValue": 600000 },
      { "key": "lowBalanceThreshold","type": "number",  "label": "低余额提醒阈值(元)",
        "min": 0, "max": 10000, "step": 5, "defaultValue": 20 }
    ]
  }
}

值得注意的分工:barWidget.defaults运行时默认值,而 schema给宿主设置界面用的元数据——宿主拿到 defaults + schema 就能自动渲染出一张表单(整数/数字框、滑杆步进、范围校验),改动后由宿主写回 shell.json 的对应条目。插件作者写一个 schema,配置 UI 就免费到手,这是这个插件体系里很省事的约定。

三、QML 骨架:状态栏按钮 + 下拉面板

OpenClawWidget.qml 的根元素不是裸 Item,而是继承自 Omarchy 的 qs.Ui.Panel(Ui/Panel.qml)——这是一切「点开才出现的面板类组件」的基类,白送了整套面板状态机:

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui

Panel {
  id: root
  moduleName: "luaws.openclaw"   // 组件 id:宿主据此注入 settings / 路由 IPC
  ipcTarget: "luaws.openclaw"    // 允许外部通过 IPC 唤醒这个面板

  // 读配置:布局条目里的内联字段 > 这里的兜底默认值
  readonly property string url: setting("url", "http://192.168.XX.XX:18789")
  readonly property int    balanceInterval: Number(setting("balanceInterval", 600000))
  readonly property double lowBalanceThreshold: Number(setting("lowBalanceThreshold", 20))
  ...
}

Panel 基类提供了 opened 状态和 open()/close()/toggle()switchPanel(direction)(在多个面板间用 Tab 切换)以及 Quickshell IPC 控制器,插件自己只负责三件事:数据、按钮、面板内容

3.1 状态栏按钮(BarIconButton)

按钮是 BarIconButton,铺满整个 widget 的隐式尺寸,行为分左右键:

  • 左键toggle() 弹出/收起下拉面板;
  • 右键 → 直接 xdg-open Web UI;
  • 悬停 → 多行 tooltip:服务在线状态、当前模型、余额、上次刷新时间、操作提示。

图标是一个 openclaw-lobster.svg 的龙虾形象,外面叠了一层「异常红圈」:当 alarming 为真(服务离线 / API 异常 / 低余额任意一个成立),一个透明圆环 Rectangle 就会出现,边框用状态栏的 urgent 红色,配一个 900ms 往返的透明度呼吸动画:

Rectangle {
  visible: root.alarming
  radius: width / 2; color: "transparent"
  border.color: root.urgent
  NumberAnimation on opacity {
    running: root.alarming
    from: 0.35; to: 0.85; duration: 900
    easing.type: Easing.InOutSine
    loops: Animation.Infinite      // 一直呼吸,直到异常解除
  }
}

这就是「低余额图标变红并警示」的第一层——不用打开面板,余光一扫就知道出事了。

3.2 下拉面板(KeyboardPanel)

面板用 KeyboardPanel 承载,anchorItem: button 让弹层锚定在按钮下方,内部结构是 ScrollView → Column,自上而下排列:

  1. PanelHero:大标题 + 当前 Provider/模型 + 在线状态;
  2. Service 区块:服务状态、容器 CPU、容器内存;
  3. Account 区块:Provider、当前模型(tooltip 里塞了全部可用模型)、余额(低余额时 urgent 变红加粗)、API 可用性、消费趋势;
  4. 趋势图:一个 QtQuick Canvas,把余额历史画成折线(低余额时整条线变成红色);
  5. 低余额警示条:一个 BorderSurface 红框,中间是 ⚠ 图标 + 「余额不足 ¥X,请及时充值」;
  6. Shortcuts 区块:打开 Web / 会话 / 智能体 / 充值 / 手动刷新等 Button
  7. 底部小字:上次检查时间 + 「按 [O] 打开 Web」+ 当前 URL。

面板还接了一个 PanelKeyCatcher 处理键盘:Esc 关闭、Tab 在兄弟面板间切换、按键 o 直接开 Web。面板整体高度是 fittedContentHeight,内容超长就滚。

值得一提的细节:InfoRow(「标签: 值」一行)不是复制的样板代码,而是 QML 内联组件:

component InfoRow: Item {
  property string label; property string value
  property bool urgent: false; property bool emphasized: false
  ...
}

它统一了 hover 高亮背景、toUpperCase() 的小标题风格、值右对齐省略、hover 时显示 PanelToolTip。整块面板十几处信息行全靠这一个组件撑起来。

四、数据怎么来:两个 Shell 脚本 + 「管道行协议」

Quickshell 本身可以跑子进程,插件把「取数」全部外包给了两个 bash 脚本,QML 只负责展示。这个界面与取数彻底分离的设计是全文最值得借鉴的点:改取数逻辑只动 shell,改展示只动 QML,互不牵连。

4.1 balance-check.sh:健康 + 模型 + 余额

脚本分四步,输出一行一行的 | 分隔记录(下文叫它行协议):

# 1) 读密钥:受保护文件,绝不把 key 写死在代码/配置里
API_KEY=""
[[ -f "deepseek.key" ]] && API_KEY=$(cat deepseek.key 2>/dev/null | tr -d ' \n')
[[ -z "$API_KEY" ]] && API_KEY="${DEEPSEEK_API_KEY:-}"   # 环境变量兜底

# 2) 健康检查:curl 本机局域网里的 Web 服务
curl -s --max-time 4 "http://192.168.XX.XX:18789/health" | grep -q '"ok"[: ]*true' \
  && echo "health|online" || echo "health|offline"

# 3) 动态读「当前模型 + 模型列表」:SSH 到宿主机 docker exec 拿 OpenClaw 配置
MODEL_INFO=$(ssh -i ~/.ssh/id_ed25519_* -o BatchMode=yes ... user@192.168.XX.XX \
  'docker exec OpenClaw sh -c "cat /root/.openclaw/openclaw.json"' 2>/dev/null \
  | python3 -c '...提取 agents.defaults.model.primary 与各 provider 模型...')

# 4) DeepSeek 余额:curl 官方接口,Bearer 鉴权
resp=$(curl -s --max-time 8 https://api.deepseek.com/user/balance \
       -H "Authorization: Bearer $API_KEY")
# ...用 grep -oE 提取 is_available / CNY total_balance / granted_balance...
echo "balance|DeepSeek|$CUR_MODEL|$cny|$avail|$granted"

几个实现细节很聪明:

  • 密钥只从文件读deepseek.key 权限 600、只有所有者可读,脚本 cd 到自己目录后 cat 进来再去掉空白;QML 里、shell.json 里、manifest 里都不出现任何密钥,只有一个文件路径。万一文件丢了还能用同名环境变量兜底。发送时用 curl -H "Authorization: Bearer …"ps 可见性这类问题由脚本作者自己权衡,这里不做展开。
  • 模型不写死,动态读:当前主模型从 OpenClaw 自己的配置里取(agents.defaults.model.primary),模型列表则遍历 models.providers.*.models 拼成 provider/model。这样在 Web UI 里切换模型后,状态栏自动跟着变,无需改插件。
  • 拿远程文件用了一次「ssh + docker exec + python 管道」:远程读容器的 JSON,不落盘,直接喂给本地的 python3 -c 解析,取数逻辑全部留在本地脚本里,好维护。

4.2 openclaw-stats.sh:容器资源

第二个脚本更短——SSH 到宿主机跑 docker stats --no-stream,用 Go 模板直接输出管道分隔的 CPU/内存字段,再拆开形如 487.5MiB / 31.27GiB 的内存字符串,得到纯数值:

out=$(ssh ... user@192.168.XX.XX \
  'docker stats --no-stream --format "{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}" OpenClaw')
# 输出 container|cpu%|memUsed|memTotal|memPct%

任何一步失败(SSH 超时、容器不存在)就输出一行全是 -- 的兜底记录,界面优雅降级为占位符,而不是报错刷屏。

4.3 行协议:QML 与 Shell 之间唯一的契约

两个脚本产出的数据遵循同一种形态,QML 端一个 parse 函数按 | 切分即可,脚本和界面可以各自独立演进:

行首标记 语义 字段
health 服务健康 online / offline
model 模型信息 当前 provider/model; 分隔的可用模型
balance 账户余额 Provider|模型|余额|API 可用性|充值余额
container 容器资源 CPU%|已用内存|总内存|内存占比

五、定时刷新与按钮交互

数据进 QML 靠的是 Quickshell.Io 的 Process + SplitParser

Process {
  id: balProc
  command: ["/bin/sh", "-c", "cd " + root.scriptDir + " && bash balance-check.sh"]
  stdout: SplitParser { onRead: data => root.parseBalance(data) }
}

SplitParser 把子进程 stdout 按行拆开、逐行回调 parseBalanceparseBalance 拿到的是行协议,按行首标记分流,落到 serviceOnline / model / balanceValue / balanceAvail / grantedBalance 等一堆 reactive property 上。脚本跑完就退出,QML 里没有长驻的轮询线程,干净利落。

刷新策略是「三层保险」:

  1. 定时:一个 Timer { interval: balanceInterval; repeat: true; triggeredOnStart: true } ——启动即刷一次,之后每 10 分钟(可配置)刷一次;容器资源用同频的第二个 Timer 驱动;
  2. 打开即刷onOpenedChanged: if (opened) refreshNow() —— 每次弹出面板都强制刷新,保证看到的永远是最新值;
  3. 手动:面板里「刷新余额」按钮直接调 refreshNow(),悬停时还有高亮反馈。

按钮交互另外值得一提:按钮右键开 Web、左键开面板把「看」和「去操作」分开;面板里每个跳转按钮都走 bar.run("xdg-open '…'"),对 URL 里的单引号做了 '\'' 转义防注入——一个小型但认真的安全习惯。

六、低余额红警:一鱼四吃

作者把「余额低于阈值」这一个条件做成了四层递进的提醒,值得单独讲讲:

  1. 图标变色balanceLow = balanceValue >= 0 && balanceValue <= lowBalanceThreshold,与「服务离线」「API 不可用」一起并入 alarming,驱动按钮进入高亮态并叠加呼吸红圈(见 3.1);
  2. 面板行变红:Account 区 Balance 那行 urgent: root.balanceLow,红字加粗;
  3. 警示条:面板里出现红框 BorderSurface 提示「余额不足 ¥20,请及时充值」,趋势折线同步变红;
  4. 桌面通知:低余额时触发一次系统通知(通过状态栏宿主的命令通道调用 omarchy-notification-send),标题「DeepSeek 余额不足」、正文带当前余额和阈值。

通知还有一个防抖设计:notifiedLow 标志 + lastNotifiedVal 记住「上次提醒时的余额值」+ 3 秒延迟 Timer。只有余额进一步跌破且没提醒过时才再弹一次,避免每 10 分钟定时刷新都轰炸一次通知。

面板里的「Trend」和折线图也是同一份数据的二次加工:balanceHistory 保留最近 60 个采样点(时间戳 + 余额),趋势文本取最后两次的差值算「近段消费 ¥X」,Canvas 折线把整段历史画出来。数据量很小,但「历史可视」一下子把刷新插件变成了消费监控。

七、注册到状态栏与热重载

插件写好之后怎么「上栏」?关键在 ~/.config/omarchy/shell.jsonbar.layout。Omarchy 的状态栏布局就是三个数组 left / center / right每个数组元素是一个 widget 条目,widget 的 id 就是 manifest 里的插件 id:

{
  "bar": {
    "layout": {
      "right": [
        { "id": "omarchy.tray" },
        { "id": "luaws.openclaw" },   // ← 插件在这里“上栏”
        { "id": "omarchy.agents" }
      ]
    }
  }
}

运行期的完整链路是:

  1. 发现:shell 的 PluginRegistry 扫描 ~/.config/omarchy/plugins/,读每个子目录的 manifest.json 并校验;
  2. 注册:对 kinds: ["bar-widget"] 的插件,shell.qml 把它注册进 BarWidgetRegistry,键是插件 id,附带 barWidget 元信息(displayName / defaults / schema);
  3. 实例化:Bar 宿主按 bar.layout 找到条目,用 entryPoints.barWidget 指到的 QML 创建实例,并注入三个关键对象——bar(宿主)、moduleName(插件 id)、settings(该条目的内联配置字段);
  4. 启用判定:一个 widget 是否启用,就看它是否出现在布局里(或被 disabledPlugins 显式禁用)。

热重载是这个体系的舒适区:PluginRegistryinotifywait 递归监听插件目录的 close_write/create/delete/move 事件——~/.config/omarchy/plugins/ 下随便存个文件,插件代码自动重载,改 QML 保存即生效;shell.json 本身也热重载。如果某次改动没生效,还能手动兜底:omarchy-shell shell rescanPlugins。想微调布局位置,有现成命令 omarchy bar move luaws.openclaw --section right 可走,或直接手改 shell.json。所以迭代插件的工作流就是「改文件 → 保存 → 看状态栏」三拍,快得没有心理负担。

八、改配置:三种入口一条路

插件的可调项就三个:url(Web 地址)、balanceInterval(刷新间隔)、lowBalanceThreshold(低余额阈值)。改它们的路径最终都汇到同一处——布局条目上的内联字段

{ "id": "luaws.openclaw", "balanceInterval": 900000, "lowBalanceThreshold": 50 }

QML 里统一用基类提供的 setting(name, fallback) 读取:先查条目内联字段,查不到就用 manifest defaults / 代码里的兜底值。三种入口:

  1. 宿主设置表单:因为 manifest schema 声明了类型、min/max/step,宿主能自动渲染配置界面,改动由 shell 的 updateEntryInline() 写回 shell.json 对应条目,并就地热更新已运行的 widget(不必重建实例);
  2. 手改 shell.json:直接编辑上面那个 JSON,保存即热重载;
  3. 改默认值:改 manifest barWidget.defaults,同样触发注册表刷新。

注意它没有「每插件独立配置文件」——README 里的设计原则是「settings 直接内联在条目上,不搞 config 子对象、不搞合并层」,简单粗暴但非常好排查。

九、小结:这个插件的设计可取之处

  • 行协议解耦界面与数据:Shell 只管吐 key|v1|v2 行,QML 只认行首分派,两边可独立迭代、可独立调试(脚本在终端直接跑,肉眼就能验证);
  • 密钥与代码分离:密钥躺在权限 600 的独立文件里,任何配置文件、界面代码、仓库 diff 里都不可能出现它;取数脚本失败路径全部有 -- 兜底,不炸界面;
  • QML 吃透宿主基类Panel 的面板状态机、KeyboardPanel 的锚定与键盘、BarIconButton 的 active/tooltip、setting() 的配置注入——插件自己只写差异化逻辑,约 400 行就做出一个信息量不小的监控面板;
  • 低余额提醒做成递进式:图标呼吸 → 行变红 → 面板警示条 → 桌面通知(带防抖),从「被动看到」到「主动打扰」层层加码。

对一个「给状态栏加个按钮」的小需求来说,这个插件把数据获取、动态配置、视觉反馈、通知防抖都做成了可复用的范式,是了解 Omarchy/Quickshell 插件体系一份很好的活教材。