Generated Computers
Locals: an Apple Silicon engine that runs local LLMs up to 3x faster. A product of Generated Computers.
翻译准备中
Schedule macOS background jobs without writing plists — a friendly CLI for launchd LaunchAgents and LaunchDaemons
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Schedule and manage macOS background jobs without writing a single plist.
launchd is powerful but painful to use directly. Summond wraps it with a CLI that handles plist generation, log management, and job lifecycle — so you can focus on what the job actually does.
brew install --cask joshgummersall/summond/summond
Or install from source (requires Go 1.24+):
go install github.com/joshgummersall/summond/cmd/summond@latest
Jobs run from a copy of summond in its state directory. After brew install or brew upgrade, the cask replaces that copy so jobs run the new version. If you have daemon jobs, this step asks for your password. If it can't run (for example, an unattended upgrade with no terminal), the next summond apply or sudo summond apply replaces the copy.
Jobs run from a copy of summond in Application Support, so they keep running after brew uninstall. To remove every job, plist, log, and state directory:
brew uninstall --zap summond
Zap runs summond uninstall twice: once as you, for agent jobs, and once with sudo, for daemon jobs. It also removes the newsyslog config that summond v1 installed.
Or run summond uninstall (agents) and sudo summond uninstall (daemons) before brew uninstall. Each run removes one scope. Homebrew runs a cask's plain uninstall steps on every upgrade too, so only --zap removes jobs.
Define jobs in a TOML file and apply them:
[jobs.cleanup]
command = "/usr/local/bin/my-script"
schedule = "daily"
hour = 3
minute = 0
[jobs.cleanup.env]
MODE = "nightly"
summond apply
That's it. There is no separate setup step: Summond creates its state directory on first use, generates and loads the launchd plist, creates and rotates log files, and tracks execution history. When you delete a job from the config, summond apply --prune removes it cleanly.
command, args, shell_command, working_dir, watch_paths, and env values all support $VAR/${VAR} expansion, resolved against summond's own environment when you run apply. Referencing an unset variable is an error rather than silently resolving to an empty string:
[jobs.cleanup]
command = "$HOME/bin/my-script"
working_dir = "$HOME/projects/foo"
# Run a binary on a schedule
summond add cleanup --schedule daily --hour 3 --minute 0 -- /usr/local/bin/my-script
# Run a shell snippet
summond add rotate-logs --schedule daily --hour 3 --minute 30 <<'EOF'
find /tmp -type f -mtime +7 -delete
EOF
login, boot, hourly, daily, weekly, interval, and calendar — with --hour, --minute, --weekday, and --interval-minutes flags to tune them. Omit the timing flags and summond deterministically seeds them from the job spec to spread load.
daily and weekly schedules also accept a window, which constrains the seeded hour to a named time-of-day range instead of the full day:
| Window | Default hours |
|---|---|
morning | 05:00-11:59 |
afternoon | 12:00-16:59 |
evening | 17:00-21:59 |
[jobs.report]
command = "/usr/local/bin/weekly-report"
schedule = "weekly"
weekday = 1
window = "morning"
Override the default window bounds in summond.toml:
[windows]
morning = { start_hour = 6, end_hour = 10 }
Run a job when files change instead of on a schedule:
[jobs.on-config-change]
command = "/usr/local/bin/reload"
trigger = "on_change"
[jobs.on-config-change.watch]
paths = ["./config.json"]
throttle_seconds = 2 # debounce delay; defaults to 2
The flat keys watch_paths and throttle_interval_seconds are also accepted for backwards compatibility.
The job receives a SUMMOND_CHANGED_PATHS environment variable — a colon-separated list of entries from watch_paths that changed since the last successful run. Note that these are the paths you listed in watch_paths, so a watched directory appears as the directory itself, not the individual file that changed within it. On the first execution (no prior baseline), all watch_paths are included.
# shell_command example
for path in ${SUMMOND_CHANGED_PATHS//:/ }; do
reload "$path"
done
Run a job whenever a specific USB device is plugged in — for example, reapplying a webcam setting that doesn't persist across unplug/replug:
[jobs.brio-zoom]
shell_command = "uvc-util -I 0 -s zoom-abs=150"
trigger = "on_usb_attach"
[jobs.brio-zoom.usb]
vendor_id = "0x046d" # Logitech
product_id = "0x085e" # Brio
vendor_id and product_id identify the device and are both required. They can be hex strings ("0x046d"), TOML hex integers (0x046d), or decimal (1133). Find them with system_profiler SPUSBDataType, ioreg -p IOUSB -l, or uvc-util --list-devices.
Under the hood this renders a launchd LaunchEvents > com.apple.iokit.matching subscription, so launchd itself watches for the device — no summond process stays resident. The trigger fires whenever the device enumerates, including behind a hub or dock. Two things to know:
on_usb_detach: launchd only delivers IOKit matching (arrival) events, not termination events, and watching for removal would require a resident daemon. To react to an unmounted volume, watch its mount point with the on_change trigger instead.Like on_change, the trigger debounces via throttle_interval_seconds (default 2) in case the device enumerates more than once on attach.
summond tui
Opens a two-pane terminal UI. The left pane lists all jobs; the right pane shows details for the selected job across three tabs:
Keybindings:
| Key | Action |
|---|---|
↑ / ↓ or j / k | Navigate job list |
tab / shift+tab | Cycle tabs forward / backward |
| scroll wheel | Scroll right pane |
X | Run selected job immediately in the foreground |
r | Force refresh |
q / ctrl+c | Quit |
Shell commands and plist XML are syntax-highlighted if bat is installed, with graceful fallback to plain text.
summond list # all jobs with last-run status
summond logs cleanup # output from the last execution
summond logs cleanup -f # stream new output live
summond exec cleanup # run immediately in the foreground
summond kill cleanup # stop a running job and reconcile its state
summond restart cleanup # restart through launchd (useful for login jobs)
summond state cleanup # full execution history as JSON
Stdout and stderr go to stdout.log and stderr.log in the job's directory under jobs/ in the summond state directory. summond cd <job> takes you there.
summond rotates these logs itself. Before each run, a log larger than 5 MiB is copied to stdout.log.1 or stderr.log.1 (replacing the older copy) and then truncated. summond keeps one old copy per log. It truncates in place instead of renaming, so launchd keeps writing to the same file.
summond manages LaunchAgents for the current user. sudo summond manages system-wide LaunchDaemons, with its own state and env file under /Library/Application Support/summond.
Each config file targets one scope. Set target at the top of the file, before any [jobs] table. It applies to every job in the file and defaults to "agent":
target = "daemon"
[jobs.nightly-backup]
command = "/usr/local/bin/backup"
schedule = "daily"
hour = 2
Apply agent configs with summond apply and daemon configs with sudo summond apply. Applying a config for the other scope is an error, so a stray sudo never turns agent jobs into root daemons. Keep agent and daemon jobs in separate files; a target inside a job table is rejected. See summond.toml and summond.daemon.toml in this repo for examples.
Every command follows the same rule: summond add, list, env, logs, uninstall, and the rest act on agent jobs, and the same commands under sudo act on daemon jobs. For example, sudo summond add backup --schedule boot -- /usr/local/bin/backup adds a daemon job, and sudo summond list lists daemon jobs. No command switches scope or prompts for sudo on its own.
Under sudo, apply expands $VAR references against root's environment, so $HOME may not be your home directory. Use absolute paths in daemon configs.
See ADR 1 for the reasoning.
Jobs that exit non-zero can be automatically retried with exponential backoff:
[jobs.flaky-api-sync]
command = "/usr/local/bin/sync"
schedule = "hourly"
[jobs.flaky-api-sync.retry]
attempts = 3 # retries after the initial attempt
delay_seconds = 5 # wait before first retry (default 1)
max_delay_seconds = 60 # cap the doubling delay (0 = no cap)
Or via summond add:
summond add flaky-api-sync --schedule hourly \
--retry-attempts 3 \
--retry-delay-seconds 5 \
--retry-max-delay-seconds 60 \
-- /usr/local/bin/sync
Delay doubles between each retry (5 s → 10 s → 20 s … up to the cap). Only exit-code failures are retried; if the binary can't be launched at all, summond gives up immediately.
Each job receives a SUMMOND_ATTEMPT environment variable (0-indexed) so scripts can adapt their behavior on retries:
# shell_command example — skip expensive setup on retries
if [ "$SUMMOND_ATTEMPT" -eq 0 ]; then
do-expensive-preflight
fi
do-the-actual-work
Retry annotations ([summond] retry attempt N/M after Xs) are written to the job's stderr log between attempts.
By default, a job's next scheduled trigger is skipped while a previous run of that job is still in progress:
[jobs.long-running-sync]
command = "/usr/local/bin/sync"
schedule = "interval"
interval_minutes = 5
If sync takes longer than 5 minutes, the overlapping trigger is skipped (logged to stderr) rather than starting a second instance. Set concurrency = true to allow overlapping runs instead:
[jobs.long-running-sync]
command = "/usr/local/bin/sync"
schedule = "interval"
interval_minutes = 5
concurrency = true
Or via summond add --concurrency.
summond env set API_KEY=secret # injected into every agent job at runtime
sudo summond env set API_KEY=secret # injected into every daemon job at runtime
Each scope has its own env file, env.json in its state directory, written with mode 0600. Per-job [jobs.name.env] values are merged on top.