This document explains Looper's canonical config taxonomy, default config location, supported file formats, project override rules, and the legacy-to-canonical migration story.
For the default supported macOS install flow:
looperis installed from a GitHub Release Go binarylooper daemon installinstalls the managed daemon binary to~/.looper/bin/looperdlooper daemon startwrites its pid file to~/.looper/looperd.pidlooper daemon startwrites lifecycle diagnostics to~/.looper/looperd.state.json- when webhook mode is enabled,
looperdholds~/.looper/looperd.lock(beside the SQLite DB path) to prevent two local daemons from racing on the same webhook forwarders
The daemon lookup order used by the CLI is ~/.looper/bin/looperd, then $PATH.
Keep the runtime directory (~/.looper by default, or the directory containing storage.dbPath) on a local filesystem. The webhook forwarder lock uses OS file locking and is not designed for NFS-style shared filesystems. Tunnel-mode webhook secrets live under the same runtime directory in secrets/ and must be mode 0600.
Looper has two project-level network modes:
projects[].network.mode = "off"β local-only operation.looper:target:*labels are ignored and the classic single-Node assignee/review-request behavior stays unchanged.projects[].network.mode = "routed"β multi-Node operation coordinated throughloopernet.
Authority stays split on purpose:
- GitHub work intent stays on GitHub:
looper:worker-readyfor Worker and GitHub review requests for Reviewer. - exactly one
looper:target:<node_name>label is the exact-Node authority in Routed mode. - the
loopernetlease is a mutation fence for Coordinator only; it does not become the source of truth for work intent.
Operational notes:
loopernetcentralizes webhook ingress and Node wakeups, but it must not mutate GitHub on its own.- Coordinator writes coarse GitHub authority first, then writes the exact target label last.
- polling remains enabled as fallback and drift recovery when webhook delivery or SSE wakeups are missed.
- if you use
looper network joinwithout--no-enroll-projects, Looper rejects enrollment when Planner or Fixer auto-discovery is still enabled for those projects; disable those settings first or opt projects into Routed mode manually.
The formal contract is documented in ADRs 0007 through 0011.
For runtime deployment details β container image, required environment variables, persistence, and the current single-instance recommendation β see loopernet deployment.
webhook.enabled=true supports two delivery modes:
gh-forward(default): Looper startsgh webhook forwardagainst each configured repo and receives deliveries on the daemon API route/webhook/forward.tunnel: Looper creates an ordinary GitHub repository webhook per repo and expects the user to run a tunnel to127.0.0.1:<webhook.listenPort>.
Tunnel-mode example:
[webhook]
enabled = true
mode = "tunnel"
listenPort = 8765
publicBaseUrl = "https://looper.example.com"
fallbackPollIntervalSeconds = 300
[[projects]]
id = "looper"
name = "looper"
repoPath = "/Users/me/src/looper"
[[projects]]
id = "private"
name = "private"
repoPath = "/Users/me/src/private"
[projects.webhook]
mode = "gh-forward"Rules:
webhook.modeis the global default. A project may override withprojects[].webhook.mode.tunnelrequireswebhook.listenPortbetween1024and65535and an HTTPSwebhook.publicBaseUrl.- The tunnel URL for repo
owner/repois{publicBaseUrl}/webhook/owner/repo. - Looper binds only
127.0.0.1:<listenPort>; it does not run or supervisecloudflared,ngrok, Tailscale Funnel, or any reverse proxy. - Looper stores the remote GitHub hook id in SQLite and the HMAC secret in
secrets/webhook_<owner>_<repo>.keywith mode0600. - Removing a project or switching it away from
tunnelmarks the local hook record orphaned; it does not delete the GitHub hook automatically.
looperd loads configuration in this order:
- built-in defaults
- config file
- environment variables
- CLI flags
Later layers override earlier ones. Objects are merged deeply, arrays are replaced as a whole, and omitted fields keep the previous-layer value.
Looper accepts config files in these formats:
.toml.yaml.yml.json
Canonical default path:
~/.looper/config.toml
Config source selection precedence is:
--configLOOPER_CONFIG- default-path discovery
Default-path discovery checks, in order:
~/.looper/config.toml~/.looper/config.yaml~/.looper/config.yml~/.looper/config.json
Behavior:
- if exactly one supported default config file exists, Looper loads it
- if both
~/.looper/config.tomland legacy~/.looper/config.jsonexist, Looper prefersconfig.toml - any other multiple-default-file combination fails clearly instead of guessing
- if none exist, Looper continues with built-in defaults and treats
~/.looper/config.tomlas the canonical path for newly generated config
To migrate the legacy default JSON config explicitly, run:
looper config migrateUseful migration flags:
--from <path>to read a non-default source config file--to <path>to write somewhere other than the default canonical TOML path--dry-runto preview the canonical output without touching user files--forceto overwrite an existing destination after creating a backup
Custom config path examples:
LOOPER_CONFIG=/absolute/or/relative/path/to/config.tomllooperd --config /absolute/or/relative/path/to/config.toml
Relative config paths are resolved from the current working directory used to start looperd.
Looper's frozen canonical top-level config roots are:
| Root | Purpose |
|---|---|
server |
network-facing API/server configuration |
daemon |
daemon lifecycle, runtime paths, and local process behavior |
storage |
sqlite/database/backups/history retention and storage-specific settings |
scheduler |
loop scheduling, concurrency, polling, and timing policy that is not role-specific |
agent |
model/provider/executor defaults that apply across roles unless overridden more locally |
logging |
logs, verbosity, sinks, and diagnostic controls |
notifications |
user notifications such as osascript or future notifier integrations |
disclosure |
disclosure/stamping policy for outward-facing automation output |
tools |
external tool paths and tool-specific execution settings such as git, gh, and osascript |
package |
packaging, upgrade, and distribution policy |
defaults |
user-facing default policy that does not belong to a narrower domain |
instructions |
global instruction-system settings that are not role-specific instruction content |
roles |
role-specific config grouped by role name, for example roles.<role> |
projects |
per-project metadata and supported project-scoped overrides |
Legacy top-level reviewer.* input is compatibility-only. The canonical reviewer behavior home is roles.reviewer.behavior.*.
Schema migration is independent from config-file format migration: precedence stays defaults β config file β environment variables β CLI flags regardless of whether a file still uses legacy reviewer paths or legacy JSON defaults.
looper config migrate is the only product-supported file-writing migration path. Normal CLI and daemon startup never rewrite config files implicitly.
In the simplest setup, you can rely on defaults and only create a config file when you need to customize behavior.
agent.vendor does not have a built-in default. If you want planner / reviewer / fixer / worker loops to run, set it explicitly.
Example minimal ~/.looper/config.toml:
[agent]
vendor = "opencode"
[[projects]]
id = "looper"
name = "Looper"
repoPath = "/absolute/path/to/repo"All role-specific config lives under roles.<role>.
- shared role instructions live at
roles.<role>.instructions - discovery policy lives at
roles.<role>.discovery.* - runtime behavior lives at
roles.<role>.behavior.*when that split is useful for the role
Coordinator is the proactive, stateless issue-intake role. It owns both Triage and Dispatch. Triage writes triaged plus the coordinator-owned label namespace. Dispatch consumes triaged + dispatch/* and derives the actual trigger label from Planner or Worker config instead of redeclaring those labels.
Coordinator triage lives under roles.coordinator.triage.*:
| Path | Purpose | Default |
|---|---|---|
roles.coordinator.enabled |
Turns Coordinator on for the project or globally | false |
roles.coordinator.pollInterval |
Minimum delay between Coordinator ticks for the same project | "5m" |
roles.coordinator.triage.triagedLabel |
Durability-commit label written last after comment posting succeeds | "triaged" |
roles.coordinator.triage.maxIssueAgeDays |
Bootstrap guard for fresh issues only | 7 |
roles.coordinator.triage.maxPerTick |
Per-tick cap on issues processed for triage | 5 |
roles.coordinator.triage.disposition.outOfScopeLabel |
Label reused for out-of-scope |
"wontfix" |
roles.coordinator.triage.disposition.unclearLabel |
Label used for unclear |
"needs-info" |
roles.coordinator.triage.disposition.reTriageOnAuthorReply |
Re-opens the triage loop when the original author clarifies a needs-info issue |
true |
Coordinator clears and rewrites its own label namespace on each successful triage pass: kind/*, area/*, complexity/*, dispatch/*, wontfix, and needs-info. It then posts or edits the marker comment and writes triaged last.
Coordinator dispatch lives under roles.coordinator.dispatch.*:
| Path | Purpose | Default |
|---|---|---|
roles.coordinator.dispatch.mode |
Chooses human-gated or autonomous dispatch |
"human-gated" |
roles.coordinator.dispatch.assignTo |
Optional GitHub assignee added before the trigger label commit | "" |
roles.coordinator.dispatch.humanGate.slashCommands |
Accepted start-of-line slash commands | ["/plan", "/implement"] |
roles.coordinator.dispatch.humanGate.allowedUsers |
Extra users allowed to dispatch even without repo write access | [] |
roles.coordinator.dispatch.autonomous.delayMinutes |
Grace window after triaged before autonomous dispatch can commit |
30 |
roles.coordinator.dispatch.autonomous.holdLabel |
Global hold / veto label for autonomous dispatch | "looper:hold" |
Behavior notes:
/planmaps to the first planner trigger label atroles.planner.triggers.labels[0]/implementmaps to the first worker trigger label atroles.worker.triggers.labels[0]- autonomous mode uses the existing
dispatch/*label to choose the same derived trigger labels - Coordinator never stores its own dispatch state; the authority chain stays on GitHub labels, comments, and timeline events
roles.coordinator.dispatch.autonomous.holdLabelis also a veto signal, alongside removingdispatch/*or manually applying the destination trigger label
Coordinator example:
[roles.coordinator]
enabled = true
pollInterval = "5m"
[roles.coordinator.triage]
triagedLabel = "triaged"
maxIssueAgeDays = 7
maxPerTick = 5
[roles.coordinator.triage.disposition]
outOfScopeLabel = "wontfix"
unclearLabel = "needs-info"
reTriageOnAuthorReply = true
[roles.coordinator.dispatch]
mode = "human-gated"
assignTo = ""
[roles.coordinator.dispatch.humanGate]
slashCommands = ["/plan", "/implement"]
allowedUsers = []
[roles.coordinator.dispatch.autonomous]
delayMinutes = 30
holdLabel = "looper:hold"Reviewer is the main migration example:
- legacy top-level
reviewer.*is compatibility input only - legacy reviewer discovery paths such as
roles.reviewer.autoDiscovery,roles.reviewer.triggers.*, androles.reviewer.specReview.*are compatibility input only - canonical reviewer discovery lives at
roles.reviewer.discovery.* - canonical reviewer behavior lives at
roles.reviewer.behavior.*
Canonical reviewer example:
This is a standalone reviewer-only snippet. Do not paste it together with the full config example below as a single TOML file, or table headers such as [roles.reviewer.behavior.reviewEvents] would be duplicated.
[roles.reviewer]
instructions = "Review for correctness, regressions, and migration safety."
[roles.reviewer.discovery]
autoDiscovery = true
[roles.reviewer.discovery.triggers]
includeDrafts = false
requireReviewRequest = true
enableSelfReview = false
labels = []
labelMode = "all"
[roles.reviewer.discovery.specReview]
includeReviewingLabel = true
reviewingLabel = "looper:spec-reviewing"
[roles.reviewer.behavior]
scope = "changed_ranges"
publishMode = "single_review"
[roles.reviewer.behavior.loop]
enabledByDefault = true
quietPeriodSeconds = 60
minPublishIntervalSeconds = 300
[roles.reviewer.behavior.reviewEvents]
clean = "APPROVE"
blocking = "REQUEST_CHANGES"
[roles.reviewer.behavior.nativeResume]
onHeadChange = false
reReviewPromptOnHeadChange = falseThe reviewer defaults above are intentionally aggressive: clean reviews publish APPROVE, blocking reviews publish REQUEST_CHANGES, and enableSelfReview still defaults to false.
Reviewer auto-merge lives under roles.reviewer.autoMerge.*:
| Path | Purpose | Default | Valid values | Validation |
|---|---|---|---|---|
roles.reviewer.autoMerge.enabled |
Enables Reviewer's auto-merge opt-in flow for in-scope code PRs | false |
true, false |
When true, project startup fails fast unless the repo allows auto-merge, the configured merge strategy is enabled in repo settings, the repo is known, and GitHub validation is configured |
roles.reviewer.autoMerge.strategy |
Merge strategy passed to gh pr merge --auto |
"squash" |
"squash", "merge", "rebase" |
Config validation rejects any other value; when enabled=true, startup also fails fast if the repo disallows the chosen strategy |
roles.reviewer.autoMerge.requireBranchProtection |
Requires base-branch protection with required checks before Reviewer opts in | true |
true, false |
When true and enabled=true, startup fails fast unless the default/base branch is known and GitHub reports branch protection with required checks |
roles.reviewer.autoMerge.transientRetries |
Retry budget for transient merge-watch failures | 3 |
positive integers | Config validation rejects values less than 1 |
roles.reviewer.autoMerge.scope |
v1 scope guard for which PRs Looper may opt into auto-merge | "looper-only" |
"looper-only" |
Config validation rejects any other value; startup validation also rejects unsupported scopes |
Project-level overrides use the same shape under projects[].roles.reviewer.autoMerge.*.
When roles.reviewer.autoMerge.enabled = true, Looper performs a repo-aware startup validation pass: the project must have a known GitHub repo, GitHub auto-merge must be enabled for that repo, the configured strategy must be allowed, and β if requireBranchProtection=true β the effective base branch must exist with required checks enabled.
Project entries stay in projects[], but any override-bearing config must mirror the same local shape it uses globally.
Project entries are split into:
- project metadata:
id,name,repoPath,baseBranch,worktreeRoot - project-scoped override config: canonical override-bearing domains such as
roles.<role>... - project-local role instructions:
projects[].roles.<role>.instructions
Project override rules:
- if a field is overrideable per project, the project path uses the same local canonical shape as the global path
- project overrides remain part of the config-file layer; they do not create a new precedence layer above environment variables or CLI flags
- omitted project fields inherit the effective global value
- project-local role instructions may be set to an empty string to clear inherited global role instructions for that project
- legacy project reviewer discovery paths are compatibility-only; canonical reviewer project overrides live under
projects[].roles.reviewer.discovery.*
Canonical project override example:
[[projects]]
id = "looper"
name = "Looper"
repoPath = "/absolute/path/to/looper"
baseBranch = "main"
worktreeRoot = "/Users/you/.looper/worktrees/looper"
[projects.roles.worker.discovery]
autoDiscovery = false
[projects.roles.reviewer]
instructions = "Project-specific reviewer guidance"
[projects.roles.reviewer.discovery.triggers]
labels = ["needs-review"]
labelMode = "any"
requireReviewRequest = false[server]
host = "127.0.0.1"
port = 17310
authMode = "local-token"
localToken = "replace-me"
[daemon]
mode = "foreground"
restartPolicy = "on-failure"
restartThrottleSeconds = 10
logDir = "/Users/you/.looper/logs"
workingDirectory = "/absolute/path/to/where/you/start/looperd"
shutdownTimeoutMs = 1000
[daemon.worktreeCleanup]
enabled = false
interval = "24h"
retentionDays = 7
maxPerTick = 10
includeOrphans = false
dryRun = true
[daemon.environment]
EXAMPLE_FLAG = "1"
[storage]
mode = "sqlite"
dbPath = "/Users/you/.looper/looper.sqlite"
backupDir = "/Users/you/.looper/backups"
[scheduler]
pollIntervalSeconds = 30
maxConcurrentRuns = 3
retryMaxAttempts = 5
retryBaseDelayMs = 5000
[agent]
vendor = "opencode"
model = "your-model-if-needed"
[agent.params]
reasoning = "medium"
[agent.env]
OPENAI_API_KEY = "replace-me"
[agent.nativeResume]
enabled = true
[agent.timeouts]
plannerSeconds = 1800
workerSeconds = 3600
reviewerSeconds = 1800
fixerSeconds = 1800
[logging]
level = "info"
maxSizeMB = 10
maxFiles = 5
[notifications]
inApp = true
[notifications.osascript]
enabled = true
soundForLevels = ["action_required", "failure"]
throttleWindowSeconds = 60
[disclosure]
enabled = true
includeAgent = true
includeOS = false
[disclosure.channels]
gitCommit = true
pullRequest = true
issueComment = true
reviewComment = true
inlineCommentVisible = true
[tools]
gitPath = "/usr/bin/git"
ghPath = "/opt/homebrew/bin/gh"
osascriptPath = "/usr/bin/osascript"
[package]
distribution = "github-release"
autoMigrateOnStartup = true
requireBackupBeforeMigrate = false
[defaults]
baseBranch = "main"
allowAutoCommit = true
allowAutoPush = true
allowAutoApprove = true
allowAutoMerge = false
allowRiskyFixes = false
openPrStrategy = "all_done"
addSnapshotMode = "async"
# `allowAutoApprove` is a legacy compatibility alias.
# Prefer `roles.reviewer.behavior.reviewEvents.clean = "APPROVE"` in new config.
[roles.coordinator]
enabled = false
pollInterval = "5m"
[roles.coordinator.triage]
triagedLabel = "triaged"
maxIssueAgeDays = 7
maxPerTick = 5
[roles.coordinator.triage.disposition]
outOfScopeLabel = "wontfix"
unclearLabel = "needs-info"
reTriageOnAuthorReply = true
[roles.coordinator.dispatch]
mode = "human-gated"
assignTo = ""
[roles.coordinator.dispatch.humanGate]
slashCommands = ["/plan", "/implement"]
allowedUsers = []
[roles.coordinator.dispatch.autonomous]
delayMinutes = 30
holdLabel = "looper:hold"
[roles.planner.discovery]
autoDiscovery = true
[roles.planner.triggers]
labels = ["looper:plan"]
labelMode = "all"
requireAssigneeCurrentUser = true
[roles.reviewer]
instructions = "Review for correctness, regressions, and migration safety."
[roles.reviewer.discovery]
autoDiscovery = true
[roles.reviewer.discovery.triggers]
includeDrafts = false
requireReviewRequest = true
enableSelfReview = false
labels = []
labelMode = "all"
[roles.reviewer.discovery.specReview]
includeReviewingLabel = true
reviewingLabel = "looper:spec-reviewing"
[roles.reviewer.behavior]
scope = "changed_ranges"
publishMode = "single_review"
[roles.reviewer.behavior.loop]
enabledByDefault = true
quietPeriodSeconds = 60
minPublishIntervalSeconds = 300
[roles.reviewer.behavior.reviewEvents]
clean = "APPROVE"
blocking = "REQUEST_CHANGES"
[roles.reviewer.behavior.nativeResume]
onHeadChange = false
reReviewPromptOnHeadChange = false
[roles.reviewer.autoMerge]
enabled = false
strategy = "squash"
requireBranchProtection = true
transientRetries = 3
scope = "looper-only"
[roles.fixer.discovery]
autoDiscovery = true
[roles.fixer.discovery.triggers]
includeDrafts = false
authorFilter = "current_user"
labels = []
labelMode = "all"
[roles.worker.discovery]
autoDiscovery = true
[roles.worker.triggers]
labels = ["looper:worker-ready"]
labelMode = "all"
requireAssigneeCurrentUser = true
[[projects]]
id = "looper"
name = "Looper"
repoPath = "/absolute/path/to/looper"
baseBranch = "main"
worktreeRoot = "/Users/you/.looper/worktrees/looper"
[projects.roles.worker.discovery]
autoDiscovery = false
[projects.roles.reviewer]
instructions = "Project-specific reviewer guidance"
[projects.roles.reviewer.discovery.triggers]
labels = ["team:alpha", "needs-review"]
labelMode = "any"
requireReviewRequest = falseThis refactor is a warning-only migration release.
- Looper does not add
looper config migratein this change set. - Looper does not rewrite, rename, convert, or delete user config files during startup.
- Loading legacy
~/.looper/config.jsonemits one informational note per process telling users that~/.looper/config.tomlis now the preferred default path. - Accepted legacy config paths, legacy environment variable names, and legacy CLI flags still load during this release, but they emit actionable replacement guidance.
Deprecated legacy JSON:
{
"reviewer": {
"scope": "changed_files",
"publishMode": "single_review",
"reviewEvents": {
"clean": "APPROVE",
"blocking": "REQUEST_CHANGES"
}
},
"roles": {
"reviewer": {
"autoDiscovery": true,
"triggers": {
"requireReviewRequest": true
},
"specReview": {
"reviewingLabel": "looper:spec-reviewing"
},
"instructions": "Review carefully."
}
}
}Canonical replacement:
[roles.reviewer]
instructions = "Review carefully."
[roles.reviewer.discovery]
autoDiscovery = true
[roles.reviewer.discovery.triggers]
requireReviewRequest = true
[roles.reviewer.discovery.specReview]
reviewingLabel = "looper:spec-reviewing"
[roles.reviewer.behavior]
scope = "changed_files"
publishMode = "single_review"
[roles.reviewer.behavior.reviewEvents]
clean = "APPROVE"
blocking = "REQUEST_CHANGES"Deprecated legacy JSON:
{
"projects": [
{
"id": "looper",
"name": "Looper",
"repoPath": "/absolute/path/to/looper",
"roles": {
"reviewer": {
"autoDiscovery": true,
"triggers": {
"labels": ["needs-review"]
}
}
}
}
]
}Canonical replacement:
[[projects]]
id = "looper"
name = "Looper"
repoPath = "/absolute/path/to/looper"
[projects.roles.reviewer.discovery]
autoDiscovery = true
[projects.roles.reviewer.discovery.triggers]
labels = ["needs-review"]{
"reviewer": {
"reviewEvents": {
"clean": "APPROVE",
"blocking": "REQUEST_CHANGES"
}
}
}Reviewer behavior matrix:
| Reviewer outcome | reviewEvents.clean |
reviewEvents.blocking |
GitHub event |
|---|---|---|---|
clean |
COMMENT |
any | COMMENT |
clean |
APPROVE |
any | APPROVE |
non_blocking |
any | any | COMMENT |
blocking |
any | COMMENT |
COMMENT |
blocking |
any | REQUEST_CHANGES |
REQUEST_CHANGES |
legacy actionable |
any | any | COMMENT |
One-off reviewer jobs can snapshot the policy into loop metadata so queued work is not affected by later daemon config changes:
looper review owner/repo#123 \
--clean-review-event APPROVE \
--blocking-review-event REQUEST_CHANGESTo restore the previous synchronous project add behavior for one command:
looper project add --snapshot-mode full /absolute/path/to/repoTo restore it by default for all project additions:
{
"defaults": {
"addSnapshotMode": "full"
}
}The roles section controls scheduler-driven auto-discovery for planner, reviewer, fixer, and worker. It does not block manual commands, direct processing, retries, or already queued work.
Defaults preserve Looper's historical behavior:
- planner discovers open issues labeled
looper:planassigned to the current GitHub user - worker discovers open issues labeled
looper:worker-readyassigned to the current GitHub user - reviewer discovers open non-draft PRs where the current user is requested for review, skips self-authored PRs by default, and includes the
looper:spec-reviewingfollow-up path - fixer discovers open non-draft PRs authored by the current user that have actionable review items
Common fields:
roles.<role>.autoDiscovery: whenfalse, the scheduler skips new discovery for that role only- issue roles (
planner,worker):triggers.labels,triggers.labelMode(allorany), andtriggers.requireAssigneeCurrentUser - reviewer:
triggers.includeDrafts,triggers.requireReviewRequest,triggers.enableSelfReview,triggers.labels,triggers.labelMode,specReview.includeReviewingLabel,specReview.reviewingLabel - fixer:
triggers.includeDrafts,triggers.authorFilter(current_userorany),triggers.labels,triggers.labelMode
Trigger fields are combined with logical AND. Label lists use labelMode=all or labelMode=any; an empty labels list means no label constraint.
When reviewer triggers.requireReviewRequest=true and no reviewer label filter is configured, discovery queries GitHub directly for PRs review-requested from the current GitHub user. This avoids missing requested reviews that fall outside the generic open-PR discovery window. Reviewer label filters keep using the labeled open-PR query path and are still applied before queuing.
For reviewer discovery, triggers.enableSelfReview defaults to false. When omitted or falsy, non-manual reviewer loops skip pull requests whose normalized PR author login matches the current authenticated GitHub login. Set it to true to allow those loops to review self-authored PRs.
Canonical environment variables and CLI flags override the config-file layer. Legacy names remain accepted only as compatibility aliases during the migration window.
Examples:
LOOPER_CONFIG="$HOME/custom-looper/config.toml" \
LOOPER_PORT=4321 \
LOOPER_ROLES_REVIEWER_DISCOVERY_TRIGGERS_ENABLE_SELF_REVIEW=true \
looperdlooperd \
--config "$HOME/custom-looper/config.toml" \
--port 4321 \
--roles-reviewer-discovery-triggers-enable-self-review=truelooperd fails fast on invalid config. Common validation rules:
- required strings must be non-empty
- numeric fields must be positive integers where applicable
server.portmust be between1and65535scheduler.pollIntervalSecondsmust be at least10authMode=local-tokenrequiresserver.localTokenprojects[].idmust be valid and uniquestorage.dbPathparent directory must be writabledaemon.logDirmust be writabledaemon.workingDirectorymust be writable- the default worktree root must be writable
- required tool paths must resolve
notifications.osascript.enabled=truerequirestools.osascriptPathto resolve
- Install
gitandgh - Create
~/.looper/config.toml - Add at least one project in
projects - Set
agent.vendor - Start the daemon with your installed
looperd(orgo run ./cmd/looperdwhile developing) - Run
looper config showto inspect the effective config
If you enable server.authMode=local-token, also export LOOPER_TOKEN before using the CLI.
Set explicit paths in the config file, or make sure the binaries are on PATH for the environment that starts looperd.
Either:
- install or expose
osascript, or - disable macOS notifications with:
[notifications.osascript]
enabled = falseMake sure the daemon user can write to:
- the parent directory of
storage.dbPath daemon.logDirdaemon.workingDirectory- the default worktree root under
~/.looper/worktrees
Looper records worktrees it creates for planner, reviewer, fixer, and worker loops. The daemon periodically inspects those Looper-managed records and removes only clean worktree checkouts that are no longer referenced by active loop state.
Defaults:
daemon.worktreeCleanup.enabled = truedaemon.worktreeCleanup.interval = "24h"daemon.worktreeCleanup.retentionDays = 7daemon.worktreeCleanup.maxPerTick = 10daemon.worktreeCleanup.includeOrphans = falsedaemon.worktreeCleanup.dryRun = false
To disable automatic cleanup:
[daemon.worktreeCleanup]
enabled = falseTo keep automatic inspection enabled without deleting anything:
[daemon.worktreeCleanup]
enabled = true
dryRun = trueManual inspection is always dry-run by default:
looper worktree cleanup
looper worktree cleanup --dry-runRun one immediate cleanup pass with the same safety rules:
looper worktree cleanup --confirm
looper worktree cleanup --jsonCleanup removes Looper-managed worktree checkouts only. It does not delete branches, skips dirty worktrees, preserves worktrees referenced by active loop state, and does not automatically delete filesystem-only orphan directories that are not present in Looper's worktree records.