From 6e22700420c8d35ac4eabc3e0405a37bca153494 Mon Sep 17 00:00:00 2001 From: jamu85 Date: Sat, 29 Aug 2026 23:59:24 +0900 Subject: [PATCH 1/2] feat(sysmon): add rootless gpu utilization token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds $sys_gpu to the sidebar: GPU utilization 0-100% on Apple Silicon, read rootless from the IORegistry (PerformanceStatistics on the AGX accelerator) through a small bundled C helper that uses only public IOKit/CoreFoundation APIs — no sudo and no powermetrics required. - bin/gpu-util.c: recursive property search for PerformanceStatistics, then reads "Device Utilization %" from the nested dict. Matching by property instead of the chip-specific class name (AGXAcceleratorG14X, …) keeps it working across GPU generations. ~9-330ms per read. - scripts/metrics.sh: sys_gpu collector with the usual never-empty "--" fallback (also covers Linux until native reads land in v0.2). - scripts/poll.sh: collects and pushes sys_gpu alongside sys_cpu. - config/sidebar.toml.snippet: metrics regrouped into [gpu cpu mem] and [batt net disk] rows. Co-authored-by: dudu --- README.md | 2 + bin/gpu-util | Bin 0 -> 34424 bytes bin/gpu-util.c | 74 ++++++++++++++++++++++++++++++++++++ config/sidebar.toml.snippet | 5 ++- scripts/metrics.sh | 17 +++++++++ scripts/poll.sh | 1 + 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100755 bin/gpu-util create mode 100644 bin/gpu-util.c diff --git a/README.md b/README.md index 72ecb88..e869573 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ workspace tokens. Renders in the left sidebar, per workspace: +- GPU % (Apple Silicon, rootless — bundled `gpu-util` helper reads the IORegistry via public IOKit user-space APIs; no sudo, no powermetrics; `--` without the stat) - CPU % (100 − idle) - Memory — active + wired pages → GB (port of `~/dotfiles/tmux/memory.sh`) - Battery — Nerd Font glyph + percentage, charging-aware @@ -66,6 +67,7 @@ correct on your system. - Herdr ≥ 0.7.0 - macOS (v0.1 uses `pmset`, `vm_stat`, `df`). Linux reads land in v0.2. +- `gpu-util` rebuilds with `clang` when available; otherwise the bundled arm64 binary is used. - `jq` (for parsing `herdr … --json`) - A Nerd Font for the battery / wifi / clock glyphs (JetBrainsMono Nerd Font works, matching the existing Ghostty config) diff --git a/bin/gpu-util b/bin/gpu-util new file mode 100755 index 0000000000000000000000000000000000000000..505cf870cc6435278510dafa6b22ea9ce0e20a87 GIT binary patch literal 34424 zcmeI5e{3AZ702J+Il&1bxyDYKCdIx$2o$i-%`f-^qH}zSNdAaKRZB<}snn80DwQgzA_!8d6cGqS3993M z-|YHWZ=6Ibs*tMpq<8b){CKnbnVG%6X6BtQKfbbvNFzu9S_5sGNAwJZVG(@?x)rMH zoz3m7kF_QfoI6`oJdI5`Vm5 zROWcY?nAN9?^Tg1DphP4x?Zr0J%vh^Ki>KIPP~I|4feh9s@-^Cs6^K@1=})n{S|-q z`yRN-iMPcqXWuK{*qpEH#oTT)*Q=YkzP$2h$2;N18*qcL?~maYsf5SjEL7KH&8cR+ zCDGC4j7HR*1W1+m(jYQfId%xD>-~ApFkCa<5b7$4I_+JLfRS#Qagc&rpBJ3}`G%s- z^C3~fK0nX366m#L1=_Egqi`|u!cd-Pkz&D$WX+yP z??|>6J`1-|&r#Sl^3aQ$>Bz#L-TubLhWt9@hoN{R$Vx1xmCAP5U8uSnx$6B?IniBs zKaRue#w*-!H-Fa(gArenKY}$=|GZ~Q` zhE*{0xk9AbDN^A%F3;VWxK;C>$0z8Q*NfYdw~V%YG1r^65pM1dX4gS{?&p3_KSbNq zm!iTFJe^l*+|mfHW)CVu)9$%ELtC7lL%So@mz>-#)Yw<1<#hAbOd! z=rIFjVU0G{+(aAOVSCGd2tPWXHY}r3QpHNum2X4aEt6U~NTpM2ke@^yd|Z!wj`&g7 z+z%Ly?+MU3t%_2s`5B^t^66mh;f=`uQIL*soo3ja_kc=k4u2mh$G}`0KZLfw4N|I_ zN=wf!p<^GcC$z6Qyjm+I8&EzOpbcl2M33zcT)OXUkS+t%7~NH0=6<=}ok;NstL6A> zVE+~Gr|N92D|38I&*ao-3%G5WxcF%E1UQx_gZjJNb_L3hUYXv2wKk6VJsw6~aI2jf z{lmmmkjnp_p8i!6`U@=vzd-%F%3kWRmwW7$9=pzC-{!H`dhCG5p6{{m_SlUcd!5JL zg@Fnm3SXBQMmNB6Mzn> zG_3YcbBVwFs9Ud=3q^#05D)@FKnMr{As_^VfDjM@LO=)z0U;m+gn$qb0zyCt2mv7= z1cZPP5CTF#2nYcoAOwVf5D)@FKnMr{As_^VfDjM@LO=)z0U;m+gn$qb0zyCt2mv7= z1cZPP5CTF#2>c%t2;<#4p$DLRH@R~b01SxtvR~YTh$|dXFTyp+RSz4EjwoPrHfgc^p>`mnc*wKaW6S)r$&a1cn zuxP;`cZWk2E-gG0;4>Ij0pDWidl(WPhbGrQ0sB*5{drE~ zO8G55-?`1+z4RJwfsOd+31-V@TkyFSe5M7TXTfJ#@HrJsi^mEV))SvkF_Zr-*w<_C zZ-?p>#{9T?m~}PG-^fhYfNw3-qDhZCpz>dQe+x4 z`D}0FI)>MgFaNP<64T}vKs+;dYLSO|qwS>tzHR|Er_(6!hO%I|_ni(0)KEJ) z*ZtLo-D0hOsMt`%o$R#d!P*OtcP!uaugz=sJaGTf@Oz_YPQNqugH124I&r0a_0MC! z+w=14-T6QMvv0EMZzoUg3?#beO+R(ysVATL`|qFK`qO(~`nu&e-}>sRPp0pQzrMG9 z^Rg57esoT|^1&0Q66McwPY;}W{Mi@2Zu}dyxBeV$J70gk?)bqcK8dd>{-xp7-;BQB S84o@G+$)=oANX)9fc*z#RQ>7z literal 0 HcmV?d00001 diff --git a/bin/gpu-util.c b/bin/gpu-util.c new file mode 100644 index 0000000..ae83469 --- /dev/null +++ b/bin/gpu-util.c @@ -0,0 +1,74 @@ +/* + * gpu-util — rootless GPU utilization for Apple Silicon. + * + * Reads the GPU's "Device Utilization %" out of the IORegistry + * (PerformanceStatistics on the AGX accelerator service). Pure user-space + * IOKit access — no sudo, no powermetrics, no private framework linkage + * (the symbols used are all public IOKit/CoreFoundation APIs). + * + * Usage: gpu-util prints an integer percent (e.g. "3") + * prints "--" when no GPU/stat is found. + * gpu-util --raw same, but exits 1 when unavailable + * + * The chip-generation-specific class name (AGXAcceleratorG14X, G13, …) + * is deliberately NOT matched by name; the recursive property search finds + * whatever accelerator the current SoC exposes, so the helper survives + * future GPU generations without changes. + * + * Build (macOS, Xcode CLT): + * clang -O2 -framework IOKit -framework CoreFoundation \ + * -o gpu-util gpu-util.c + */ + +#include +#include +#include +#include + +/* The utilization value lives NESTED inside the accelerator's + * "PerformanceStatistics" dictionary, so the search must first locate + * that top-level property, then read the sub-key from the dict. */ +#define STATS_KEY CFSTR("PerformanceStatistics") +#define UTIL_KEY CFSTR("Device Utilization %") + +int main(int argc, char *argv[]) { + int raw = (argc > 1 && strcmp(argv[1], "--raw") == 0); + + io_registry_entry_t root = MACH_PORT_NULL; + root = IORegistryGetRootEntry(kIOMainPortDefault); + if (root == MACH_PORT_NULL) { + raw ? exit(1) : puts("--"); + return 0; + } + + /* Recursive search over the IOService plane; matching=NULL → any entry. + * Returns a retained CFNumber when found. */ + CFTypeRef stats = IORegistryEntrySearchCFProperty( + root, kIOServicePlane, STATS_KEY, NULL, kIORegistryIterateRecursively); + CFTypeRef value = NULL; + if (stats != NULL) { + if (CFGetTypeID(stats) == CFDictionaryGetTypeID()) { + value = CFDictionaryGetValue(stats, UTIL_KEY); + if (value != NULL) CFRetain(value); + } + CFRelease(stats); + } + + if (value == NULL) { + /* No discrete/integrated GPU stats exposed (e.g. headless VM) */ + raw ? exit(1) : puts("--"); + return 0; + } + + if (CFGetTypeID(value) == CFNumberGetTypeID()) { + int pct = -1; + if (CFNumberGetValue(value, kCFNumberIntType, &pct) && pct >= 0) { + printf("%d\n", pct); + CFRelease(value); + return 0; + } + } + CFRelease(value); + raw ? exit(1) : puts("--"); + return 0; +} diff --git a/config/sidebar.toml.snippet b/config/sidebar.toml.snippet index a141df9..2b801c9 100644 --- a/config/sidebar.toml.snippet +++ b/config/sidebar.toml.snippet @@ -14,6 +14,7 @@ # $sys_path — focused pane cwd (basename, 32-char truncated) # $sys_zoom — " zoom " when the focused pane is zoomed, else empty # $sys_prefix — prefix-active indicator (v0.1: always empty; v0.2) +# $sys_gpu — GPU usage 0-100% (rootless, Apple Silicon; "--" without GPU) # $sys_cpu — CPU usage 0-100% # $sys_mem — active+wired memory in GB # $sys_batt — battery glyph + percentage @@ -37,7 +38,7 @@ rows = [ ["state_icon", "workspace"], ["branch", "git_status"], ["$sys_path", "$sys_zoom", "$sys_prefix"], - ["$sys_cpu", "$sys_mem", "$sys_batt"], - ["$sys_net", "$sys_disk"], + ["$sys_gpu", "$sys_cpu", "$sys_mem"], + ["$sys_batt", "$sys_net", "$sys_disk"], ["$sys_date", "$sys_time"], ] \ No newline at end of file diff --git a/scripts/metrics.sh b/scripts/metrics.sh index 469ce8a..eba402a 100755 --- a/scripts/metrics.sh +++ b/scripts/metrics.sh @@ -79,6 +79,23 @@ sys_date() { date "+%a %b %d" } +# GPU utilization as 0-100% — rootless via the IORegistry. +# Uses the bundled gpu-util helper (pure IOKit user-space API, no sudo, +# no powermetrics). Searches for the accelerator's PerformanceStatistics +# property instead of matching a chip-specific class name, so it works +# across GPU generations. Falls back to "--" like every other collector. +sys_gpu() { + local pct + pct=$("$SYSMON_ROOT/bin/gpu-util" 2>/dev/null) + if [[ "$pct" =~ ^[0-9]+$ ]]; then + printf "%s%%" "$pct" + else + echo "--" + fi +} + +# Focused pane cwd, basename only + # Focused pane cwd, basename only, truncated to 32 chars with leading ellipsis. # Mirrors the tmux #{=/-32/...:#{b:pane_current_path}} status-left widget. # Best-effort: the `pane current` JSON shape is not fully documented; we try diff --git a/scripts/poll.sh b/scripts/poll.sh index 0093acd..2b81160 100755 --- a/scripts/poll.sh +++ b/scripts/poll.sh @@ -20,6 +20,7 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SYSMON_ROOT="$PLUGIN_ROOT" STATE_DIR="${HERDR_PLUGIN_STATE_DIR:-$PLUGIN_ROOT/.state}" mkdir -p "$STATE_DIR" From c106dcc00c3f88ebd129ce7f5958b9938b1786fd Mon Sep 17 00:00:00 2001 From: Cyb3rDudu <49803228+Cyb3rDudu@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:06:20 +0700 Subject: [PATCH 2/2] fix(sysmon): workspace-id extraction and gpu token push - herdr >= 0.8 workspace list carries no workspace_id field; derive the id from active_tab_id ("w3:t1" -> "w3") with label fallback. Without this the poller logged 'no workspaces found' forever and never pushed. - include sys_gpu in the collected tokens, never-empty guard, and the report-metadata push (the collector existed but was never wired into push_all). Co-authored-by: dudu --- scripts/poll.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/poll.sh b/scripts/poll.sh index 2b81160..5dc1e82 100755 --- a/scripts/poll.sh +++ b/scripts/poll.sh @@ -42,16 +42,19 @@ log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; } # NOTE: herdr CLI emits JSON by default; there is no --json flag. list_workspace_ids() { "$HERDR" workspace list 2>/dev/null | jq -r ' - (.result.workspaces // .workspaces // []) | .[].workspace_id // empty + # herdr >= 0.8: workspaces carry no workspace_id; the id is the workspace + # half of active_tab_id ("w3:t1" -> "w3"). + (.result.workspaces // .workspaces // []) + | .[] | (.active_tab_id // "" | split(":")[0]) // .label // empty ' 2>/dev/null } # Collect every metric once, then push the full token set to every workspace. push_all() { - local cpu mem batt net disk time date path zoom prefix + local cpu mem batt net disk time date path zoom prefix gpu cpu=$(sys_cpu); mem=$(sys_mem); batt=$(sys_batt); net=$(sys_net) disk=$(sys_disk); time=$(sys_time); date=$(sys_date); path=$(sys_path) - zoom=$(sys_zoom); prefix=$(sys_prefix) + zoom=$(sys_zoom); prefix=$(sys_prefix); gpu=$(sys_gpu) # Never-empty guards. The collectors already fall back, but a total command # failure (e.g. disk full, binary missing) can still yield an empty string; @@ -64,6 +67,7 @@ push_all() { [[ -z "$time" ]] && time="--" [[ -z "$date" ]] && date="--" [[ -z "$path" ]] && path="--" + [[ -z "$gpu" ]] && gpu="--" local wids wid wids=$(list_workspace_ids) @@ -74,6 +78,7 @@ push_all() { for wid in $wids; do "$HERDR" workspace report-metadata "$wid" --source "$SOURCE_ID" \ + --token "sys_gpu=$gpu" \ --token "sys_cpu=$cpu" \ --token "sys_mem=$mem" \ --token "sys_batt=$batt" \