Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

123 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Harmon

Harmon is a lightweight macOS workload monitor written in Kotlin/Native. It samples processes every few minutes, groups helpers into their owning application, explains memory and storage pressure, and can notify Notification Center, Telegram, or an HTTPS webhook.

The project currently has no UI. It runs as two launchd services:

flowchart LR
    LD[Root LaunchDaemon] -->|collects public kernel and IOKit counters| S[Unix socket]
    S -->|length-prefixed kotlinx.serialization JSON| LA[User LaunchAgent]
    LA --> R[Reports and alert rules]
    LA --> H[(history.db)]
    R --> N[Notification Center]
    R --> T[Telegram]
    R --> W[Webhook]
Loading

The privileged collector never loads user configuration or notification credentials. The user agent never calls the process-inspection APIs directly. They are different native executables: harmon-collector links only collection and IPC code, while harmon owns the agent, CLI, notifications, HTTP, and SQLite history.

See the collection model for metric definitions, the service architecture for the privilege and IPC boundary, and the sample history for the schema the stored samples are queried through.

What Harmon monitors

  • per-process and per-application CPU over the real sampling window;
  • resident, wired, physical-footprint, and lifetime-peak memory;
  • a bounded per-process compressed-or-paged-out memory proxy;
  • global allocated/used swap, physical compressor size, uncompressed memory represented in the compressor and in swap, compression/decompression rates, and compressor swap-I/O rates;
  • physical disk reads and writes, logical writes, page-ins, faults, system calls, context switches, thread counts, instructions, cycles, and accounted energy where macOS supplies it;
  • internal block-device bytes, operations, and service time from IOKit;
  • root-filesystem capacity;
  • system CPU, 1/5/15-minute load averages, physical memory, and power state;
  • automatic .app grouping, including Firefox, Chromium, Electron, and other multi-process applications, with the terminals listed in terminalApplications treated as boundaries rather than owners of every command they launch;
  • alerts on crossing a threshold for CPU, memory, physical storage writes, swap usage, swap-out traffic, likely battery impact, and low battery;
  • an alert on a process losing its parent: a process that had a live parent in one sample and is handed to launchd by the next raises a warning naming both, which no snapshot of ps can tell apart from an ordinary daemon.

All local IPC and outbound JSON is encoded with kotlinx.serialization. Executable paths and detailed collection failures remain local and are omitted from normal webhook payloads.

Important interpretation limits

Per-process swap

macOS exposes supported global swap state, but it does not expose a supported public PID → bytes physically present in swap files table. Harmon therefore keeps three concepts separate:

  • swap.usedBytes, from vm.swapusage, is the global amount of used swap space;
  • virtualMemory.swapBackedUncompressedBytes is the uncompressed size of VM pages represented by compressor slots currently on disk. It is deliberately not labelled physical swap bytes and can be larger than swap.usedBytes;
  • compressedOrPagedOutBytes is a per-process proxy obtained by walking public VM region information. It counts pages owned by the compressor pager and cannot distinguish pages still held in compressed RAM from pages whose compressor segment was written to disk.

To keep collection bounded, Harmon walks VM regions only for the 256 readable processes with the largest physical footprint, and stops walking once a per-sample budget of 100,000 regions is spent. Most of that budget goes to the largest processes first — they are what the compressed-memory ranking is made of, and a share too small to finish one produces nothing — and the reserved remainder is split over the rest of the candidates. On a loaded machine the budget, rather than the 256-process limit, is what ends attribution: the head of the ranking is measured completely and most of the tail is truncated, so a minority of the 256 candidates yields a value. Reports include the attribution coverage and failure count; see the collection model for the measured coverage figures. The proxy must not be summed and presented as exact disk swap.

Root access

Running the collector as root fixes the normal same-user restriction applied by proc_pid_rusage and proc_pidinfo, so it substantially improves process coverage. It does not disable SIP, mandatory access-control policy, or every special protection used by macOS. Harmon keeps an inaccessible-process count and detailed local diagnostics instead of silently treating missing processes as zero usage.

Battery impact

Activity Monitor's Energy Impact formula is private. Harmon exports macOS's accounted nanojoule counter when available and also calculates a transparent relative score:

CPU % + wakeups/s × 0.25 + physical disk I/O MiB/s × 2

The score is useful for ranking and alerting, not as a wattmeter.

Upgrading from an earlier build

This release corrects CPU accounting in the native bridge. proc_pid_rusage returns CPU time in mach absolute time, and Harmon reported those ticks as if they were nanoseconds. On Apple Silicon one tick is 125/3 ns, so every per-process and per-application CPU percentage, and every battery-impact score derived from CPU, is now about 41.7 times higher than the value Harmon printed before: the old numbers were understated by that factor. Intel Macs have a 1:1 timebase and are unaffected.

Thresholds tuned against the old numbers must be retuned. applicationCpuAlertPercent and applicationBatteryImpactAlertScore lowered to compensate for the understated values now fire on almost every sample; the same keys left at a level the old numbers could never reach begin alerting for the first time.

Because the meaning of the CPU counters on the wire changed, the collector protocol version is now 2. The collector (root LaunchDaemon) and the agent (user LaunchAgent) are a matched pair and have to be reinstalled together with harmon setup. A mismatched pair now fails explicitly with Unsupported collector protocol 1; expected 2 instead of silently reporting CPU that is roughly 41 times too low.

Requirements

  • Apple Silicon Mac running macOS 12 Monterey or newer.

A source build additionally needs Xcode Command Line Tools or Xcode and Kotlin Toolchain 0.11.1 through the checked-in ./kotlin wrapper. The toolchain resolves Kotlin 2.4.10.

Build and test

./kotlin build
./kotlin build --variant release
./kotlin test

Build before test. The test run drives two external harnesses over the native C bridge, and one of them is the selftest executable, which only ./kotlin build produces; a missing or outdated selftest binary fails the test suite rather than being skipped.

The C harness can also be run on its own — it needs no Kotlin build, since the script compiles it every time — with an optional name prefix to select one suite:

scripts/test-native.sh
scripts/test-native.sh socket.

Neither harness is meant to run as root; two of the checks assume an ordinary user. CLAUDE.md describes both harnesses in full.

The release executables are written to:

build/tasks/_harmon_linkMacosArm64Release/harmon.kexe
build/tasks/_harmon-collector_linkMacosArm64Release/harmon-collector.kexe

The _harmon_ in that path is the name of the checkout directory: the root module has no name: key, so a clone or a git worktree under a different directory produces build/tasks/_<directory>_linkMacosArm64Release/ instead. The collector is a physical harmon-collector/ module, so its task directory and output name stay stable. The same Debug/Release suffix convention applies to the paths below.

For a local, unprivileged IPC smoke test without installing launchd services, start the collector in one terminal:

build/tasks/_harmon-collector_linkMacosArm64Debug/harmon-collector.kexe \
  --socket /tmp/harmon-dev.sock \
  --allowed-uid "$(id -u)" \
  --allowed-gid "$(id -g)" \
  --allow-unprivileged

Then sample through it from another terminal:

HARMON_COLLECTOR_SOCKET=/tmp/harmon-dev.sock \
  build/tasks/_harmon_linkMacosArm64Debug/harmon.kexe \
  once --sample-seconds 2

This development mode intentionally has the same visibility limitations as the current login user.

Commands

harmon-collector --allowed-uid UID --allowed-gid GID [--socket PATH]
harmon run [--config PATH]
harmon once [--config PATH] [--sample-seconds N] [--notify]
harmon diagnose [--config PATH] [--sample-seconds N]
harmon check-config [--config PATH]
harmon test-notifications [--config PATH]
harmon setup
harmon setup --system --uid UID --gid GID
harmon status
harmon --help
harmon --version

launchd owns harmon-collector in a normal installation; it is not a harmon subcommand. With no command, harmon starts the user-agent loop. once takes two collector snapshots and prints one report. diagnose also prints grouping, attribution coverage, and process-access failures.

--sample-seconds is the gap between those two snapshots and accepts 1 to 300 seconds inclusive. A value outside that range, or one that is not an integer, is rejected with exit status 2; the same bounds apply to the onceSampleSeconds configuration key.

Configuration

Harmon reads ~/.config/harmon/config. The installer creates it from config/harmon.conf.example without overwriting an existing file.

Important defaults:

collectorSocket=/var/run/harmon.collector.sock
intervalSeconds=300
historyRetentionDays=7
orphanAlerts=true
applicationCpuAlertPercent=150
applicationMemoryAlertMiB=2048
applicationDiskWriteAlertMiBPerSecond=50
swapAlertMiB=1024
swapOutAlertMiBPerSecond=25
applicationBatteryImpactAlertScore=100
batteryLowAlertPercent=20
systemNotifications=true
notifyEverySample=false

A threshold of 0 disables that rule. orphanAlerts is the one alert rule without a threshold — losing a parent is an event, not a quantity, so there is no number to lower until it stops matching — and orphanAlerts=false is how it is switched off. It is on by default and fires at most maxAlertsPerCategory times per sample, like every other rule. It switches off the alert and nothing else: harmon run still records the transition in the history database, in process.reparented_at. applicationMemoryAlertMiB and swapAlertMiB are capped at 1,048,576 MiB (1 TiB); a larger value is rejected and the process exits with status 2. terminalApplications is a comma-separated list of bundle names without .app, matched case-insensitively. It replaces the built-in list outright — that list is spelled out in config/harmon.conf.example — and an empty value turns the terminal boundary off. The old processCpuAlertPercent, processMemoryAlertMiB, and batteryImpactAlertScore keys remain accepted as compatibility aliases. alertCooldownSeconds no longer does anything — alerts are pushed when a threshold is crossed rather than on a timer — but the key is still accepted and reported on stderr instead of failing the config.

Notification destinations can be overridden for manual runs:

HARMON_WEBHOOK_URL
HARMON_WEBHOOK_BEARER_TOKEN
HARMON_TELEGRAM_BOT_TOKEN
HARMON_TELEGRAM_CHAT_ID
HARMON_COLLECTOR_SOCKET

External webhooks must use HTTPS. Plain HTTP is accepted only for 127.0.0.1, and the host is read from after any @ in the URL, so http://127.0.0.1@example.com/hook is an external HTTP URL and is rejected rather than treated as loopback. A LaunchAgent does not inherit terminal environment variables, so normal installations should keep secrets in the generated 0600 config file.

Validate configuration and notification delivery without printing secrets:

harmon check-config
harmon test-notifications

Alerts and notifications

Alerts are event-driven. A push goes out when an alert key crosses its threshold; while the condition keeps holding there are no repeat reminders. Once the value clears, the same alert pushes again the next time it fires. To stop a value sitting on the threshold from flapping, an alert that is already firing clears only after it drops below 90% of the threshold. Low battery is exempt: it is the one rule comparing with "less than or equal", where a lowered bound would drop the alert while the battery is still low.

A key counts as pushed only once a channel confirmed it. Notification Center is best-effort — macOS gives the launchd agent no synchronous delivery confirmation — so its optimistic success is discounted whenever a decisive channel, a webhook or Telegram, is configured: then only that channel settles an alert. With Notification Center as the only channel there is nothing to discount it against, so its success does settle the alert; otherwise such an install could never settle anything. A failure it does report, such as being unable to write the HTML report, is an observation either way and keeps the alert pushable. A sample whose webhook and Telegram calls both failed is retried on the next sample instead of being silently dropped. An alert whose condition still holds is never given up on, but after three consecutive failed deliveries its retries widen from two samples up to thirty-two, so a permanently broken channel cannot turn Notification Center into an endless banner loop. Any confirmed delivery clears that backoff at once. Alert state is stored in the history database and resumed on restart, so an alert that never stopped firing is not pushed a second time and a failing channel keeps the backoff it earned. A stored state older than two sampling intervals is dropped rather than resumed: it describes a machine that has since moved on. So is one the database refuses to hand back: the failure is logged and the agent starts empty, because a damaged record of yesterday's monitoring must not cost today's. With history turned off the agent starts from an empty state every time, as it did before.

The push text names only the alerts that fired on this sample. The attached HTML report and the JSON webhook payload both carry the sample's whole reported alert list, and the payload adds newAlertKeys listing the ones the push was about. That list is capped at maxAlertsPerCategory alerts per rule, and suppressedAlertKeys names every key the cap left out — over its threshold for the rules that have one, matching at all for the orphan rule, which has none — so a consumer diffing the alert list can tell a dropped alert from a cleared one and the count is the whole overflow. An already-firing alert pushed out of the top slice is demoted rather than cleared — it stays in the alert state, so its return to the list does not push again — while a key crossing its threshold below the cut is reported as suppressed without entering that state.

One rule sits outside all of that. The orphan alert fires on a transition — a process that had a live parent in the previous sample and has pid 1 as its parent in this one — and a transition exists for exactly one sample. Everything above works by carrying a key from one sample to the next and rebuilding the push from the alert list of the later one, and for this rule there is nothing to rebuild from: the next sample has no such alert, and even the message could not be reassembled, because the dead parent's name was read from the sample it was still alive in. So delivery is one-shot. A failed push of an orphan alert is not retried, and an orphan pushed out of the per-category slice is not demoted but dropped — unlike an application alert it gets no later sample to return on.

Neither case loses the fact, though what is left of it differs. A suppressed orphan is in the report text, in the webhook payload and in the alert table with reported = 0. A failed push is in the report text and the alert table only — the payload is exactly what did not arrive. Both are stamped in process.reparented_at in the history database, which keeps the transition for as long as retention keeps the process: once the last sample naming it leaves the window, the row goes with it. Transitions that happen while the agent is not running are not seen at all, since the sample they would have been compared against was never taken.

With notifyEverySample=true the agent sends on every sample and treats the whole alert list as push content; nothing is deferred in that mode, so newAlertKeys there names every alert not yet confirmed as delivered, including one whose earlier deliveries failed.

Edge detection across samples exists only in the long-running harmon run agent. harmon once --notify starts with a fresh, empty alert state, so every alert active in its single sample counts as new: all of them are pushed, and newAlertKeys lists all of them. once and diagnose do compare two snapshots, so the orphan rule can match there, but their window is seconds rather than five minutes and a process that daemonizes inside it looks exactly like one that lost its parent. Neither command writes history, so nothing is stored — but once --notify does deliver, and a false orphan raised under it reaches the webhook, Telegram and Notification Center like any other alert. orphanAlerts=false is the way out for an installation that runs it often.

Notification Center delivery uses the background-only Harmon application bundle installed under ~/Library/Application Support/Harmon/Harmon.app. Each system notification atomically updates a private local report at ~/Library/Application Support/Harmon/Reports/latest.html. Clicking the notification opens that complete report in the default browser. No scripts or remote resources are embedded in the HTML, and Script Editor is not involved. The launchd agent uses the compatible Notification Center path because current macOS releases reject the modern UserNotifications API from a launchd job.

History

A report describes the sample it was built from and nothing else, so harmon run also writes every sample to a SQLite database at ~/Library/Application Support/Harmon/history.db: the system counters, every process, every application that has a bundle, the alerts, and what each channel did with them. That is what makes "what was eating the machine at three in the morning" a question with an answer.

historyRetentionDays is how far back the answer goes. At the default of seven days and a 300-second interval expect a couple of hundred megabytes — a measured sample costs about 140 bytes per stored process, and a machine with several hundred readable processes writes around 222 000 of those rows a day. That is an extrapolation from one measured window rather than an observed steady state, so treat it as an order of magnitude. A 0 keeps no history at all and creates no database file. The agent prunes the window about once an hour and hands the freed space back to the file system as it goes, up to 8 MiB per pass.

Only harmon run writes. once and diagnose measure a window of seconds instead of the sampling interval, so their numbers would mean something different inside the same series. Harmon itself reads the database only for the alert state it resumes after a restart; everything else is a sqlite3 query against a schema that is meant to be queried by hand. That schema, with the queries worth starting from, is the sample history.

Install with Homebrew

The formula is maintained in a separate tap. Once the generated formula has been copied there, the normal installation and upgrade flow is:

brew install OWNER/TAP/harmon
harmon setup
harmon status

Run harmon setup again after every brew upgrade harmon, then verify with harmon status. Homebrew updates the paired source binaries in its Cellar, but cannot use sudo to replace the root helper or update the user app bundle. status reports those stale copies explicitly.

An installation made by the former source script may still have ~/.local/bin/harmon earlier in PATH. For that one migration run, invoke the new Homebrew binary explicitly:

"$(brew --prefix)/bin/harmon" setup
"$(brew --prefix)/bin/harmon" status

Setup removes only the former installer's managed symlink; an unrelated file or symlink at that path is preserved.

The formula installs a ready-made arm64 archive; it does not need the Kotlin toolchain. It intentionally has no Homebrew service: the source collector is copied by setup to the root-owned /Library/PrivilegedHelperTools/harmon-collector before launchd can execute it. Release and tap handoff instructions are in docs/releasing.md.

Install a source build with launchd

Build the paired release binaries, then run setup as the login user:

./kotlin build --variant release
build/tasks/_harmon_linkMacosArm64Release/harmon.kexe setup

scripts/install.sh remains as a compatibility shortcut that performs exactly those two commands. All installation behavior lives in harmon setup; the script does not generate plist files or call launchctl itself.

Setup first creates the application bundle, config, logs, and LaunchAgent as the login user. It then re-executes the same resolved binary once through sudo for the root-owned helper, LaunchDaemon, and service bootstrap. It:

  • installs the background-only agent bundle under ~/Library/Application Support/Harmon/Harmon.app;
  • installs harmon-collector at the root-owned /Library/PrivilegedHelperTools/harmon-collector path;
  • registers dev.yoda.harmon.collector as a system LaunchDaemon;
  • creates /var/run/harmon.collector.sock, accessible only to root and the configured login user;
  • registers dev.yoda.harmon.agent in the Aqua user session;
  • preserves an existing user configuration.

An old ~/.local/bin/harmon symlink managed by the former installer is removed so it cannot shadow an upgraded Homebrew binary. An unrelated file or symlink at that path is left alone. The former agent Label, plist, and helper path are cleaned up during the same migration.

After setup, and after every source binary upgrade, run:

harmon status

It is read-only and needs no sudo. The report shows the running CLI and paired source collector versions, the copies in Harmon.app and /Library/PrivilegedHelperTools, expected and live protocol versions, socket health, and both launchd jobs with their PID and executable path. Any stale, mixed, unloaded, or failed state exits 1 and says to run harmon setup.

Inspect services and logs:

launchctl print "gui/$(id -u)/dev.yoda.harmon.agent"
sudo launchctl print system/dev.yoda.harmon.collector
tail -f ~/Library/Logs/Harmon/agent.log
sudo tail -f /Library/Logs/Harmon/collector.log

Remove both services and installed binaries while preserving configuration, logs, generated reports and the sample history:

harmon uninstall

The command runs as the login user, removes the LaunchAgents and app bundle, then requests sudo once to remove the LaunchDaemon, root helper, legacy helper, and socket. A Homebrew install itself remains in the Cellar; run brew uninstall harmon afterwards if the CLI should be removed too. scripts/uninstall.sh is retained only as a source-checkout compatibility shortcut: like scripts/install.sh, it builds the release binary and delegates all behavior to the typed CLI command.

The history database is the largest of those and is left behind deliberately — it is the record the agent was collecting, and up to a few hundred megabytes of it. Reclaim the space when you no longer want it:

rm -f ~/Library/Application\ Support/Harmon/history.db*

Project structure

core/          shared model, protocol, policy, config, reports, and agent runtime
history-sqlite/
               SQLDelight history implementation, schema, retention, and tests
harmon-collector/
               collector app, IPC server, Darwin probes, and collector tests
src/           harmon CLI, IPC client, notifications, and app composition root
bridge-ipc/    Unix sockets and JSON framing cinterop
bridge-install/
               executable-path discovery used by setup and status
bridge-probe/  libproc, Mach, sysctl, and IOKit cinterop
bridge-http/   libcurl cinterop
plugins/       the SQLDelight code generator, as a Toolchain plugin
selftest/      probe binding checks run from Kotlin
test/          root CLI/factory/harness tests, plus the C harness in test/native
launchd/       Harmon.app metadata and icon
scripts/       compatibility install/uninstall entry points, release tools, and C tests
docs/          architecture, metric semantics, and the history schema
LICENSE        GPL-3.0-only license text

License

Harmon is licensed under the GNU General Public License version 3 only (GPL-3.0-only).

About

Lightweight macOS process and battery monitor in Kotlin/Native

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages