omarchy 状态栏插件实现:OpenClaw 余额监控
用 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.* 备份文件 |
作者改脚本前留的带时间戳备份,说明改动习惯 |
插件回答用户三个问题:
- OpenClaw 服务还活着吗 —— 探测 Web 服务的
/health端点; - 我这个月的 API 钱还剩多少 —— 调 DeepSeek 官方余额接口,并区分「账户可用 / 不可用」与充值余额;
- 跑的容器吃多少资源 —— 通过 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-openWeb 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,自上而下排列:
PanelHero:大标题 + 当前 Provider/模型 + 在线状态;- Service 区块:服务状态、容器 CPU、容器内存;
- Account 区块:Provider、当前模型(tooltip 里塞了全部可用模型)、余额(低余额时 urgent 变红加粗)、API 可用性、消费趋势;
- 趋势图:一个 QtQuick
Canvas,把余额历史画成折线(低余额时整条线变成红色); - 低余额警示条:一个
BorderSurface红框,中间是 ⚠ 图标 + 「余额不足 ¥X,请及时充值」; - Shortcuts 区块:打开 Web / 会话 / 智能体 / 充值 / 手动刷新等
Button; - 底部小字:上次检查时间 + 「按 [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 按行拆开、逐行回调 parseBalance;parseBalance 拿到的是行协议,按行首标记分流,落到 serviceOnline / model / balanceValue / balanceAvail / grantedBalance 等一堆 reactive property 上。脚本跑完就退出,QML 里没有长驻的轮询线程,干净利落。
刷新策略是「三层保险」:
- 定时:一个
Timer { interval: balanceInterval; repeat: true; triggeredOnStart: true }——启动即刷一次,之后每 10 分钟(可配置)刷一次;容器资源用同频的第二个 Timer 驱动; - 打开即刷:
onOpenedChanged: if (opened) refreshNow()—— 每次弹出面板都强制刷新,保证看到的永远是最新值; - 手动:面板里「刷新余额」按钮直接调
refreshNow(),悬停时还有高亮反馈。
按钮交互另外值得一提:按钮右键开 Web、左键开面板把「看」和「去操作」分开;面板里每个跳转按钮都走 bar.run("xdg-open '…'"),对 URL 里的单引号做了 '\'' 转义防注入——一个小型但认真的安全习惯。
六、低余额红警:一鱼四吃
作者把「余额低于阈值」这一个条件做成了四层递进的提醒,值得单独讲讲:
- 图标变色:
balanceLow = balanceValue >= 0 && balanceValue <= lowBalanceThreshold,与「服务离线」「API 不可用」一起并入alarming,驱动按钮进入高亮态并叠加呼吸红圈(见 3.1); - 面板行变红:Account 区 Balance 那行
urgent: root.balanceLow,红字加粗; - 警示条:面板里出现红框
BorderSurface提示「余额不足 ¥20,请及时充值」,趋势折线同步变红; - 桌面通知:低余额时触发一次系统通知(通过状态栏宿主的命令通道调用
omarchy-notification-send),标题「DeepSeek 余额不足」、正文带当前余额和阈值。
通知还有一个防抖设计:notifiedLow 标志 + lastNotifiedVal 记住「上次提醒时的余额值」+ 3 秒延迟 Timer。只有余额进一步跌破且没提醒过时才再弹一次,避免每 10 分钟定时刷新都轰炸一次通知。
面板里的「Trend」和折线图也是同一份数据的二次加工:balanceHistory 保留最近 60 个采样点(时间戳 + 余额),趋势文本取最后两次的差值算「近段消费 ¥X」,Canvas 折线把整段历史画出来。数据量很小,但「历史可视」一下子把刷新插件变成了消费监控。
七、注册到状态栏与热重载
插件写好之后怎么「上栏」?关键在 ~/.config/omarchy/shell.json 的 bar.layout。Omarchy 的状态栏布局就是三个数组 left / center / right,每个数组元素是一个 widget 条目,widget 的 id 就是 manifest 里的插件 id:
{
"bar": {
"layout": {
"right": [
{ "id": "omarchy.tray" },
{ "id": "luaws.openclaw" }, // ← 插件在这里“上栏”
{ "id": "omarchy.agents" }
]
}
}
}
运行期的完整链路是:
- 发现:shell 的
PluginRegistry扫描~/.config/omarchy/plugins/,读每个子目录的manifest.json并校验; - 注册:对
kinds: ["bar-widget"]的插件,shell.qml把它注册进BarWidgetRegistry,键是插件 id,附带barWidget元信息(displayName / defaults / schema); - 实例化:Bar 宿主按
bar.layout找到条目,用entryPoints.barWidget指到的 QML 创建实例,并注入三个关键对象——bar(宿主)、moduleName(插件 id)、settings(该条目的内联配置字段); - 启用判定:一个 widget 是否启用,就看它是否出现在布局里(或被
disabledPlugins显式禁用)。
热重载是这个体系的舒适区:PluginRegistry 用 inotifywait 递归监听插件目录的 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 / 代码里的兜底值。三种入口:
- 宿主设置表单:因为 manifest
schema声明了类型、min/max/step,宿主能自动渲染配置界面,改动由 shell 的updateEntryInline()写回 shell.json 对应条目,并就地热更新已运行的 widget(不必重建实例); - 手改 shell.json:直接编辑上面那个 JSON,保存即热重载;
- 改默认值:改 manifest
barWidget.defaults,同样触发注册表刷新。
注意它没有「每插件独立配置文件」——README 里的设计原则是「settings 直接内联在条目上,不搞 config 子对象、不搞合并层」,简单粗暴但非常好排查。
九、小结:这个插件的设计可取之处
- 行协议解耦界面与数据:Shell 只管吐
key|v1|v2行,QML 只认行首分派,两边可独立迭代、可独立调试(脚本在终端直接跑,肉眼就能验证); - 密钥与代码分离:密钥躺在权限 600 的独立文件里,任何配置文件、界面代码、仓库 diff 里都不可能出现它;取数脚本失败路径全部有
--兜底,不炸界面; - QML 吃透宿主基类:
Panel的面板状态机、KeyboardPanel的锚定与键盘、BarIconButton的 active/tooltip、setting()的配置注入——插件自己只写差异化逻辑,约 400 行就做出一个信息量不小的监控面板; - 低余额提醒做成递进式:图标呼吸 → 行变红 → 面板警示条 → 桌面通知(带防抖),从「被动看到」到「主动打扰」层层加码。
对一个「给状态栏加个按钮」的小需求来说,这个插件把数据获取、动态配置、视觉反馈、通知防抖都做成了可复用的范式,是了解 Omarchy/Quickshell 插件体系一份很好的活教材。