Engine for loops — recurring autonomous LLM agent sessions. A loop is a folder of files (manifest, prompt, state, changelog); the agent is disposable, the folder is the memory. loopctl validates loops, assembles session prompts, runs them under a harness with hard budgets, keeps a run ledger, and trips a circuit breaker when a loop misbehaves.
This repo is the engine only. Loop data lives in a loops root — a plain directory of loop folders (one subfolder per loop). Resolution order: the LOOPS_ROOT env var if set; else the registry's single root if exactly one is registered; else ~/loops. Each loop owns its operator queue at <LOOPS_ROOT>/<name>/REVIEW.md.
git clone <repo-url> && cd loopctl
./setup.sh # add --link-harness if codex lives under nvmThen install and authenticate a harness. setup.sh deliberately does not install or authenticate harnesses or credentials — those are identity, not tooling. For codex: npm i -g @openai/codex then codex login. For claude-code likewise (install the CLI, then log in). SSH keys, VPN access, and keychain entries are per-loop operational needs you set up yourself.
loopctl register /path/to/loops # add your loops root (or `loopctl init <name>` to scaffold a first loop)
loopctl check # validate every loop
loopctl apply # reconcile the scheduler to the manifests (launchd/systemd)Requires uv (the script is self-contained via inline metadata).
./setup.sh # idempotent; safe to re-run
./setup.sh --link-harness # also symlink node+codex into the bin dir for launchdWhat it does:
- verifies
uvis installed (prints the platform-appropriate install command and exits if not) - symlinks
loopctlinto/opt/homebrew/bin(macOS, when present) or~/.local/bin - smoke-tests
loopctl --help(exercises uv resolving the env on first run) - checks that
codex/claudeare findable on the scheduler's bare PATH, with the fix if not - prints next steps (
LOOPS_ROOT,register,check,apply)
| Command | What it does |
|---|---|
loopctl init <name> |
Scaffold <LOOPS_ROOT>/<name>/ from templates/ (manifest, PROMPT, AGENTS, STATE, CHANGELOG, REVIEW, sessions/, checks/). Refuses if the folder exists. Then handcraft manifest.yaml and PROMPT.md. |
loopctl check [name] |
Validate one loop (or all): YAML parses, required fields, enums, name/folder match, name globally unique across all registered roots (scheduler labels share one namespace — a same-named loop in another root fails with both paths), trigger consistency, watch script executable, PROMPT.md non-trivial, budgets positive, no TODOs left. By-name lookup spans all roots. Exit 1 on any failure. |
loopctl run <name> [--trigger anomaly|cron|digest|manual] [--anomaly-file PATH] [--force] [--one-turn] |
Execute a session: assemble prompt (PROMPT.md + session context: trigger, autonomy, STATE.md, changelog tail, optional anomaly output), invoke the harness with a hard timeout, stream output to sessions/raw-<ts>.log as it arrives (live-tailable; also teed to stdout at session end), post-validate (summary written? changelog grew?), append to sessions/sessions.jsonl, trip the circuit breaker if warranted. Announces each session start with the raw log path. Refuses non-active loops (--force also allows draft) and runs past the configured daily cap, if any. Continuous loops burst: sessions chain back-to-back until one goes bad, trigger.max_turns is hit, the configured daily cap is reached, or the status leaves active (e.g. a session marks the loop done); --one-turn runs a single session instead. Bursts announce start (max_turns, sessions remaining under the daily cap, or unlimited), each turn's start/end, and the burst-end summary on stdout. |
loopctl watch <name> |
Run the loop's check script (60s timeout). Exit 0 → "healthy". Non-zero → wake an anomaly session with the check output injected. |
loopctl list [--global] |
One row per loop: name, type, status, autonomy, trigger kind, LAST TURN (any scheduled invocation, from turns.jsonl) and LAST LLM SESSION (from sessions.jsonl), Pacific time + ok/bad. --global lists loops across every registered root (plus the active one), grouped by root; missing roots get a warning line. |
loopctl status [name] |
Status block per loop (read-only; broken loops are reported, never fatal — only an unknown loop name errors): manifest facts (type/status/autonomy, trigger detail, harness/model), budgets vs ledger reality (sessions today vs cap, breaker streak vs circuit_breaker), last turn + last LLM session, last 3 turn notes, and an in-flight session heuristic — a raw-*.log newer than the newest sessions.jsonl entry is likely a session running right now (start, elapsed, raw log path, growing/stalled), or a stale raw log if it outlived the session budget. By-name lookup spans all roots. |
loopctl apply [name] [--backend launchd|systemd] [--dry-run] |
Declaratively reconcile the scheduler to the manifests. The manifest's status is declared state; apply makes launchd/systemd match it: active + watch/cron → jobs regenerated and (re)loaded (so trigger edits take effect); active + manual/continuous → nothing to schedule (these run by hand); draft/paused/done → jobs unloaded and removed. Registers the active root implicitly (idempotent, like init). With a name, the loop is located across all registered roots (names are globally unique; a dupe is a hard error). Bare apply reconciles every loop in every registered root and prunes orphans — scheduler jobs whose loop name matches no known loop. Invalid loops are reported and their scheduling left untouched (final exit 1). Backend auto-detected; --backend overrides, --dry-run previews. |
loopctl register [path] |
Add a loops root to the global registry (defaults to the active root). Idempotent. |
loopctl unregister <path> |
Remove a loops root from the registry. Exit 1 if not registered. |
A run is bad if exit code != 0, it timed out, or no session summary file was written. After circuit_breaker consecutive bad runs, the loop's status is rewritten to paused and a line is appended to that loop's REVIEW.md. The breaker only flips the manifest — scheduler jobs stay loaded but the runner refuses to fire non-active loops; the next loopctl apply reconciles them away.
Two ledgers per loop. sessions/sessions.jsonl records LLM sessions only (the circuit breaker reads it). sessions/turns.jsonl records every invocation of run/watch — watch ticks, digests, manual runs — via a wrapper at the dispatch layer, so all schedulers and hand-fired commands are recorded identically. Healthy watch turns carry the check script's summary line in note, giving digest sessions a metric history to trend over. trigger labels are truthful: scheduled cron fires record cron, burst sessions record continuous plus a 1-based burst_turn (in both ledgers — each burst turn gets its own turns.jsonl entry, followed by the dispatch-level burst-end summary entry), and only hand-fired bare loopctl run records manual.
name: my-loop # must match the folder name
type: maintenance # maintenance | build | investigate | plan
description: One sentence — what outcome this loop owns.
status: draft # draft | active | paused | done
autonomy: observe # observe | propose | act | autonomous
trigger:
kind: watch # watch | cron | continuous | manual
check: checks/health.sh # watch: script rel. to loop dir, exit 0 = healthy
interval: 15m # watch: how often launchd fires `loopctl watch`
digest: "0 7 * * *" # watch, optional: healthy-path digest cron
# schedule: "0 7 * * *" # cron: when to fire `loopctl run` (daily)
# interval: 5m # cron alternative: fire `loopctl run` every N (schedule XOR interval)
# max_turns: 10 # continuous: max chained sessions per `loopctl run` burst
harness: codex # codex | claude-code
model: gpt-5.5
repos: [] # optional
budgets:
max_minutes_per_session: 15 # hard subprocess timeout
# max_sessions_per_day: 2 # optional; omit or set null for unlimited
circuit_breaker: 2 # consecutive bad runs -> auto-pause
verification: |
How this loop's work is checked before it counts as done.
escalation: |
When to interrupt the operator instead of waiting for morning review.
exit_criteria: |
When the loop marks itself done. Maintenance: none (runs until paused).~/.loopctl/registry.yaml is a global registry of loop roots so loopctl list --global can see loops across multiple roots:
roots:
- /Users/you/loopsPointers only. The registry stores loop-root directories and nothing else — no status, no name caches, no loop state. Manifests remain the single source of truth; the registry only says where to look. loopctl init auto-registers the active root.
The engine is plain Python (via uv) and runs on macOS and Linux. Scheduling is the only platform-specific part: two backends, launchd (user LaunchAgents) on macOS and systemd user timers on Linux, auto-detected from the platform — --backend launchd|systemd overrides (handy with --dry-run to preview the other platform's output). Job logs: launchd writes <loop>/launchd.log; systemd jobs go to journald (journalctl --user -u loopctl-<name>-watch.service). On headless Linux servers, run loginctl enable-linger $USER once so user units run without a login session. Loop-level portability is a separate concern: individual check scripts may use BSD-vs-GNU tools (e.g. date -j is macOS-only), and secrets pulled from macOS Keychain need an env-file or secret-manager substitute on Linux.
Loops run unattended — no shell session needed. Scheduling is declarative: the manifest's status + trigger declare what should be scheduled, and loopctl apply reconciles the platform scheduler to match — darwin → launchd, linux → systemd user timers (override with --backend). Loop names are globally unique across roots because scheduler labels (com.loopctl.<name>.* / loopctl-<name>-*) live in one namespace.
Job mapping (same on both backends), for status: active loops:
watchtrigger → awatchjob firesloopctl watch <name>everyinterval; ifdigestis set, adigestjob firesloopctl run <name> --trigger digeston that schedulecrontrigger → arunjob firesloopctl run <name> --trigger crononschedule(daily) or everyinterval(e.g.5m) — exactly one of the two. An interval cron wakes a full LLM session every fire; setmax_sessions_per_daywhen you need a daily cap. (Re-runloopctl applyon existing cron loops to regenerate their jobs with the truthful--trigger cronlabel.)- cron exprs: only daily
M H * * *is parsed — useintervalor hand-write the plist/unit for anything fancier manual/continuous→ nothing to schedule; these run by hand (applyreportsok (no scheduling needed)). For continuous loops,loopctl runis itself the burst driver — see the command table.
Non-active loops (draft/paused/done) are unscheduled by apply, and bare loopctl apply prunes orphaned jobs left behind by deleted or renamed loops.
Jobs are com.loopctl.<name>.<job> plists in ~/Library/LaunchAgents/ (StartInterval for watch, StartCalendarInterval for digest/cron).
loopctl apply <name> # reconcile: write plists + launchctl load (re-apply after any manifest edit)
# kill switch: set `status: paused` in the manifest, then
loopctl apply <name> # unloads + removes the plists
# emergencies (bypass loopctl entirely):
launchctl unload ~/Library/LaunchAgents/com.loopctl.<name>.*.plist
# fire one immediately in the real launchd environment
launchctl kickstart gui/$(id -u)/com.loopctl.<name>.watchlaunchd runs jobs in a bare environment (no .zshrc, no nvm): the generated plists set PATH=/opt/homebrew/bin:..., and node + codex are symlinked into /opt/homebrew/bin for that reason (./setup.sh --link-harness does this). Jobs run in your user session, so Keychain, ~/.ssh, ~/.codex auth, and Tailscale all work. If the Mac is asleep at a fire time, launchd skips and runs at the next interval after wake — no queue buildup, no overnight coverage.
Jobs are loopctl-<name>-<job>.{timer,service} units in ~/.config/systemd/user/. Watch timers use OnBootSec=2min + OnUnitActiveSec=<interval>; digest/cron timers use OnCalendar=*-*-* HH:MM:00 with Persistent=true (a missed fire runs after boot). Services set the same PATH/LOOPS_ROOT as the plists; stdout/stderr go to journald — no log file needed.
loopctl apply <name> # reconcile: write units + daemon-reload + enable --now
# kill switch: set `status: paused` + loopctl apply <name> (disables --now + deletes units)
journalctl --user -u loopctl-<name>-watch.service # job logs
systemctl --user start loopctl-<name>-watch.service # fire one immediately
loginctl enable-linger $USER # headless servers: run once, or timers stop at logoutThe manifest is the single source of scheduling truth — status and trigger are declared state. After editing either, re-run loopctl apply <name> (or bare loopctl apply) to reconcile the scheduler.
- Full cron syntax for
schedule— only dailyM H * * *parses;*/N, weekday (0 9 * * 1-5), monthly, and multiple-times-a-day expressions are rejected. Sub-daily cadence is covered by croninterval; anything calendar-shaped beyond daily means hand-writing the plist/unit. Fix would be a cron→(launchd StartCalendarInterval | systemd OnCalendar) translator for the common subset. loopctl pause/resume— edit the manifest'sstatusby hand for nowloopctl page— escalation/paging channel; non-interrupting asks land in each loop'sREVIEW.mdfor nowloopctl review— aggregated daily view across loop-localREVIEW.mdfiles- Interactive
init— a codex session that grills the loop definition untilloopctl checkpasses continuousloops have no scheduler wiring —loopctl runis the burst driver (chains sessions until bad/max_turns/cap/status change), but starting a burst is manual; scheduling bursts (e.g. nightly) means a cron-interval sibling job or a hand-written plist. Field note (2026-06-11): two production build loops shipped a whole project in hand-fired bursts — worked, but only because an operator was present to fire them; overnight utility needs this wiring.
- No sandbox enforcement of autonomy levels. Both harnesses run with permission checks bypassed (
codex exec --dangerously-bypass-approvals-and-sandbox,claude -p --dangerously-skip-permissions— mirror of the Ralph-loop convention). Autonomy is enforced by prompt + morning review only; trust = prompt + review. - Circuit breaker rewrites manifest.yaml via a YAML round-trip — comments in the manifest are lost when it trips.
- Post-validation is heuristic (a new file in
sessions/, changelog byte growth) — it can't tell a good summary from a bad one. - No pager/escalation channel yet; everything lands in each loop's
REVIEW.md. - The Mac must be awake for anything to run.