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]
The privileged collector never loads user configuration or notification credentials. The user agent never calls the process-inspection APIs directly. Both roles currently use the same native executable, installed at different, appropriately owned paths.
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.
- 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
.appgrouping, including Firefox, Chromium, Electron, and other multi-process applications, with the terminals listed interminalApplicationstreated 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.
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.
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, fromvm.swapusage, is the global amount of used swap space;virtualMemory.swapBackedUncompressedBytesis 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 thanswap.usedBytes;compressedOrPagedOutBytesis 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.
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.
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.
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
./scripts/install.sh. 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.
- Apple Silicon Mac;
- Xcode Command Line Tools or Xcode;
- Kotlin Toolchain 0.11.1 through the checked-in
./kotlinwrapper; - Kotlin 2.4.10, resolved by the toolchain.
./kotlin build
./kotlin build --variant release
./kotlin testBuild 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 executable is written to:
build/tasks/_harmon_linkMacosArm64Release/harmon.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 same applies to the debug paths below.
For a local, unprivileged IPC smoke test without installing launchd services, start the collector in one terminal:
build/tasks/_harmon_linkMacosArm64Debug/harmon.kexe \
collector \
--socket /tmp/harmon-dev.sock \
--allowed-uid "$(id -u)" \
--allowed-gid "$(id -g)" \
--allow-unprivilegedThen sample through it from another terminal:
HARMON_COLLECTOR_SOCKET=/tmp/harmon-dev.sock \
build/tasks/_harmon_linkMacosArm64Debug/harmon.kexe \
once --sample-seconds 2This development mode intentionally has the same visibility limitations as the current login user.
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 --help
harmon --version
launchd owns the collector command in a normal installation. 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.
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
applicationCpuAlertPercent=150
applicationMemoryAlertMiB=2048
applicationDiskWriteAlertMiBPerSecond=50
swapAlertMiB=1024
swapOutAlertMiBPerSecond=25
applicationBatteryImpactAlertScore=100
batteryLowAlertPercent=20
systemNotifications=true
notifyEverySample=falseA threshold of 0 disables that rule. 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-notificationsAlerts 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 over its threshold that the cap left out,
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. 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.
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.
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.
Run the installer as the login user:
./scripts/install.shIt asks for sudo only for the system-owned collector files and services. It:
- builds the release executable;
- installs the background-only agent bundle under
~/Library/Application Support/Harmon/Harmon.app; - links the user CLI at
~/.local/bin/harmonto the bundled executable; - installs a root-owned copy at
/Library/PrivilegedHelperTools/dev.yoda.harmon; - registers
dev.yoda.harmon.collectoras a system LaunchDaemon; - creates
/var/run/harmon.collector.sock, accessible only to root and the configured login user; - registers
dev.yoda.harmon.agentin the Aqua user session; - preserves an existing user configuration.
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.logRemove both services and installed binaries while preserving configuration, logs, generated reports and the sample history:
./scripts/uninstall.shThe 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*src/
ipc/ versioned kotlinx.serialization protocol and Unix socket roles
monitor/ privileged macOS collection and interval calculations
analysis/ application grouping, alert rules, and alert state
config/ user-agent configuration
history/ the SQLite sample history and its retention
report/ text, local HTML, and kotlinx.serialization JSON reporting
notify/ Notification Center, webhook, and Telegram delivery
runtime/ user-agent monitoring loop
sqldelight/ history schema and the queries generated from it
plugins/ the SQLDelight code generator, as an Amper plugin
nativebridge/ libproc, Mach, sysctl, IOKit, sockets, and libcurl bridge
selftest/ binding checks that run the bridge from Kotlin
test/ unit tests, plus the C test harness in test/native
launchd/ LaunchDaemon and LaunchAgent templates
scripts/ install and uninstall flows, plus the C harness runner
docs/ architecture, metric semantics, and the history schema
LICENSE GPL-3.0-only license text
Harmon is licensed under the
GNU General Public License version 3 only (GPL-3.0-only).