Releases: binlecode/actop
Release list
actop 1.6.9
Added
- Guard-release CI workflow (
guard-release.yml): everyv*tag push verifies
all tags have a corresponding GitHub Release object, failing the run if any are
missing — catches the silent link breakage that caused the v0.8.7–v1.6.6
release-object gap. - Pre-push githook tag nudge: warns when a bare
git pushof av*tag is
detected, reminding to usescripts/tag_release.shinstead.
Changed
- README badge row gains a Website badge linking to the Cloudflare Pages coverpage
(actop.pages.dev).
actop 1.6.6
Changed
- README hero GIF re-recorded (
images/actop-demo.gif) — refreshed after the
GPU% process-table column landed; recorded under a live llama.cpp workload
(OpenAI wire protocol on the llamacpp router, originalqwen3.6-35b-a3b
weights) instead of the ollama-router.record-tui-gifskill moved from
.claude/skills/to.agents/skills/;record.shnow defaults to the
llama.cpp router (API=openai), with the ollama-router preserved as a
documented fallback (API=ollama+ native/api/generate). Cover
(cover/) re-deployed with the new GIF.
actop 1.6.5
Added
--json --samples N— bounded NDJSON emission for agent/script one-shots.
--jsonpreviously streamed until interrupted, forcing an agent or script to
kill the process after reading its record.--samples N(with--json) now
emits exactlyNsnapshot records then exits 0;--samples 0(default)
keeps the streaming behavior. The first record already carries real deltas
(theMonitorprimes the baseline at construction), so--json --samples 1
is a clean single-snapshot tool call.
actop 1.6.4
Fixed
- Process CPU% was understated ~41.7x on every Apple Silicon machine. The
two CPU-time fields inproc_pidinfo(pti_total_user/pti_total_system)
are mach absolute-clock ticks, not nanoseconds — on Apple Silicon the
timebase is 125/3 (1 tick = 41.667 ns).get_native_processes()returned
those raw ticks ascpu_time_ns, so every process's CPU% read ~2.4% of its
real value (e.g. 1.2 s of burned CPU appeared as 0.03 s).proc_pidinfo'
actually reports nanoseconds only by coincidence on Intel (timebase 1/1),
which is why the offset-verified offsets never surfaced the unit. The module
now readsmach_timebase_infoonce at import and converts via integer math
(_mach_ticks_to_ns). Per-processcpu_time_shareand watt attribution are
ratio-based, so they were never affected. See Apple openradar FB9546856.
actop 1.6.3
Added
- GPU% column in the TUI process table —
gpu_time_sharefrom the Monitor
was always collected but never rendered. The process table now shows aGPU%
column (between CPU% and PWR) displaying the per-process share of total GPU
time as a percent.–when the first GPU delta is still pending. Thessort
cycle now includes GPU% (CPU% → GPU% → PWR → RSS → PID).
actop 1.6.2
Fixed
- Export modes (
--json/--serve) now honor--show-processesand
--proc-filter— they were silently ignored, so every NDJSON record carried
"processes":[]even when the TUI (tkey) showed populated per-process data.
The CLI routing (_run_export) now forwards both flags to the NDJSON and
Prometheus backends;run_json_streamandserve_prometheuspass them through
toMonitor(include_processes=True, process_filter=...).--proc-filter
without--show-processesimplies it, matching the Monitor's opt-in cost model
where process collection stays off by default. - Per-process Prometheus gauges (
actop_process_cpu_percent,
actop_process_cpu_time_share,actop_process_gpu_time_share,
actop_process_attributed_watts,actop_process_rss_bytes,
actop_process_num_threads): labelled bypidandcommand, emitted only
wheninclude_processes=True. The NDJSON path needs no format change —
dataclasses.asdictalready serialised processes when they were collected;
the gap was purely that collection was never enabled.
actop 1.6.1
Docs only — no code change.
Added
-
Landed
docs/TODO-layering-cleanup-2026-07-02.md, the design record behind
LC-1→LC-3 (shipped v1.2.4–v1.3.0:SystemSnapshotas the sole frame
contract,ProcessSamplethrough L2, andanalytics.py'sAlertEngine/
throttle / session-energy move out of the widget). The plan drove all three
releases but lived only on an unmerged branch, so the code shipped while its
rationale stayed unpublished — the violation inventory, per-violation fix
design, and sequencing are now in the repo instead of one branch tip.§§1–9 are the July plan verbatim, so their line references point at July code.
A new §10 records status verified againstmain, and is the only open
scope. -
Two roadmap items promoted out of that plan into
docs/TODO-architecture-roadmap.md:- Export parity (§10.2) — per-process rows, throttle/alert flags, and
session energy are still TUI-only and never reach--json/--serve, so
profiling a local inference run through the export backends cannot answer
which process drew the watts, whether the chip throttled, or what the run
cost in energy. Both candidate designs and the recommendation are recorded,
along with the per-PID Prometheus cardinality constraint that keeps process
data NDJSON-only either way. - LC-4 (§10.1) — the watt/GB-s history deques and
_avg_maxreducer never
moved to ananalytics.RollingStats. Explicitly low priority: it relocates
working code, and the reason to do it is export parity needing the same
aggregates outside the TUI.
§10.3 records one acceptance criterion that does not literally pass and should
be reworded rather than "fixed":tui/app.pystill importsget_soc_infofor
a single construction-time call that buildsDashboardConfig. The criterion
targeted per-frame L1 acquisition in the view, which is gone; routing that one
call through another module to satisfy a grep would add indirection for no
layering gain. - Export parity (§10.2) — per-process rows, throttle/alert flags, and
actop 1.6.0
Reading-plane audit §8: adopt the GPU driver's IOAccelerator
PerformanceStatistics as a second, independent GPU utilization source.
Verified on live hardware (M4 Max / Darwin 25.5.0). As-built design in
docs/DESIGN-system.md §3.8.
Added
-
Renderer/Tiler GPU breakdown — a metric actop could not previously
express.Renderer Utilization %(shader/compute work) andTiler Utilization %(geometry work) are read off the accelerator's own
PerformanceStatisticsdict via IOKit ctypes. For local-inference profiling
this separates an MLX/CoreML compute frame (Renderer high, Tiler ≈ 0) from a
render-bound one — a split IOReport residency cannot report at all, because
the GPU exposes a single unifiedGPUPHchannel.New
SystemSnapshotfieldsgpu_device_pct/gpu_renderer_pct/
gpu_tiler_pct/gpu_perf_stats_available/gpu_util_source; new
actop_gpu_device_utilization_percent/
actop_gpu_renderer_utilization_percent/
actop_gpu_tiler_utilization_percentPrometheus gauges; all five fields in
NDJSON.gpu_util_sourceis a string and so is deliberately not a
Prometheus gauge — emitting it as one would produce a non-numeric value line
and break the whole scrape. -
New public L1 reader
gpu_registry.get_gpu_perf_stats(), returning a
GPUPerfStats(device_pct, renderer_pct, tiler_pct, available)namedtuple.
Costs 0.025 ms/call measured — 33× less than the per-process GPU-time walk
already running each frame — so it needs no caching and adds no measurable
idle-CPU load.ioregis deliberately not shelled out to. -
A
Rend N% · Tiler N%row in theGPU · ANETUI section, hidden entirely
when the accelerator reports no statistics (the same hide-row contract as Mem
BW and Fan) rather than showing a phantom0/0.Device Utilization %is
deliberately kept out of the TUI: the GPU row already carries the headline
percent, and a second, differently-measured whole-GPU number beside it reads
as a contradiction. It remains available via the API and both exports.
Changed
-
GPU utilization now degrades to the driver's reading instead of silently to
zero.gpu_util_pctandgpu_freq_mhzboth depend on the GPU DVFS table
being classified by_classify_dvfs_tables; when that fails there is no
ceiling (gpu_max_freq_mhz == 0) and both values were meaningless but
indistinguishable from a genuinely idle GPU.api._sample_to_snapshotnow
falls back toDevice Utilization %in exactly that case and records which
path was used ingpu_util_source("residency"|"ioaccelerator"). The
TUI rendersGPU N% (drv)and drops the unmeasured@NMHzwhen the fallback
is active.IOReport residency remains the primary metric on every recognized chip.
Measured side-by-side, the two diverge hard per-sample (actop=40% @1232MHz
vsDevice=91%in one frame) because residency is integrated over the sample
interval while the driver's number is an instantaneous point read; swapping
them wholesale would be a regression in sampling semantics for a sampling
monitor. The fallback branch is unreachable on M1–M4, so it is verified by
inspection rather than by a test — forcing it would need a mock, which the
testing contract forbids.
Fixed
-
Every letter key went dead under Caps Lock, which broke the TUI outright for
CJK input-source users. Caps Lock and Shift deliver the uppercase character,
and Textual names that key"Q", not"q"— so the lowercase-only bindings
simply never matched andq/p/s/g/l/c/tall stopped responding
with no feedback. This is not a fringe case: with a Chinese input source
selected, Caps Lock is how macOS forces direct ASCII, so uppercase is the
normal way these keys arrive in that mode.Each letter action now carries a hidden uppercase alias, derived from a single
_LETTER_BINDINGSlist so the two cannot drift, and the footer still shows one
row per action rather than fourteen.check_actiongates by action name, so
the aliases inherit its gating unchanged. The help overlay's own close keys get
the same treatment.Two consequences worth knowing.
Shift+qnow quits too — a terminal
delivers the sameQfor Shift as for Caps Lock, so the two cannot be told
apart and aliasing one aliases both. And a CJK input source with Caps Lock
off still will not respond: the IME consumes the letters before they ever
reach the process, which no in-app binding can reach. Caps Lock on — the case
this fixes — is the documented way to get direct ASCII in that mode.
actop 1.5.0
Reading-plane audit remediation (docs/TODO-reading-plane-audit-2026-07-29.md
§§1-6), verified against live hardware on an M4 Max / Darwin 25.5.0. §8
(IOAccelerator Device/Renderer/Tiler utilization) is deferred to its own PR;
§3.5 (removing the deprecated *_gb fields) is breaking and rides 2.0.0.
Added
-
Byte quantities are now exported as exact byte counts. New
SystemSnapshot.ram_used_bytes/ram_total_bytes/swap_used_bytes/
swap_total_bytes,ProcessSample.rss_bytes,*_byteskeys on
utils.get_ram_metrics_dict(), andactop_ram_used_bytes/
actop_ram_total_bytes/actop_swap_used_bytes/actop_swap_total_bytes
Prometheus gauges.Bytes rather than a GiB/GB prefix, for three reasons: the GB-vs-GiB question
cannot be got wrong if no prefix is applied; byte counts are exact, whereas the
old rounded fields quantize to ±50 MiB at one decimal; and base units are the
Prometheus/OpenMetrics naming convention (node_exporteruses
node_memory_MemTotal_bytes). Prefix formatting is a display concern and now
happens only in the TUI.Additive and non-breaking.
ram_used_gb/ram_total_gb/swap_used_gb/
swap_total_gb/rss_mb, the*_GBdict keys,convert_to_GBand the
*_gigabytesgauges all remain as rounded views with unchanged values.
They are deprecated and will be removed in 2.0.0. -
--alert-swap-rise-gibreplaces--alert-swap-rise-gb, which is kept as a
working alias (same destination) until 2.0.0. The threshold was always
compared against GiB values, so the old name was a misnomer rather than a
different unit.AlertFrame.swap_rise_gbis likewise renamed
AlertFrame.swap_rise_gib, and the alert token rendersSWAP+0.3Gi.
Fixed
-
Memory reported binary quantities under decimal names.
convert_to_GB
divided bytes by 2^30 and called the result GB;rss_mbdivided by 2^20 and
called it MB. Per IEC 80000-13,1 GB = 10^9while1 GiB = 2^30, so both were
wrong by standard. The mislabel reached the public API (ram_used_gb,
ram_total_gb,swap_*_gb,ProcessSample.rss_mb), theram_used_gigabytes
Prometheus gauge, the NDJSON stream, and the TUI (RAM 66.7/128.0GB,
MEM (MB)).Anyone dividing memory against the genuinely decimal
bandwidth_gbpswas
picking up a silent 7.4% error — andactop's audience does exactly that
(tokens/s ~= effective_bandwidth / bytes_read_per_token, RAM headroom vs.
quantized weights). Fixing only the display string was considered and rejected:
it would protect the casual reader while continuing to mislead the actual user.The TUI now displays GiB and MiB, matching modern monitors (btop,
bottom,free -h,nvidia-smi,docker stats, KubernetesMi/Gi).
Bandwidth is deliberately left decimal: the DCS bucket labels are literally
"32GB/s"and Apple publishes 546 GB/s for M4 Max decimally, soGB/sis the
vendor's own unit for the bus, not an inconsistency. -
The swap-rise alert now measures growth from the exact
swap_used_bytescounts
instead of the rounded*_gbview, so a 0.1 GiB threshold can no longer trip on
rounding alone. -
_resolve_state_freqreturned0andNonefor different flavours of
"unresolvable", and its two consumers disagreed about which meant what. An
out-of-rangeV{n}P{m}/P{n}index returned0, which
_compute_residency_metricscounted as an active state (inflating
active_pctwhile draggingavg_freqdown) but
_compute_residency_distributionbucketed as idle — sogpu_util_pctand
gpu_residency_pct, displayed side by side, could contradict each other.
Out-of-range now returnsNoneand both consumers rejectfreq <= 0.
Latent on M1-M4: a probe of all 316 real state entries across every
CPU Stats/GPU Statschannel on an M4 Max hit the zero path 0 times. It
triggers on a chip exposing more states than its DVFS table describes — the
unknown-future-chip path thesoc_profilestier fallback exists to serve. -
bandwidth_availablereported that a channel exists, not that it carried
data. A present-but-silentAMCC RD+WRchannel surfaced
bandwidth_available=Truewithbandwidth_gbps=0.0, so the TUI showed
Mem BW 0.0 GB/sinstead of hiding the row — the misleading zero the hide-row
logic exists to prevent. Availability now also requires non-zero residency.
Verified no row flicker on the first frame or at idle for bothsubsamples=1
andsubsamples=3. -
Percentages truncated instead of rounding.
flooris a biased estimator
(expected error -0.5 units, max 1.0, for a uniform fractional part), so every
percentage read systematically low and 99.9% displayed as99%.clamp_percent,
the residencyavg_freq/active_pct, and the RAM/swap used-percent now
round. Deliberately left asint():
sampler._largest_remainder_percentages' floors, which are Hamilton's
apportionment rather than rounding —round()there would let the remainder go
negative and silently break the sum-to-100 guarantee.This is not purely cosmetic.
clamp_percentfeedsbandwidth_percentand
package_power_percent, which feed theMEM-BOUNDandPKGalert thresholds
throughAlertEngine, so a value sitting exactly on its threshold can now
fire one sample earlier. -
Hz -> MHzconversion innative_sys.get_dvfs_tables_nativerounds instead of
flooring, so a 1,499,800,000 Hz state reads 1500 MHz rather than 1499.
No visible change on M1-M4 — every observed table entry is already an exact
MHz multiple, andget_dvfs_tables_native()returns the same table set,
lengths and values before and after on an M4 Max. This is pre-emptive
correctness for chips whose tables are not exact, not a fix for a wrong number
anyone is seeing today.
Changed
- Documented that Apple's DVFS tables are not monotonic. The M4 Max GPU
voltage-states table reads
[0, 338, ..., 1312, 1242, 1380, 1326, 1470, 1578]— non-ascending, with
1182appearing twice. Verified against rawpmgrbytes that the 8-byte
(freq_hz, voltage)stride is correct and this is the table's genuine shape,
not a stride bug. Three docstrings claimed ascending order, which would invite
"optimizing"max(freq_table)intofreq_table[-1]and silently break the
DVFS ceiling. Docstrings and comments only; the code was already correct
because it usesmax()throughout.
actop 1.4.16
Changed
- Lint: unpin
ruffand bring the tree into0.16compliance, lifting the
>=0.15,<0.16cap that 1.4.14 added as a stopgap when0.16.0's expanded
default rule set turned a previously-clean tree into 126 errors. The rule
families are now codified in[tool.ruff.lint]rather than inherited from
ruff's defaults, so a future default-select expansion can't break the CI gate
again. Deliberately not selected:BLE/S(the native ctypes/IOKit/SMC reads
intentionally catch broadExceptionand fail silent) andEXE/PLW(skill
and scratch helper scripts).RUF001-003are ignored because the TUI
intentionally uses—,·,×and braille;RUF012because Textual's
BINDINGS = [...]is the framework idiom. No runtime or API change — the
code edits are mechanical modernizations (endswithtuples,
dict.fromkeys, comprehension and import cleanups).
Fixed
docs/DESIGN-system.md: repair theVMStatistics64example, whose elision
markers had been collapsed into the field list (...("compressor_page_count", ...)on one line, plus a stray...,), leaving the snippet syntactically
invalid.