Skip to content

Releases: binlecode/actop

actop 1.6.9

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:13
4bbe250

Added

  • Guard-release CI workflow (guard-release.yml): every v* 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 push of a v* tag is
    detected, reminding to use scripts/tag_release.sh instead.

Changed

  • README badge row gains a Website badge linking to the Cloudflare Pages coverpage
    (actop.pages.dev).

actop 1.6.6

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
79073a9

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, original qwen3.6-35b-a3b
    weights) instead of the ollama-router. record-tui-gif skill moved from
    .claude/skills/ to .agents/skills/; record.sh now 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

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
7f9d553

Added

  • --json --samples N — bounded NDJSON emission for agent/script one-shots.
    --json previously streamed until interrupted, forcing an agent or script to
    kill the process after reading its record. --samples N (with --json) now
    emits exactly N snapshot records then exits 0; --samples 0 (default)
    keeps the streaming behavior. The first record already carries real deltas
    (the Monitor primes the baseline at construction), so --json --samples 1
    is a clean single-snapshot tool call.

actop 1.6.4

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
f9adffa

Fixed

  • Process CPU% was understated ~41.7x on every Apple Silicon machine. The
    two CPU-time fields in proc_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 as cpu_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 reads mach_timebase_info once at import and converts via integer math
    (_mach_ticks_to_ns). Per-process cpu_time_share and watt attribution are
    ratio-based, so they were never affected. See Apple openradar FB9546856.

actop 1.6.3

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
68bbe2d

Added

  • GPU% column in the TUI process tablegpu_time_share from the Monitor
    was always collected but never rendered. The process table now shows a GPU%
    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. The s sort
    cycle now includes GPU% (CPU% → GPU% → PWR → RSS → PID).

actop 1.6.2

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
7154ba8

Fixed

  • Export modes (--json / --serve) now honor --show-processes and
    --proc-filter
    — they were silently ignored, so every NDJSON record carried
    "processes":[] even when the TUI (t key) showed populated per-process data.
    The CLI routing (_run_export) now forwards both flags to the NDJSON and
    Prometheus backends; run_json_stream and serve_prometheus pass them through
    to Monitor(include_processes=True, process_filter=...). --proc-filter
    without --show-processes implies 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 by pid and command, emitted only
    when include_processes=True. The NDJSON path needs no format change —
    dataclasses.asdict already serialised processes when they were collected;
    the gap was purely that collection was never enabled.

actop 1.6.1

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
9544ad7

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: SystemSnapshot as the sole frame
    contract, ProcessSample through L2, and analytics.py's AlertEngine /
    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 against main, 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_max reducer never
      moved to an analytics.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.py still imports get_soc_info for
    a single construction-time call that builds DashboardConfig. 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.

actop 1.6.0

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
a5ab6f5

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) and Tiler Utilization % (geometry work) are read off the accelerator's own
    PerformanceStatistics dict 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 unified GPUPH channel.

    New SystemSnapshot fields gpu_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_percent Prometheus gauges; all five fields in
    NDJSON. gpu_util_source is 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. ioreg is deliberately not shelled out to.

  • A Rend N% · Tiler N% row in the GPU · ANE TUI section, hidden entirely
    when the accelerator reports no statistics (the same hide-row contract as Mem
    BW and Fan) rather than showing a phantom 0/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_pct and gpu_freq_mhz both 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_snapshot now
    falls back to Device Utilization % in exactly that case and records which
    path was used in gpu_util_source ("residency" | "ioaccelerator"). The
    TUI renders GPU N% (drv) and drops the unmeasured @NMHz when 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
    vs Device=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 and q/p/s/g/l/c/t all 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_BINDINGS list so the two cannot drift, and the footer still shows one
    row per action rather than fourteen. check_action gates 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+q now quits too — a terminal
    delivers the same Q for 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

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
4d85c51

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, *_bytes keys on
    utils.get_ram_metrics_dict(), and actop_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_exporter uses
    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 *_GB dict keys, convert_to_GB and the
    *_gigabytes gauges all remain as rounded views with unchanged values.
    They are deprecated and will be removed in 2.0.0.

  • --alert-swap-rise-gib replaces --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_gb is likewise renamed
    AlertFrame.swap_rise_gib, and the alert token renders SWAP+0.3Gi.

Fixed

  • Memory reported binary quantities under decimal names. convert_to_GB
    divided bytes by 2^30 and called the result GB; rss_mb divided by 2^20 and
    called it MB. Per IEC 80000-13, 1 GB = 10^9 while 1 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), the ram_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_gbps was
    picking up a silent 7.4% error — and actop'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, Kubernetes Mi/Gi).
    Bandwidth is deliberately left decimal: the DCS bucket labels are literally
    "32GB/s" and Apple publishes 546 GB/s for M4 Max decimally, so GB/s is the
    vendor's own unit for the bus, not an inconsistency.

  • The swap-rise alert now measures growth from the exact swap_used_bytes counts
    instead of the rounded *_gb view, so a 0.1 GiB threshold can no longer trip on
    rounding alone.

  • _resolve_state_freq returned 0 and None for different flavours of
    "unresolvable", and its two consumers disagreed about which meant what.
    An
    out-of-range V{n}P{m} / P{n} index returned 0, which
    _compute_residency_metrics counted as an active state (inflating
    active_pct while dragging avg_freq down) but
    _compute_residency_distribution bucketed as idle — so gpu_util_pct and
    gpu_residency_pct, displayed side by side, could contradict each other.
    Out-of-range now returns None and both consumers reject freq <= 0.
    Latent on M1-M4: a probe of all 316 real state entries across every
    CPU Stats / GPU Stats channel 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 the soc_profiles tier fallback exists to serve.

  • bandwidth_available reported that a channel exists, not that it carried
    data.
    A present-but-silent AMCC RD+WR channel surfaced
    bandwidth_available=True with bandwidth_gbps=0.0, so the TUI showed
    Mem BW 0.0 GB/s instead 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 both subsamples=1
    and subsamples=3.

  • Percentages truncated instead of rounding. floor is 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 as 99%. clamp_percent,
    the residency avg_freq / active_pct, and the RAM/swap used-percent now
    round. Deliberately left as int():
    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_percent feeds bandwidth_percent and
    package_power_percent, which feed the MEM-BOUND and PKG alert thresholds
    through AlertEngine, so a value sitting exactly on its threshold can now
    fire one sample earlier.

  • Hz -> MHz conversion in native_sys.get_dvfs_tables_native rounds 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, and get_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
    1182 appearing twice. Verified against raw pmgr bytes 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) into freq_table[-1] and silently break the
    DVFS ceiling. Docstrings and comments only; the code was already correct
    because it uses max() throughout.

actop 1.4.16

Choose a tag to compare

@binlecode binlecode released this 09 Aug 19:03
c457197

Changed

  • Lint: unpin ruff and bring the tree into 0.16 compliance, lifting the
    >=0.15,<0.16 cap that 1.4.14 added as a stopgap when 0.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 broad Exception and fail silent) and EXE/PLW (skill
    and scratch helper scripts). RUF001-003 are ignored because the TUI
    intentionally uses , ·, × and braille; RUF012 because Textual's
    BINDINGS = [...] is the framework idiom. No runtime or API change — the
    code edits are mechanical modernizations (endswith tuples,
    dict.fromkeys, comprehension and import cleanups).

Fixed

  • docs/DESIGN-system.md: repair the VMStatistics64 example, whose elision
    markers had been collapsed into the field list (...("compressor_page_count", ...) on one line, plus a stray ...,), leaving the snippet syntactically
    invalid.