daemons

View script Copied!

Observe your homegrown launchd daemons. A LaunchAgent or LaunchDaemon runs in the background and tells you almost nothing: launchctl knows whether it is loaded and what its last exit code was, but not what it actually did over time - did the trigger fire? did it do work, or no-op? has it been quietly erroring for a week?

daemons closes that gap. Each daemon appends one-line JSON records to its own activity log (the append write path daemons call), and four read commands turn those logs plus live launchctl state into a picture you can act on: status (a per-daemon glance), check (a nonzero-on-problem health gate for your login shell or a monitor), log (human-readable rendering), and query (filter and extract the raw JSONL). Two more commands act on daemons rather than just observe them: load and unload bootstrap/bootout a daemon into launchd (and log a load/unload record so the timeline explains gaps in its fires), and registry reveals the registry file.

Quick start

The common flow is a quick triage: glance at all daemons, skim recent activity, then isolate anything that errored.

$ daemons status
reconcile                loaded      last change @ 2026-07-16T04:30:12Z | 24h: 24 runs, 2 changes, 0 errors | silence: OK (limit 15m)
screenshot-rename        loaded      last error @ 2026-07-16T05:12:44Z | 24h: 4 runs, 1 change, 1 error
backup                   NOT LOADED  never fired | 24h: 0 runs, 0 changes, 0 errors

$ daemons log --all
2026-07-16T04:30:12Z reconcile CHANGE reconciled 3 drifted keys
2026-07-16T05:00:03Z screenshot-rename NOOP no new screenshots
2026-07-16T05:12:44Z screenshot-rename ERROR sips exited 1 on Screen Shot 2026-07-16.png

$ daemons query --all --event error --since 1h
{"ts":"2026-07-16T05:12:44Z","daemon":"screenshot-rename","event":"error","detail":"sips exited 1 on Screen Shot 2026-07-16.png"}

status, check, load, and unload read a small registry you set up once (see The registry below). append, log, and query work straight from the log files and need no registry at all.

Status windows and silence limits

status keeps the last activity visible while replacing lifetime trivia with a rolling operational window. The default is 24 hours:

daemons status

Use -s/--since to ask the same question over another relative span, date, or UTC timestamp:

daemons status --since 7d
daemons status -s 2026-07-15

The window reports trigger records as runs and independently counts change and error outcomes. It does not infer a run from an outcome, so an older outcome-only client can temporarily produce more outcomes than runs in a historical window instead of receiving a made-up activation count.

The registry's optional max_silence field turns a daemon's expected firing cadence into health policy. For a loaded daemon with a limit, status shows silence: OK, silence: OVERDUE, or silence: NEVER OBSERVED; check fails for the latter two. The clock is measured from the most recent trigger, so a recent load, noop, or change record cannot conceal a daemon that stopped firing. Unloaded daemons already have a stronger launchd failure and are not also labeled overdue.

Logging activity from a daemon (append)

append is the write path: a daemon calls it once per run to record what happened.

daemons append reconcile change "reconciled 3 drifted keys"

The <event> argument is one of six fixed values, so activity is countable and filterable:

Always call append guarded, so a logging failure never takes the daemon down with it:

daemons append reconcile change "reconciled 3 drifted keys" 2>/dev/null || :

The 2>/dev/null || : swallows any failure (jq missing under the daemon's minimal PATH, a full disk, a read-only log dir) - the record is best-effort, and the daemon's real work proceeds regardless.

For a large or multi-line detail (a captured command output, say), pass --detail-stdin in place of the detail argument and pipe the payload in:

some-command 2>&1 | daemons append reconcile error --detail-stdin 2>/dev/null || :

Newlines and tabs in the detail round-trip intact: the record stays one physical line, with the detail JSON-escaped inside it.

Reading and filtering (log and query)

log renders records for humans - one header line per record (<ts> <daemon> <EVENT> <first detail line>), with any further detail lines indented beneath. Name a single daemon, or use --all to merge every daemon's records in timestamp order:

$ daemons log reconcile
2026-07-16T04:30:12Z reconcile CHANGE reconciled 3 drifted keys

Because timestamps are ISO-8601 UTC they sort lexically, which is how -a/--all interleaves daemons correctly. Add -f/--follow to follow the log as new records land, like tail -f:

daemons log --all -f

query is the machine-readable counterpart: it emits the raw JSONL records that survive its filters, so you can pipe them onward.

Unlike log --all (which merge-sorts every daemon's records by timestamp), query --all emits records grouped by daemon, in log-file order - not globally time-sorted. Pipe through jq -s 'sort_by(.ts)' if you need one global time order across daemons.

Keep one event type with -e/--event:

daemons query reconcile --event error

Keep recent records with -s/--since, which accepts three forms:

daemons query --all --event error --since 1h

Reshape the output by piping matches through a jq expression with -j/--jq:

daemons query reconcile --event change --jq '.detail'

A bare log or query with no name (and no -a/--all) defaults to all daemons. Querying a daemon that has never logged is not an error - it simply prints nothing and exits 0.

Health-checking (check)

check is a gate: it reads the registry and exits nonzero, printing a loud alert to stdout, for any daemon that is not loaded in launchd, points at a script that no longer exists, whose last launchd run exited nonzero, or has exceeded its configured max_silence without a trigger record.

$ daemons check
[daemons] com.example.backup not loaded in launchd!
$ echo $?
1

Because it exits nonzero only on a problem and is silent when everything is healthy, it drops cleanly into a login-shell startup snippet or a monitoring job:

daemons check || echo "a daemon needs attention - run: daemons status"

Silence checks read backward from the end of each append-only log and stop at the newest trigger; a malformed record in that scanned suffix still fails the check. Nothing is cached or summarized in a sidecar, so every invocation observes the live log while its healthy-path work stays independent of years of older records. status still reads the full history needed for rolling event counts.

Loading and unloading (load and unload)

load and unload drive launchctl directly, so you don't need to remember the domain/label/plist incantation for a daemon you already registered.

load always reloads: it boots the daemon out first (ignoring "not loaded", since there's nothing to boot out on a first load), then bootstraps it fresh from its plist. That makes it safe to re-run after editing a plist or a script - the old instance is gone before the new one starts.

The plist path is resolved by precedence: the --plist flag, then the registry's plist column, then convention derived from the domain and label - gui/$UID/ daemons resolve to ~/Library/LaunchAgents/<label>.plist, system/ daemons to /Library/LaunchDaemons/<label>.plist. Whichever way it's resolved, load verifies the file exists before touching launchd.

unload boots the daemon out and is idempotent: if it's already not loaded, it says so and exits 0 rather than treating that as an error.

Both accept -n/--dry-run to print the launchctl commands they would run without running them:

daemons load reconcile --dry-run

A system/ target needs root, so load/unload elevate just the launchctl call (and the lifecycle record append) via sudo when you're not already root - expect a password prompt:

$ daemons load reconcile
$ daemons load reconcile --plist /custom/path.plist
$ daemons unload reconcile
$ daemons load managed-settings-patch   # system/ target: prompts for sudo password

Each successful load/unload appends its own load/unload record to the daemon's log, so log/query show not just what the daemon did but when it was (re)loaded or pulled - useful for explaining a gap in its activity.

The registry

status, check, load, and unload need to know which daemons exist and how to address them in launchd. That lives in a small registry file (see DAEMONS_REGISTRY for its location). It is a header row plus one whitespace-separated row per daemon:

name      domain     label                 script                   plist  max_silence
periodic  gui/$UID/  com.example.periodic  /usr/local/bin/periodic  -      15m
watcher   gui/$UID/  com.example.watcher   /usr/local/bin/watcher   -      -

status, check, load, and unload consult the registry. The write and read paths (append, log, query) operate purely on log files, so a daemon can log before it is ever registered.

Revealing the registry (registry)

registry prints the resolved $DAEMONS_REGISTRY path, so you don't have to remember or recompute it:

$ daemons registry
/Users/you/.config/daemons/daemons.tsv

Pass --edit to open it straight in your editor - $VISUAL, then $EDITOR, then vi (default), and the editor string may carry arguments (e.g. VISUAL="code --wait") - or --open to open it with macOS open (whatever app is registered for the file type). The two are mutually exclusive:

daemons registry --edit
daemons registry --open

The JSONL record

Each daemon owns one log file, <name>.log, in DAEMONS_LOG_DIR. Every line is a self-contained JSON object (JSONL):

{"ts":"2026-07-16T05:12:44Z","daemon":"screenshot-rename","event":"error","detail":"sips exited 1 on Screen Shot 2026-07-16.png"}
Field Value
ts ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ), stamped by append at write time
daemon the <name> passed to append
event one of trigger, noop, change, error, load, unload
detail free-form string; may contain newlines and tabs (JSON-escaped, so the record stays one line)

Reference

Subcommands

Subcommand Description
append <name> <event> [detail] Append one record to <name>'s log (creating the log dir on first write). <event> is trigger, noop, change, error, load, or unload. Pass --detail-stdin in place of [detail] to read the detail from stdin
status [--since T] Per-daemon summary from the registry: loaded state, last activity, rolling run/change/error counts, and configured silence-limit state. The count window defaults to 24h; -s is the short form of --since
check Health gate: exits nonzero with a loud alert for any daemon not loaded, pointing at a missing script, whose last run exited nonzero, or exceeding its configured max_silence
log [name|--all] [--follow] Render a log human-readably. -a is the short form of --all; -f is the short form of --follow
query [name|--all] [--event E] [--since T] [--jq EXPR] Emit raw JSONL records matching the filters. The short forms are -a, -e, -s, and -j respectively
load <name> [--plist PATH] [--dry-run] Bootstrap (reload) a daemon into launchd, resolving its plist by --plist > registry column > convention. Logs a load record. -n is the short form of --dry-run; a system/ target elevates via sudo
unload <name> [--dry-run] Bootout a daemon from launchd (idempotent) and log an unload record. -n is the short form of --dry-run
registry [--edit|--open] Print the resolved registry path; --edit opens $VISUAL/$EDITOR (default vi), --open uses macOS open
-h, --help Show help

-s/--since T accepts a relative span (NNN[smhd], e.g. 1h or 7d), a bare date (YYYY-MM-DD, midnight UTC), or a full ISO-8601 UTC timestamp. status defaults to 24h; query has no time filter unless the option is provided.

Environment variables

Variable Meaning
DAEMONS_LOG_DIR Directory holding the <name>.log files. Default: $XDG_STATE_HOME/daemons, or ~/.local/state/daemons when XDG_STATE_HOME is unset
DAEMONS_REGISTRY Registry file that status, check, load, and unload read (and registry prints). Default: $XDG_CONFIG_HOME/daemons/daemons.tsv, or ~/.config/daemons/daemons.tsv when XDG_CONFIG_HOME is unset

Exit codes

Code Meaning
0 Success (and, for check, all daemons healthy)
1 Runtime failure; also check when it detects a health problem
2 Usage error (unknown or missing subcommand, missing argument, invalid event, unknown option)
3 jq is not installed

Dependencies

macOS only for status/check/load/unload, which are launchd-specific. append, log, query, and registry are portable - just files (and jq, for the JSON ones).