Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

loopctl

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.

Quickstart (fresh machine)

git clone <repo-url> && cd loopctl
./setup.sh                  # add --link-harness if codex lives under nvm

Then 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)

Install

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 launchd

What it does:

  • verifies uv is installed (prints the platform-appropriate install command and exits if not)
  • symlinks loopctl into /opt/homebrew/bin (macOS, when present) or ~/.local/bin
  • smoke-tests loopctl --help (exercises uv resolving the env on first run)
  • checks that codex/claude are findable on the scheduler's bare PATH, with the fix if not
  • prints next steps (LOOPS_ROOT, register, check, apply)

Commands

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.

Manifest schema (<LOOPS_ROOT>/<name>/manifest.yaml)

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).

Registry

~/.loopctl/registry.yaml is a global registry of loop roots so loopctl list --global can see loops across multiple roots:

roots:
  - /Users/you/loops

Pointers 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.

Portability

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.

Scheduling

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:

  • watch trigger → a watch job fires loopctl watch <name> every interval; if digest is set, a digest job fires loopctl run <name> --trigger digest on that schedule
  • cron trigger → a run job fires loopctl run <name> --trigger cron on schedule (daily) or every interval (e.g. 5m) — exactly one of the two. An interval cron wakes a full LLM session every fire; set max_sessions_per_day when you need a daily cap. (Re-run loopctl apply on existing cron loops to regenerate their jobs with the truthful --trigger cron label.)
  • cron exprs: only daily M H * * * is parsed — use interval or hand-write the plist/unit for anything fancier
  • manual/continuous → nothing to schedule; these run by hand (apply reports ok (no scheduling needed)). For continuous loops, loopctl run is 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.

launchd (macOS)

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>.watch

launchd 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.

systemd user timers (Linux)

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 logout

The 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.

Not built yet

  • Full cron syntax for schedule — only daily M H * * * parses; */N, weekday (0 9 * * 1-5), monthly, and multiple-times-a-day expressions are rejected. Sub-daily cadence is covered by cron interval; 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's status by hand for now
  • loopctl page — escalation/paging channel; non-interrupting asks land in each loop's REVIEW.md for now
  • loopctl review — aggregated daily view across loop-local REVIEW.md files
  • Interactive init — a codex session that grills the loop definition until loopctl check passes
  • continuous loops have no scheduler wiring — loopctl run is 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.

Known MVP tradeoffs

  • 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.

About

Tool to manage AI agent loops that run from plain folders, keep file-based memory, and schedule themselves with hard budgets, ledgers, and circuit breakers.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages