Shows how many coding agents are working, idle or blocked — and how much of the Claude Code limits is already spent — on the screen of an Ergohaven Qube dongle.
herdr.sock ──events.subscribe (+ agent.list)──┐
├─▶ qubeherd ──raw HID 0xB0/0xB1──▶ Qube dongle ──▶ screen
status line ──rate_limits─────────────────────┘
herdr tracks the state of every agent pane it manages. This daemon subscribes to its socket API and pushes the aggregate into the keyboard firmware, which renders it as a 2×2 grid between the two battery gauges:
┌────────────────────────────────┐
│ 5H ▓▓▓▓░░░░░░░░░░░░░░░░ 24% │
│ 7D ▓░░░░░░░░░░░░░░░░░░░ 2% │
│ ▎ WORKING BLOCKED ▎ │
│ ▎ 1 ▎ │
│ ▎ IDLE DONE ▎ │
│ ▎ 2 1 ▎ │
└────────────────────────────────┘
A zero count draws no number, so an idle screen reads as quiet. The gauges on
the edges are the per-half batteries — position says which half, so they carry
no L/R letter. The two bars on top are the Claude Code limit windows, the
5-hour session over the 7-day one.
-
Firmware with the agent and usage packets. Stock RMK ignores packet types
0xB0and0xB1; you need a build from thefeat/k04-agent-statusbranch of AlexBSoD/rmk or later. -
Write access to the raw HID node. The Ergohaven udev rules already grant it (
/dev/hidraw*for vendor0xE126, groupinput). Check withqubeherd --once: it prints which node it picked. -
A running herdr server — the socket defaults to
$HERDR_SOCKET_PATH, falling back to~/.config/herdr/herdr.sock. -
A status line that harvests the limits, for the two bars only. Claude Code hands its status-line command the session JSON on every render; a harvester writes the
rate_limitsobject to$XDG_RUNTIME_DIR/claude-usage.json, which is what this daemon reads. Claude Code has exactly one status-line slot, so the harvesting belongs in whatever script already owns it:jq -c '{five_hour: .rate_limits.five_hour, seven_day: .rate_limits.seven_day, written_at: (now | floor)}' <<<"$input" > "$tmp" mv -f "$tmp" "${XDG_RUNTIME_DIR:-/tmp}/claude-usage.json"
Write through a temporary file and rename, so a read never catches a half written one, and skip the write when
rate_limitsis absent — it is missing before a session's first API response and for accounts without a Pro or Max subscription, and blanking the cache there would blank the bars on every start. Without a harvester the daemon falls back to~/.claude.jsonand flags what it finds as stale.
$ qubeherd --once --verbose # push the current state once, then exit
[INFO ] qubeherd 0.2.0
[INFO ] writing to /dev/hidraw3 (Ergohaven Qube)
[INFO ] layout: ru
[INFO ] agents: 1 working, 2 idle, 0 blocked, 0 done, 0 unknown
[INFO ] limits: 5h 24%, 7d 2%
$ qubeherd # stay running
$ nix run . # …or straight from the flake| Flag | Effect |
|---|---|
--socket <path> |
herdr API socket, overriding the default discovery |
--device <path> |
write to this hidraw node instead of searching for one |
--once |
push the current state once and exit |
--no-clock |
leave the header clock to Entropy |
--no-usage |
leave the Claude Code limit bars empty |
--usage-harvest |
read the harvested limits from this file |
--claude-config |
fallback config, when no status line has run yet |
--no-layout |
do not sync the keyboard layout (Universal Symbols need it) |
--verbose |
log every packet |
RUST_LOG is honoured on top of the flags, so detail can be raised on a
running service without editing the unit — RUST_LOG=qubeherd::layout=debug
narrows it to one module instead of drowning the journal in zbus internals.
Build it with cargo build --release, or nix develop for a shell with the
toolchain. As a home-manager module:
{
inputs.qubeherd.url = "github:AlexBSoD/qubeherd";
# ...
imports = [ inputs.qubeherd.homeModules.default ];
services.qubeherd.enable = true;
}The unit is Type=notify with WatchdogSec=90: a wedged process is otherwise
indistinguishable from an idle one, and Restart=on-failure alone would never
hear about it. It is bound to graphical-session.target, because the layout
source lives on the desktop session bus.
- Events are hints,
agent.listis the truth. Any pane event triggers a freshagent.listcall rather than incremental bookkeeping, so a missed or reordered event cannot leave the screen wrong. - Debounced by 300 ms. Agent state flaps between tool calls, and herdr
emits
pane.updatedfor scrolling too; without this the screen would flicker. A burst pulls the next beat in, it never pushes it out. - Heartbeat every 10 s. The firmware expires the counts after 30 s, so a
dead daemon shows
NO AGENT FEEDinstead of a stale number. A packet is only sent when the counts change or the heartbeat comes due. - Every socket and bus call is bounded (5 s). A unix socket has no read timeout of its own and zbus applies none either, so a peer that accepts a connection without answering — or a name owned by a wedged kwin — would park the whole event loop with no error to recover from.
- Reconnects with backoff (1 s → 30 s) if herdr restarts, and reopens the HID node if the dongle is unplugged and plugged back in. A session that lasted a minute resets the backoff, so a boot race that saturated it does not keep charging 30 s for a one-second herdr restart hours later.
- Picks the hidraw node deterministically. Nodes are matched on the raw-HID
report descriptor rather than a product id (so Qube/mini/micro all work) and
sorted numerically, because a lexicographic listing puts
hidraw10beforehidraw2and would silently flip the pick between replugs. A wired half attached alongside the dongle matches just as well, so several matches are logged as a warning —--devicesettles it. - Reads the Claude Code limits off disk (packet
0xB1), from the file the status line harvests. Claude Code has no command that reports them and no pollable endpoint —/api/oauth/usageanswers 429 to anything that tries (claude-code#31637). What it does have is a status-line command that receivesrate_limitson every render, so that is where the numbers come from.~/.claude.jsonis kept as a fallback for a machine where no session has rendered yet, and it is marked stale on sight: itscachedUsageUtilizationblock only refreshes when/usageis actually run. - Says when a reading stopped moving. The percentages only advance while a
local Claude Code session renders; usage spent in a browser or on another
machine never reaches this daemon. A harvest older than 30 minutes is sent
with the stale flag, and the screen dims the bars rather than dropping them.
A window whose own
resets_athas passed is reported as0instead — an empty window is a fact, not a guess. Genuinely unreadable goes out as0xFFand draws--, never a confident0%. - Sends the header clock too (packet
0xAA, on every minute rollover and after reopening the dongle). Entropy sends the same packet, but only while its GUI is running — without either, the header sits at--:--. Pass--no-clockto leave the clock to Entropy. - Syncs the host keyboard layout (packet
0xAC), following KDE'sorg.kde.KeyboardLayoutsD-Bus signals. This one is not cosmetic: Universal Symbols resolve their keycodes against the layout the firmware believes is active, so without it the keyboard types the wrong characters — see ergohaven/entropy#140.--no-layoutopts out; a layout the firmware has no code for (a third one such asde) leaves the last known one in place. - Follows edits to the layout list, not just switches. An index is a
position in that list, so a reorder that keeps the same length would map
ruonto theencode and stay wrong until a restart. - Survives starting before the desktop session. A missing layout service is one warning, not a failure: the clock and the agent counts keep running while the daemon looks again every 30 s, so starting before Plasma — or outliving a Plasma restart — costs nothing.
- Replays clock and layout on every (re)open of the device. A keyboard that
just rebooted starts from
HostLayout::Englishand an empty clock, and nothing on the firmware side expires either — so a reflash or a replug would otherwise leave Universal Symbols wrong until the next layout switch. The layout is also refreshed every 10 s as a backstop against a lost packet. - Says what it has been doing every 10 minutes. A healthy daemon is otherwise silent, which makes silence useless as a signal: a spin, a freeze and an idle afternoon all read the same in the journal. The summary reports wake-ups, herdr events, packets, device opens and session restarts as rates — a few hundred wake-ups an hour is normal, millions is a bug the watchdog cannot catch, because a spinning loop pings it very cheerfully.
32-byte output report on the QMK-compatible raw HID interface (usage page
0xFF60, usage 0x61), preceded by a 0x00 report id byte for hidraw:
| Byte | Meaning |
|---|---|
| 0 | 0xB0 — agent summary |
| 1 | protocol version (0x01) |
| 2 | agents working |
| 3 | agents idle |
| 4 | agents blocked |
| 5 | agents done |
| 6 | agents in an unknown state |
| 7 | reserved flags, must be 0 |
The limit bars are their own packet:
| Byte | Meaning |
|---|---|
| 0 | 0xB1 — subscription limits |
| 1 | protocol version (0x01) |
| 2 | 5-hour window, percent used, 0xFF if unknown |
| 3 | 7-day window, same encoding |
| 4 | flags: bit 0 = nothing refreshed this lately |
The firmware swallows a packet whose version byte it does not know rather than misreading it, and keeps the unknown count without giving it a cell on screen.
Two packets predate this daemon and are reused as-is: 0xAA ([1] hour,
[2] minute) for the header clock and 0xAC ([1] layout: 0 = English,
1 = Russian) for Universal Symbols.
The firmware side lives in rmk/src/host/via/mod.rs (packet parsing),
rmk/src/host_data.rs (the 30 s expiry, shared by both feeds) and
keyboards/k04/src/qube_display.rs (the screen itself).