README
¶
Daedalus
A local, sandboxed AI developer agent control plane, written in Go and orchestrated via Temporal.
Daedalus turns an issue into working, reviewed, tested code. Given a repository, an issue ID, and a prompt, it spins up an isolated git worktree and runs two review-gated loops inside it: a jailed agent — Claude Code by default, opencode, Amp (deprecated — see below), pi, aider (deprecated — see below), or codex — implements the change while a jailed reviewer approves the code; the agent then writes the test suite while the reviewer — and the repository's own test suite — approve the tests. Each loop runs until its reviewer approves. When the test reviewer finds the work needs an implementation change rather than a test change — on its own finding or on the tester's report, relayed to it — it verdicts REBUILD: the finding goes back to the implementation loop with a tight, finding-only prompt, and the test loop (both sessions' context intact) resumes once the code reviewer approves again. Temporal provides durable execution: every step is auditable and survives worker restarts, and a run that dies (crash, cancellation) or parks itself — provider quota exhausted past its hourly heartbeats, or a reviewer halt on an impossible task — leaves its work preserved and resumable.
flowchart TD
PROMPT["issue"] --> CREATE["CreateWorktree"]
CREATE --> P1_IMPL
%% Phase 1 Nodes
P1_IMPL["<b> PHASE 1: IMPLEMENTATION </b><hr/>RunJailedClaude<br/><i>(implements, no tests yet)</i>"]
P1_REV["<b> PHASE 1: REVIEW </b><hr/>RunJailedReviewer<br/><i>(code)</i>"]
P1_IMPL --> P1_REV
P1_REV -- "review comments<br/>(until APPROVED)" --> P1_IMPL
P1_REV -- "APPROVED" --> P2_IMPL
%% Phase 2 Nodes
P2_IMPL["<b> PHASE 2: TESTS </b><hr/>RunJailedClaude<br/><i>(writes/fixes/improves tests)</i>"]
P2_TEST["<b> PHASE 2: SUITE </b><hr/>RunNativeTests"]
P2_REV["<b> PHASE 2: REVIEW </b><hr/>RunJailedReviewer<br/><i>(tests)</i>"]
P2_IMPL --> P2_TEST
P2_TEST --> P2_REV
P2_REV -- "test output + comments<br/>(until APPROVED & green)" --> P2_IMPL
%% REBUILD: the test reviewer can send an implementation-level finding
%% back through the dev cycle. After the code reviewer approves the
%% rebuild, flow resumes at the suite re-run — no fresh test-agent
%% round (the rebuild never touches test files); both sessions'
%% context stays intact.
P2_REV -- "REBUILD<br/>(implementation change needed)" --> P1_IMPL
P1_REV -- "APPROVED after REBUILD" --> P2_TEST
P2_REV -- "APPROVED & green" --> CLEAN
%% Final Nodes
CLEAN["<b> CLEANUP </b><hr/>Cleanup<br/><i>(guaranteed on every exit path)</i>"]
DONE["done"]
CLEAN --> DONE
Why
Letting an AI agent loose on your working copy is risky. Daedalus never
touches your checkout: all agent work happens in a throwaway worktree under
~/.daedalus/worktrees/<queue>/issue-<id>, wrapped in ai-jail
for filesystem confinement (the worktree is the only writable path; network
access is granted so the agent's CLI can reach its provider). The worktree is
always removed at the end of the run, success or failure — but never at the
cost of the work: a run that closes without approval has its commits
preserved on an aborted/issue-<id> branch that daedalus continue picks
up.
Prerequisites
- Go 1.26+
- A Temporal server:
temporal server start-dev— all defaults match (frontend127.0.0.1:7233, UI :8233) - The
temporalCLI on yourPATH, and the run-visibility search attributes registered once per namespace viamake custom-columns— before starting an upgraded worker: with the attributes missing, the server rejects every new run's first status upsert and the run wedges (old, already-running runs are unaffected) - The
ai-jailCLI on yourPATH - One supported jailed-agent CLI:
claude(the default, invoked by the jail)opencodewhenagent: opencodeis set in config.yaml — the jail bridges opencode's state dir (~/.local/share/opencode, or$XDG_DATA_HOME/opencodewhen that var is set, which is passed through) read-write, so jailed rounds share the host's session database and auth: rounds chain conversations for resume, and host login state reaches jailed rounds (the same accepted trade pi and codex make). opencode reads noOPENAI_*var natively, so anopenai:-section round is bridged per invocation through a staged round-scoped config file exposed viaOPENCODE_CONFIG(a custom provider entry — the key rides a{env:OPENAI_API_KEY}template and never lands on disk; seestageOpencodeProvider), while ananthropic:-section round has no opencode channel — daedalus stages nothing and the round would resolve its own config inside the jail (a repo-committed opencode.json honored, the host's global one not — the jail bridges only the state dir, no opencode config dir); the launch gate (see theagentrow) refuses that combo when the anthropic URL is the deployment's only serving endpoint, so an opencode deployment needs an openai-wire endpoint configuredampwhenagent: ampis — authenticates viaAMP_API_KEYin the worker's environment, which daedalus passes into the jail when set; amp's own host login does not reach the jail. Amp support is deprecated: it keeps working exactly as it does today, but it is no longer changed or maintained — no new flags, fixes, or probes.piwhenagent: piis — authenticates via the provider env vars (ANTHROPIC_API_KEY,OPENAI_API_KEY, ...) that daedalus already exports; the jail bridges pi's host~/.pi/agent/auth.jsonread-write and that login takes priority over the env vars, so a stale host login wins — keep it clean or aligned. pi-served implementing rounds also carry an edit-discipline guardrail in their prompt (read before edit, read again after a failed edit — cheap insurance for small self-hosted models); a resumed pi session whose transcript embeds tool-call JSON as assistant text is not resumed (the pattern is self-reinforcing poison), and one ending in repeated failededitcalls gets its next round steered toward read-then-retry. Flagship agents' prompts and resume paths are byte-for-byte untouched.aiderwhenagent: aideris — authenticates via the same provider env vars; the repo's.envis masked to empty inside the jail, so it cannot override the exported env. Aider support is deprecated: it keeps working exactly as it does today, but it accepts no new flags, fixes, or probes — its gaps are accepted limitations: no id-addressable resume (rounds always start fresh), openai-only model wiring (the anthropic half awaits a probe of the installed litellm's anthropic provider), and a uv-tools-only install layout.codexwhenagent: codexis — authenticates via its host login state (~/.codex, or$CODEX_HOMEwhen that var is set — the jail bridges the directory read-write and passes the var through) plus the provider env vars daedalus exports. codex reads noOPENAI_*var natively and speaks only OpenAI wire formats, so anopenai:-section round is bridged per invocation through codex's own-cconfig overrides (a staged provider entry — the key value never lands in argv or on disk; seestageCodexProvider), while ananthropic:-section round has no codex channel at all
- The repo's
.claude/settings.json/.claude/settings.local.jsonare masked to empty inside the jail: a committed settings file'senvblock would otherwise apply over the exported env inside the jail (a staleANTHROPIC_AUTH_TOKENthere once authenticated every round as a dead credential). Outside the jail — repro activities, anything you run yourself — repo settings files are honored as usual - Context-window tuning support varies by CLI (probed against the
installed CLIs, 2026-09-25):
aider— fully wired: daedalus stages a model-metadata file (max_input_tokens/max_output_tokens, 64k/8k defaults) for configured openai-style models automatically;provider.context_tokens(the preferred section) or the legacyanthropic.context_tokensoverrides the input side when setclaude— daedalus exportsCLAUDE_CODE_MAX_CONTEXT_TOKENSwhen the context window is set in config.yaml (provider.context_tokens, preferred, or the legacyanthropic.context_tokens; a settings-fileenvblock would not reach jailed rounds — masked, above; the[1m]model-name suffix for the 1M window is expressible today via the configuredmodel)opencode— openai-served rounds stagelimit.contexton the staged provider's model entry (selected with-m); without the openai section, the window is overridden per model in opencode.json (provider.<id>.models.<model>.limit), which a jailed round honors from a repo-committed file (the host's global config is not mounted)pi,amp— unprobed (neither binary was available to probe)codex— no wired lever (unprobed; amodel_providersentry carries no request params)
Usage
The full CLI surface is answerable from the binary itself: daedalus -h
lists every command with a one-line flag hint, daedalus <command> --help prints that command's detail, and daedalus worker <action> --help prints a sub-action's flag rules (which flags it accepts, which
it rejects). The tables below carry the same surface — this section and
the help text are kept in agreement.
| Command | Arguments | Flags | Purpose |
|---|---|---|---|
run |
<repo-path> <issue-id> "<prompt>" |
-w -d -p -cli -folder -dep -f -a |
Start an implementation pipeline for an issue |
run -a <workflow-id> |
<workflow-id> ["<prompt>"] |
-f |
Append a prompt to an already-running pipeline (same as guide) |
continue |
<workflow-id> "<prompt>" |
-d |
Resume a closed, failed, or parked session |
guide |
<workflow-id> "<message>" |
— | Send operator instructions to a running pipeline |
attach |
<workflow-id> |
— | Reconnect to an in-flight or completed pipeline |
list |
[max] |
— | Display past and current sessions on the task queue |
log |
<workflow-id> |
--status -cot -cot-n |
Show a session's captured task log (raw, status brief, or chain-of-thought) |
wipe |
<workflow-id> |
--yes |
Erase a session's disk work entirely (worktree, branches, logs) |
worker start |
— | -c -t |
Run the worker daemon detached (the default worker action) |
worker stop |
— | -c |
Drain the daemon gracefully (SIGTERM) |
worker status |
— | — | List every worker on record (plus live strays) with live probes; the stuck-work recapture point |
worker restart |
— | -c -t |
Stop + start this config's worker with the config re-read from disk |
worker restart <worker> |
<worker> |
— | Restart one worker from its recorded config, from any directory |
worker restart all |
— | — | Restart every worker on record, each from its own record — host-global: every worker on record in ~/.local/state/daedalus regardless of the invoking directory |
worker foreground |
— | -c -t |
Run the worker attached to this terminal |
worker wakeup |
<workflow-id> |
-c |
Interrupt a RUNNING session's quota heartbeat so the round resumes |
worker report |
— | --json --table |
Summarize AI provider usage (cost, tokens, time) for this config's worker, plus worker slots |
metrics start |
— | -c |
Run the host metrics daemon detached (the default metrics action): usage relay, ledger, dashboard |
metrics stop / metrics status |
— | — | Terminate the daemon / report its pid, ports, and version (record-driven, no config) |
metrics restart |
— | -c |
Stop + start the daemon with the config re-read from disk |
metrics list |
— | --worker --page --json |
Query the live usage ledger, newest first |
metrics foreground |
— | -c |
Run the metrics daemon attached to this terminal |
init |
[prompt] [slim] |
— | Generate a fully commented config-example.yaml |
config |
— | — | Print the active configuration (resolved path + every field) |
version |
— | — | Print the version and exit |
completion |
bash|zsh |
— | Print the tab-completion script (eval into your shell rc) |
Global flags: -c/--config <path>, -v/--version, -h/--help. -c is
accepted by every command except log, the record-driven worker commands
(worker status, worker restart <worker>, worker restart all), and
the record-driven metrics commands (metrics status, metrics stop,
metrics list), which act from the recorded daemon files alone.
Flag reference:
| Flag | Accepted by | Meaning |
|---|---|---|
-w, --workflow <name> |
run (fresh runs) |
Flow to run: feature-dev (default), dev-only, slim, investigate, test-only, refactor, bug-fix. Rejected on every other command — including run -a, where the pipeline keeps the flow it started with |
-d, --detach |
run, continue |
Start the pipeline and return immediately; daedalus attach reconnects |
-p, --prefix <prefix> |
fresh run |
Name the preserved branch <prefix>/issue-<id>-<timestamp>, overriding the config's branch_prefix |
-cli, --cli <agent> |
fresh run |
Jailed agent for this run: claude, opencode, amp, pi, aider (deprecated), codex |
-folder, --folder <path> |
fresh run (repeatable) |
Grant the run read-write access to a host folder, mounted at .daedalus-folders/<basename> |
-dep, --depends <workflow-id> |
fresh run |
Chain the run behind another; it starts from the dependency's preserved branch once that finishes approved. When the dependency stops without approval, the dependency config decides: refuse (the default) or release onto dependency.fallback_branch when the stopped state is skip-listed |
-f, --file <path> |
run (fresh and -a) |
Read the task description from a file (glob-expandable); replaces the "<prompt>" argument |
-a, --append <id> |
run |
Append mode: fold the prompt into an already-running pipeline's next fix round |
--status |
log |
Print a maintainer-facing status brief instead of the raw log |
-cot |
log |
Print the run's chain-of-thought logs from Temporal history |
-cot-n <N> |
log -cot |
Tail the CoT view to the last N completed rounds (whole sections only) |
-t, --type dev|test |
worker start, bare worker restart, worker foreground |
Which pollers the daemon starts (dev or test; omit for both). Rejected everywhere else — run, worker status, the record-driven restarts, worker stop/wakeup, and every non-worker command |
--all |
worker restart |
Restart every worker on record (same as the all argument; host-global — every worker on record in ~/.local/state/daedalus, any invoking directory) |
--force (-f) |
worker restart |
Immediate swap: SIGTERM the old daemon and start the replacement at once instead of scheduling a drain. The drain restart keeps the old daemon serving until it has drained, then re-executes in place, so only a failure at or after the re-exec takes it down; a forced replacement is a fresh start — a failure after the old daemon was signaled (a relay dying mid-swap, a config deleted after it was loaded, a spawn or boot failure) leaves that worker down |
--yes |
wipe |
Skip the interactive confirmation (scripted use) |
--json / --table |
worker report, metrics list |
JSON vs table output (table is the default; --table is accepted for explicitness) |
--worker <name> / --page <n> |
metrics list |
Filter the ledger page to one worker / pick the page (50 entries per page, newest first) |
-
Install the CLI:
go install github.com/ozzono/daedalus/cmd/daedalus@latest -
Start a Temporal server (first terminal — skip if one is already running):
temporal server start-dev -
Create your config and start a worker (second terminal):
daedalus init # writes config-example.yaml mkdir -p ~/.config/daedalus && cp config-example.yaml ~/.config/daedalus/config.yaml # then edit as needed daedalus workerA config in
~/.config/daedalus/config.yamlis found from any directory, soworker,run, and the other commands work wherever you invoke them (./config.yamlin the working directory wins when present, then./.daedalus/config.yaml— the repo-local config of the repo your shell sits in).daedalus run <repo-path>additionally prefers the target repo's.daedalus/config.yamlover all of the above, so running against another repo configures the run for that repo, not the shell's cwd: that file replaces the default config outright for the run — no field merging — while an explicit-cstill wins over it.daedalus workerruns the worker as a detached daemon: one per task queue, logs appending to~/.local/state/daedalus/worker-<queue>.log(pruned to the past week), pid in~/.local/state/daedalus/worker-<queue>.pid. Manage it withdaedalus worker stop|status|restart, ordaedalus worker foregroundto run it attached to a terminal (the way to debug a worker that will not start). Each worker records the config it was started with (in~/.local/state/daedalus/worker-<queue>.conf), sorestartreuses the recorded settings wherever it's invoked, as long as some config resolves to name the queue (pass-cor keep a user-level config) — stop thenstart -c <new>to move a worker to a different config.daedalus worker restart allrestarts every worker on record, each with its own config, in one call — records alone, no config needed (an explicit-cis rejected there; it would have no effect). "all" is host-global: it signals every worker on record in~/.local/state/daedalusregardless of the invoking directory, and--forcetrades the drain — which keeps the old daemon serving until it has drained and re-executes in place, so only a failure at or after the re-exec takes it down — for an instant kill whose replacement is a fresh start: a failure after the old daemon was signaled (a relay dying mid-swap, a config deleted after it was loaded, a spawn or boot failure) leaves that worker down.daedalus worker statuslists every worker on record — plus any live stray running without one, shown as "(no config record)" — queue, running pid, a live provider API probe, the repo path each worker's queue is currently executing ("idle" when none), config record, and log path. Status is also the recapture point for stuck work: a running workflow whose outstanding activity attempt is held by a worker identity with no live poller (a worker that died mid-round) is recovered automatically — the stuck attempt is failed so the workflow reschedules it, and a worker is restarted (or started from the queue's most recent config record) only when the attempt's queue has no live poller left. Each action prints a[DAEDALUS-ALERT]line to the status output and the worker log. With nothing stuck, status is a read-only no-op. -
Trigger a pipeline (third terminal):
daedalus run /path/to/repo 42 "Add a /health endpoint that returns 200."The third argument is the task description — the full issue text the implementing agent works from. Daedalus has no issue-tracker integration: it never fetches the description from anywhere, so paste or write the entire task (context, requirements, acceptance criteria) into that quoted string. Use
--first if the text itself starts with a-:daedalus run /path/to/repo 42 -- "-c flag parsing must survive --config= in prompts"For long descriptions, pass a file instead — either the argument or the file, never both:
daedalus run -f issue-42.md /path/to/repo 42The path may also be a glob (
notes/*.md,/path/to/specs/*): every matching file is read in lexical order and joined into the prompt. A glob whose directory part carries metachars (*?[\) is rejected, because that directory is granted to the run read-write (below) and is taken literally, never globbed.-folder/--folder <path>(repeatable) grants the run read-write access to a host folder: each is mounted into the jailed rounds' sandbox at.daedalus-folders/<basename>and named in the agent's opening prompt, so the run can do bookkeeping outside the repo — update a shared done-index, retire its own task file. Passing-fgrants the task file's containing folder automatically, so the run above can removeissue-42.mditself. Grants are fresh-run only (rejected in append mode), validated at submit, and are the same accepted operator trade as the bug-filing mirror: a read-write window onto a host path you chose.daedalus run -folder ~/Projects/tasks/daedalus /path/to/repo 42 -f issue-42.md-cli/--cli <agent>overrides the config's jailed agent (claude,opencode,amp,pi,aider,codex) for this run — the selection travels with the run, so it applies on whichever worker serves the task queue:daedalus run -cli amp /path/to/repo 42 "Add a /health endpoint."
run prints the Workflow ID (daedalus-42) and Run ID, then blocks
until the pipeline finishes (up to 12 h; the workflow keeps running past
that). On success it prints the preserved branch holding the committed,
approved work, e.g. daedalus/issue-42-1726320000; a parked run prints a
daedalus continue resume hint instead. Follow along in the
Temporal UI at http://127.0.0.1:8233, or with temporal workflow show.
Prefer fire-and-forget? daedalus run -d starts the pipeline and returns
immediately; daedalus attach <workflow-id> reconnects later, blocks until
it finishes, and reports the outcome (also works after a run has ended).
daedalus list shows the most recent sessions on this task queue —
session id, status, and last interaction time.
Systemd supervision of the worker
A worker that dies mid-run (crash, OOM, reboot) stalls every run it was
carrying: Temporal retries the lapsed round, but a retry needs a live
worker polling the queue. deploy/daedalus-worker.service ships a systemd
unit that owns the worker's crashes and boots: Restart=on-failure
revives it with backoff, the start rate limit caps a crash loop (a broken
config surfaces in journalctl -u daedalus-worker and the worker log),
and WantedBy=multi-user.target brings it back at boot so queued runs
resume. Copy the unit to /etc/systemd/system/, edit the ExecStart
binary/config paths and the User/Group for the host, then
systemctl daemon-reload && systemctl enable --now daedalus-worker.
The lifecycle contract keeps one owner per job. Systemd owns crashes and
boots. The CLI keeps stop and restart: worker stop drains gracefully and
exits 0, which Restart=on-failure leaves alone; worker restart
SIGHUP-drains and re-execs in place, invisible to systemd. The unit sets
DAEDALUS_WORKER_DAEMON=1, so the supervised worker writes and clears its
own pid file and every pid-file gate (worker start's double-start
refusal, worker stop/restart/status/report) treats it exactly like
a spawned daemon. What stays unsupervised-mode only: worker start while
the unit is active (it would double-start — the pid-file refusal catches
the live one) and worker restart --force (its replacement spawns outside
the unit); use systemctl for those on a supervised host. An auto-revived
worker boots from the unit's ExecStart, not through worker start: no
config record is written (the unit is the boot definition) and a drain
restart re-execs the service's own command line; a supervised boot also
opens no worker log — output lives in journald (journalctl -u daedalus-worker). At boot the worker registers the pipeline's search
attributes itself (no make custom-columns step needed on a fresh
namespace).
Steering and resuming
- Steer a running pipeline:
daedalus guide <workflow-id> "<message>"sends operator guidance that is folded into the agent's next fix prompt, steering a stuck review loop without restarting the run.daedalus run -a <workflow-id> "<prompt>"is the same thing with a fuller prompt (-f/--fileworks there too). - Resume a closed one:
daedalus continue <workflow-id> "<prompt>"restarts a canceled, failed, or parked session under a new prompt. The aborted attempt's preserved work (itsaborted/<issue>branch) becomes the new run's starting point, and that attempt's last review feedback is folded into the opening prompt. A run that exhausts the provider quota first heartbeats — sleeping an hour and retrying the same round, up to five times — and then parks itself: it completes with arun parked awaiting maintainer restartresult (the reason visible in the workflow history, and the run shows green in the orchestrator — a park is a designed outcome, not a failure) so you can tell "continue later" apart from a code failure. A reviewer that judges the task impossible ends with NEEDS_MAINTAINER and parks the run the same way, with its comments carried in the result. - Wake a sleeping one:
daedalus worker wakeup <workflow-id>ends a RUNNING session's quota-heartbeat sleep immediately, so the round retries right away instead of at the top of the hour — the lever for when you have verified the provider recovered but the run is still waiting out its hourly retry. It takes no prompt (that isguide/continue); the run keeps its id and worktree. Closed sessions still resume withcontinue.
daedalus or daedalus --help prints full usage.
Configuration
All configuration lives in config.yaml, read from the working directory
when one is there, then from .daedalus/config.yaml in the working
directory (a repo's own config), otherwise from
~/.config/daedalus/config.yaml (override the path with -c/--config).
Keeping the config in ~/.config/daedalus/ makes every command — worker,
run, list, … — work from any directory; a repo carrying
.daedalus/config.yaml gets the same uniform treatment for daedalus work
invoked from inside it. daedalus init writes a fully commented
config-example.yaml documenting every field and its default:
| Field | Default | Purpose |
|---|---|---|
agent |
claude |
Jailed agent CLI: claude, opencode, amp (deprecated, unmaintained), pi, aider (deprecated), or codex (run -cli/--cli overrides per run). claude, pi, opencode, and codex chain conversations (--resume/--session/run -s/exec resume); amp and aider start each round fresh. Launch gate (start and continue alike): a run whose agent cannot take the config's only serving endpoint fails at launch naming the agent and the wire — claude is served by an anthropic channel (url, or key alone on the public base) and cannot take the openai channel; codex/opencode/aider are openai-only; pi is staged on the openai channel only (an anthropic custom URL never reaches it, a key with no url does); amp is exempt. An all-unset config is the ambient posture and never trips it |
branch_prefix |
daedalus |
Prefix for preserved branches (<prefix>/issue-<id>-<ts>); run -p/--prefix overrides per run |
authorship |
false |
When true, daedalus's own commits (the approved deliverable and the aborted-work snapshot) are authored "daedalus <daedalus@local>"; false leaves them to the worker's git config |
temporal.host |
127.0.0.1:7233 |
Temporal frontend address |
temporal.ui_port |
8233 |
Temporal UI port (shown at startup) |
temporal.task_queue |
daedalus |
Routing key; distinct projects/flows on one Temporal use distinct queues |
anthropic.url |
"" (inherit env) |
Anthropic API base URL — set only to override |
anthropic.key |
"" (optional) |
API key for the jailed agent; if unset, the agent authenticates via the worker's inherited environment or its own login |
anthropic.model |
"" (agent default) |
Model for the jailed agent |
openai.url/key/model |
"" (inherit env) |
Optional OpenAI settings, exported as OPENAI_* into the agent's environment for tooling it runs; not consumed by daedalus itself — except pi, whose openai rounds are staged into ~/.pi/agent/models.json per round (pi reads the key env var but no base-URL env var), codex, whose rounds consume the section via per-invocation -c overrides (codex reads no OPENAI_* var natively), and opencode, whose rounds consume it via a staged round-scoped config file exposed through OPENCODE_CONFIG (opencode has no provider env channel at all) |
slim.enabled/parser_model |
enabled: false |
Slim mode for small self-hosted models (aider/pi): the micro-stepped atomic flow reroutes a defaulted -w, one jailed-agent round at a time, with the optional tool-call relay (parser_model, resolved against the openai section's url) and the max_input_tokens input ceiling; mutually exclusive with mid and, like mid, requires verdict_repair.url/model at load — see SLIM.md for the full slim reference (flow, input diet, tool-call relay, sizing checklist, history) |
mid |
false (flagship default) |
Mid-tier mode for capable remote models on the deployment's own provider channel (either wire); mutually exclusive with slim.enabled and, like slim, requires verdict_repair.url/model at load; a wire-vs-agent mismatch (an agent that cannot speak the only served wire) fails the run at launch — see MID.md for the full mid reference (launch gate, verdict-repair path, readiness checklist, history) |
fallback.enabled/url/key/model/heartbeat_model |
enabled: false |
Independent secondary provider: when a jailed round fails with the primary's quota exhausted, the worker retries it on the fallback until the primary recovers |
fallback.type |
anthropic |
Fallback wire style: anthropic or openai; governs the worker status probe and which env failover values travel on. A round's wire is chosen by the agent (claude dials ANTHROPIC_*), so openai serves only agents that dial OPENAI_BASE_URL |
reviewer.url/key/model/disable_thinking |
"" / false (share primary) |
Reviewer rounds' own provider endpoint, model, and wire-level thinking toggle: url/key override ANTHROPIC_*/OPENAI_* URL and key, model overrides both families' model id (legal alone — same endpoint, different brain), for reviewer rounds only, while implementing and test rounds keep the primary's; disable_thinking: true stages the Qwen3-template disable-thinking encoding onto reviewer rounds' requests (pi/aider stagings) and keys on this section alone — the openai section's never crosses |
review_skill_list |
[] (default reviews only) |
Extra skill reviews appended to each review-gated phase of the feature-dev flow: the default daedalus review ping-pong runs first, then one ping-pong per entry in order — the code phase routes each skill's feedback through the implementing agent's session, the test phase through the tester's — each with its own reviewer conversation, the same verdict protocol (APPROVED/CHANGES_REQUESTED/NEEDS_MAINTAINER, plus the test phase's REBUILD), and the same park handling and loop caps as the default reviewer. Each entry is /name — resolved at round time to <name>/SKILL.md under the run worktree's .claude/skills (project wins) or the worker's ~/.claude/skills — or hand-written prompt text passed through verbatim; a skill that resolves nowhere, or to nothing but frontmatter, fails the round loudly. A leading / is what marks a skill name, so hand-written text cannot begin with one. And since the worktree's .claude/skills is the implementing agent's to write mid-run, project-wins means the agent can author or replace the discipline its own skill reviewer applies — the fixed preamble's verdict contract, not that directory, is the trust boundary. Whatever the instructions say, they render into the reviewer prompt under a fixed preamble restating the verdict marker contract and the harness-parsed-output rule, so configured text can never reach a reviewer without daedalus's communication rules above it. Frozen into the run at submit (a continued run keeps its attempt's list); blank entries are rejected at load |
verdict_repair.url/model/key |
"" (repair off) |
Decision-model repair of marker-less review verdicts: a reviewer round carrying no parseable verdict marker gets one classify-only repair pass against the decision model's /v1/systemone surface before the workflow ever sees it; a high-confidence APPROVED promotes the round to approval, every other outcome continues as changes requested; any failure — unset, unreachable, slow, or garbage — degrades to the historical behavior. key optional. Required (url and model) when slim.enabled: true or mid: true (fails the load otherwise); in a flagship config (neither set) it stays optional but is not inert — any config that sets url and model actively repairs marker-less review verdicts in every mode, including a high-confidence APPROVED promoting the round to approval; leave it unset for fully-off — see MID.md for the full repair reference (request shape, promotion threshold, fail-open contract, history) |
bug_filing.enabled/dir/mirror |
enabled: false |
Out-of-scope-bug filing. Off (the default), no bug files are written: out-of-scope bugs surface in round replies and review comments only. On, the round prompts instruct the agent to file every out-of-scope bug under dir — worktree-relative, resolved against the run's worktree root (dir empty keeps the historical backlog/bugs path). Without mirror the files are ordinary committed content of the branch. mirror, when set (absolute, or ~/…; a relative path, the filesystem root, or a colon is rejected), is a host directory bind-mounted read-write into each jailed round's sandbox at dir, so the agent's writes land on the host directly and never ride the branch — a configured mirror is a read-write window the jailed agent holds onto a host path, so point it at a dedicated directory; on an ai-jail that rejects the mount the round fails loudly rather than running unmounted. test_output.mirror mirrors suite dumps worker-side the same way (copy, not mount) |
prompt |
"" (embedded) |
Project-wise prompt overrides: a directory of replacement prompts, one <prompt-name>.md file per replaced prompt, the stem naming the prompt (implement, implement_fix, continue, tests, tests_failed, tests_review, review, rebuild, investigate, investigate_fix, refactor, refactor_fix, bugfix, bugfix_fix, slim_plan, slim_step, slim_parse, slim_parse_reask, slim_fix — template names, not flow names). Absolute, ~/…, or relative to the config file's directory. The worker resolves and validates the whole directory at startup — an unknown stem, missing directory, unparsable template, empty file, a data field the prompt does not take, a {{template}} action, a {{define}}/{{block}} block (a define or block body can never render in an override — the sole exception is a define named exactly <prompt-name>.md; don't rely on it), or a review override missing the verdict protocol fails the start, never a mid-round render; every override is also render-tested at startup against the prompt's representative data (its zero value; review renders further times with a diff handoff attached and in the test-review framing, exercising its guarded branches), so a reference that parses but only fails at render — a nested access, or one whose {{if}} guard an edit stripped — fails the start too; unset, every prompt renders byte-identically to the embedded one. review is validated to keep all four verdict words in its rendered text (checked in the test-review framing, the one shape where the fourth verdict renders), as whole words (DISAPPROVED cannot vouch for APPROVED — the reviewer's final-line machine contract). Rendered prompts ride workflow history, so override content is not secret |
dependency.enabled/fallback_branch/skip_parked/skip_failed/skip_stuck/skip_canceled |
enabled: true, skip_parked: true, rest empty/false |
What a run chained behind another (run -dep) does when its dependency stops without approval. The default keeps the historical refusal: the waiting run fails before any worktree, branch, or round existed. Setting fallback_branch (empty resolves to the branch the run was submitted from, frozen into the run at submit; a detached HEAD refuses; an explicitly set branch is verified to exist at submit) turns the gate into a switch: a dependency that stops in a skip-listed state releases the waiting run, which starts its worktree from that branch. skip_parked defaults true — a parked dependency's work survives on its aborted branch — while failed/stuck (timed out)/canceled-and-terminated default false. A paused dependency is never skipped: the gate waits and logs the unpause line (temporal workflow unpause). enabled: false restores the unconditional refusal |
Provider settings that are set are exported into the worker's environment
at startup and injected into the jailed agent's process environment; unset
ones are simply not exported, so the agent inherits whatever the worker's
environment provides. The API key deliberately never appears in CLI
arguments, workflow inputs, or Temporal history — only in config.yaml,
the worker's environment, and the jailed process's environment. Keep
config.yaml out of version control (it is gitignored; the example file is
the committed template). daedalus config prints which of the config
sources won plus every field as YAML — set values verbatim, absent ones
empty — so a misbehaving worker's first diagnostic is one command away.
The Temporal task queue comes from temporal.task_queue (default daedalus);
worktrees live under ~/.daedalus/worktrees/<queue>/issue-<id>
(flow-scoped <flow>-issue-<id> for the non-default flows, which also
scope their feat/ and aborted/ branch names, so concurrent flows on one
issue never share live state). Re-running
daedalus run for an issue whose previous pipeline succeeded starts a new run
(duplicates are allowed) — the previous run's preserved branch stays.
Pipeline details
- Two review-gated phases: the implementation phase loops
agent ↔ reviewer until the reviewer approves; the test phase loops
agent ↔ (test suite + reviewer) until the reviewer approves and the
suite passes. Review rounds are intentionally unbounded — the workflow
ends only on approval, with every round durable and auditable — but two
runaway guards keep a stalled stage from churning forever. The green
stage parks the run once the test reviewer has issued more than 8
REBUILDverdicts while suite green and review approval never coincided, instead of cycling on the provider budget until quota death takes the deployment's other runs down too. A rebuild round's finding carries the review's full failure inventory (the test reviewer must enumerate every implementation defect found that round), and the implementing agent is required to address every item per cycle (reporting any it cannot satisfy), so convergence normally takes a handful of cycles and the cap fires only on a genuinely stalled stage. Separately, verdict runaway guards are tracked per review role (a code-review approval between a test reviewer's repeated rebuild findings does not reset the test reviewer's count): a reviewer that ends without any verdict marker three times in a row, or repeats a whitespace-identical rejection verdict three times in a row, parks the run for a maintainer — an implementer that cannot act on the feedback cannot churn forever. - Reviewer protocol: the reviewer sees the diff of the worktree (plus
the latest test output and the test agent's latest reply in phase 2) and
must end its response with a final line
APPROVED,CHANGES_REQUESTED,REBUILD(phase 2 only), orNEEDS_MAINTAINER. The last parks the run for a maintainer restart — used when the task as stated cannot be completed by editing files in the worktree, so an impossible task cannot loop forever.REBUILDis the test reviewer's verdict for a finding the test-only agent cannot apply: an implementation-level defect — relayed by the tester or found by the reviewer — routes back through the implementation ↔ code-review cycle, and the test loop resumes once the code reviewer approves again; past the 8th rebuild the run parks (resumable withdaedalus continue) rather than looping. Anything else — including a malformed response — counts as changes requested, with the full output fed back to the implementing agent. - The repo's own test suite, whatever it is: the test command is
resolved per repository — a
tests:declaration in.daedalus.yamlwins; otherwise a Makefile with a plaintest:rule →make test(the repo's own composition beats any single-language default), then marker files (go.mod→go test ./...,package.jsonwith a test script →npm test, pytest configs →pytest -q), then Makefiletest-ui/test-apitargets →make …; for repositories none of that recognizes, a short jailed agent run discovers the command. The resolved command is recorded in the workflow history. - Branch lifecycle:
feat/issue-<id>-<unix>is the in-flight branch (always cleaned up, along with stalefeat/branches from crashed runs);<branch_prefix>/issue-<id>-<unix>(defaultdaedalus) is the committed, approved deliverable;aborted/issue-<id>carries a run that closed without approval, replaced by each newer abort and consumed bydaedalus continue; when that name is checked out in another worktree at abort time, the snapshot lands under a suffixedaborted/issue-<id>-<n>name instead. - Operator guidance:
daedalus guide(orrun -a) messages arrive as a Temporal signal and are prefixed onto the agent's next fix prompt, marked as direct operator instructions taking precedence over earlier plan assumptions. - Workflows are selectable:
daedalus run -w feature-dev ...(the default) picks from a name registry; adding another flow later is one registry entry. The other flows reassemble the same review-gated loop machinery with a different gate:dev-onlyis feature-dev's implementation ↔ code-review phase alone, landing without any test-phase execution (selection is CLI-only — no config key picks a flow);slimis the micro-stepped atomic loop for context-limited self-hosted models (target agent: pi), config-gated byslim.enabled: true— a run without an explicit-wstarts it, an explicit-walways wins — see SLIM.md for the full slim reference (flow shape, input diet, tool-call relay, sizing checklist);investigateis docs-only (no code changes, no test phase);test-onlymodifies test files only, records Go statement coverage per suite round, and parks on a reviewer REBUILD (there is no implementation loop to rebuild into);refactorchanges production code with the test suite frozen — structurally, by a path policy — and green;bug-fixis test-first, gated on the diff's tests failing on the pre-fix code.investigate,test-only, andrefactoralso carry a structural write-scope check before finalize: a diff outside the flow's allowed paths (or touching its frozen paths) parks the run for the maintainer instead of being silently stripped. A run's flow is fixed at start;continueresumes it under the same flow. - Activities run with a 15-minute start-to-close timeout and no retries
(
MaximumAttempts: 1) — a failed activity fails the run rather than re-running an agent that already mutated the worktree. Cancellation kills the whole jailed process group, not just the jail wrapper. Agent/reviewer failures that look like provider quota or rate limits are labeled as API exhaustion. The match is heuristic (a substring test against the error text), so a mislabeled failure can idle a run for hours of heartbeats before it parks. The workflow then heartbeats — one hourly retry at a time, up to five — before parking the run as a labeled,continue-able failure. - Deliverable preservation: once both phases pass, a finalize activity
commits the approved work and renames the run's branch from
feat/issue-<id>-<unix-timestamp>(in-flight) to<branch_prefix>/issue-<id>-<unix-timestamp>(preserved; default prefixdaedalus, frombranch_prefixorrun -p/--prefix; prefixes colliding with the reservedfeat/abortednamespaces are rejected up front). The prefix scopes the preserved branch and the finalized-deliverable check, but not theaborted/issue-<id>snapshot, which is shared per issue across prefixes: a failing run under another prefix replaces it and is not suppressed by a deliverable finalized under this prefix. The workflow returns the preserved branch name anddaedalus runprints it. - Prompt transport: prompts travel to the jailed agent via stdin, not
argv — no
psvisibility, no per-argument size limit on review prompts that embed the full diff. Verdicts are parsed from stdout only, so trailing stderr noise cannot flip anAPPROVED. .ai-jailprompt carve-out: the round prompts scope the agent to the task and name.ai-jail— the sandbox's own permission spec — as an untouchable artifact. For a run whose task text mentions.ai-jail, that exclusion is keyed off at submit time (template.TaskTouchesJail, derived from the task text only, never the diff — a diff-keyed carve-out would let an out-of-scope round-1 edit legitimize itself and unlock.ai-jailfor every later round; a mention is not a command — the carve-out branches only stop barring changes the task or review comments actually ask for). In the feature-dev/dev-only phase-1 loop the implementer may edit.ai-jailas the task directs, the fix prompt acts on.ai-jailcomments, and the code reviewer audits it: since git never shows the file (the repo ignores it; the jail drops it untracked), the reviewer prompt relays the worktree's current.ai-jailcontent as its own labeled section (agent output under audit, not trusted config; an emptied spec is relayed as an explicit "(the spec file is empty)" marker so the audit is never blind). At delivery, feature-dev and dev-only — the only flows whose prompts unlock jail edits — force-stage a non-empty.ai-jail: plaingit add -Awould silently drop the deliverable where the repo ignores the file (the jail's untouched drop is empty, so non-empty content is agent-authored). Other flows keepadd -A's fail-safe discard of any out-of-scope jail edit. Known limitation: an intentionally-emptied spec (a "clear the permissions" task) fails the non-empty check at finalize and is silently dropped while the run reports success — the reviewer saw and approved it, but the commit does not carry it. Runs whose task does not name.ai-jailkeep the exclusion wording byte-identical. ALERT: the other flows' opener/fix templates (continue,bugfix,investigate,refactor,tests,rebuildand their fix/review-feed prompts) still bar.ai-jailedits unconditionally — their reviewers deliberately keep the blanket exclusion too (a jail audit mandate there would demand what their agents are forbidden to do), so a jail-touching task must run feature-dev or dev-only (filed out-of-scope 2026-09-30).- History diet: the agent's visible text and chain of thought come back in the activity result tail-bounded to 16 KiB each (visible per round in the Temporal UI), test logs likewise; the worker log keeps the full untruncated stream.
- Multiple projects / flows on one Temporal: each deployment sets its own
temporal.task_queue; workflow IDs (<queue>-issue-<id>, flow-scoped<queue>-<flow>-issue-<id>for the non-default flows) and worktree paths (~/.daedalus/worktrees/<queue>/issue-<id>) are scoped by queue, so same-numbered issues in different projects never collide. - Cleanup: a deferred activity runs on every exit path — including
cancellation (via a disconnected context) and agent, reviewer, or test
failures — to remove the worktree (
--force), prune git's worktree metadata, sweep stalefeat/issue-<id>-*branches left by crashed runs, and preserve unapproved work onaborted/first. Preserved<branch_prefix>/branches are never touched; leftover worktree state is healed on the next run of the same issue.
Telemetry
Daedalus can meter every jailed provider round — tokens in and out,
cached tokens, cost, duration — through the host metrics daemon
(daedalus metrics): a loopback usage relay (default port 7778), a
usage ledger, and a web dashboard (default port 7777). A worker
started with metrics.enabled: true gates its boot on the daemon being
up, registers its provider upstreams and keys with it, and every round's
provider environment is rewritten so the round dials the relay instead of
its provider (/relay/<worker>/<wire>/…): the relay forwards verbatim,
and for the two inspected provider-call shapes (anthropic
/v1/messages, openai /chat/completions) it tees the usage block out
of the response into the ledger while the client's stream passes through
unharmed. Rounds whose endpoint was explicitly overridden — the fallback
section and the per-role reviewer/dev/final-reviewer sections — are not
staged. The ledger persists in the shared metrics database
(~/.config/daedalus/metrics.db — also home to the model price table
and the dashboard's own roll-ups and layout; storage_backend: memory
keeps everything in-process instead), and rows older than
metrics.ledger_max_age (45 days by default, 0s keeps everything) are
pruned. metrics list pages it newest-first (--worker, --page,
--json), and the dashboard folds it into tiles, tables, and charts.
Cost is never stored: the dashboard prices every entry at render time
from the operator-editable model price table (the models tab, USD per 1M
tokens, in/out/cached rates; cache writes bill at the in rate), so a
model absent from the table reads unpriced and a retroactive price edit
reprices the whole window on the next render.
The daemon serves an OpenTelemetry receiver whenever it runs — OTLP
over HTTP with JSON encoding at /v1/metrics on the same loopback relay
listener, no new port — and any claude or codex on the host can point
its own OTel config at it; batches arriving without daedalus's staged
identifiers render only on the full-metrics page as a per-model,
per-agent section. With metrics on (the default), the worker additionally
stages telemetry into jailed rounds themselves
instead of rewriting their provider endpoint: claude rounds receive the
CLAUDE_CODE_ENABLE_TELEMETRY/OTEL_* environment (per-round
daedalus.task/role/round/worker resource attributes,
delta-temporality token counters), codex rounds receive an
otel.metrics_exporter="otlp-http"
-c override pointed at the same endpoint — which also silences codex's
statsig default, its release build otherwise shipping metrics to
OpenAI. Each round is recorded by exactly one system: claude and codex
rounds whose CLI probes at or above the version floor stage otel and the
relay does not see them;
opencode/pi/aider rounds, and claude/codex rounds below the floors (with
a registered key), stage the
relay. metrics.enabled: false opts a project out of every capture
path — and a host without the dash daemon must set it explicitly, since
worker start/restart then requires the daemon. Receiver entries land in
the same ledger tagged
source: otel — one row per export batch, and claude exports every ~60
seconds plus a final flush at exit, so short rounds emit exactly one —
and the dashboard labels otel-sourced figures with an otel- prefix.
Three edges, each by design: codex's headless entry (codex exec)
exports no usage metrics yet (an upstream gap), so otel-staged codex
rounds record nothing until it closes; agents older than the
probe-verified floors (claude 2.1.304, codex 0.160.0) silently ignore
the otel staging, so the worker probes both binaries' versions (lazily,
once per worker, then cached) and falls such rounds back to the relay
path; and the models table's per-request columns (p50/p95, status
codes, req/min) are em-dashed on otel rows, because the metrics stream
carries no request-level signal — the loop, span, drift, and per-worker
tiles likewise read the relay face only, since otel batches are
minute-deltas, not request records.
Pricing is identical on both paths — every entry prices through the same
render-time table — and an agent's own cost report is deliberately not
displayed, so a proxy-served model name is priced by your table rather
than the agent's built-in list.
Development
Known open bugs live in backlog/bugs/. Notably: from a directory where no
config resolves, argument errors are masked by load config: no config found (config resolution runs before per-command argument validation); and
a continued run's opener prompt (continue.md) carries no bug-policy
paragraph, so with bug_filing.enabled: true the first round of a resumed
run is never told where to file out-of-scope bugs (and the worker_run.go
comment claiming the DAEDALUS_BUG_DIR export is benign "on exactly those
rounds" is wrong for precisely those rounds — the env hint is present while
the prompt is silent; see
backlog/bugs/bug-dir-env-continues-run-rounds.md).
Run the test suite:
go test ./...
Tests are hermetic: subprocess-backed activities are exercised against stub
git/go/ai-jail executables installed on a temporary PATH, and the
workflow is tested in Temporal's in-process TestWorkflowEnvironment with
mocked activities — no server, network, or API key needed.
Releases are git tags. CI runs the suite on every PR and master push — a PR
must carry exactly one release label (patch, minor, or major) before it
can merge. After a green master push, CI tags the merged code with the next
version, using that PR's label to pick the bump level (patch for pushes
that are not PR merges) and publishes a GitHub Release for the tag with
auto-generated notes — there is no version file and no other release
tooling. daedalus --version reports the tag-stamped version of a
make build binary and of one installed via
go install github.com/ozzono/daedalus/cmd/daedalus@latest; a plain
go build from a checkout reports (devel).
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
daedalus
command
Shell completion: the hidden `daedalus __complete` command answers "what can come next" for the words typed so far — one candidate per line and nothing else on the path — and `daedalus completion bash|zsh` prints the small script that feeds the shell's tab press to that probe.
|
Shell completion: the hidden `daedalus __complete` command answers "what can come next" for the words typed so far — one candidate per line and nothing else on the path — and `daedalus completion bash|zsh` prints the small script that feeds the shell's tab press to that probe. |
|
internal
|
|
|
activities
Package activities implements the Temporal activities that make up a Daedalus pipeline: worktree lifecycle, jailed agent and reviewer runs, and tests.
|
Package activities implements the Temporal activities that make up a Daedalus pipeline: worktree lifecycle, jailed agent and reviewer runs, and tests. |
|
config
Package config loads Daedalus runtime configuration from a YAML file.
|
Package config loads Daedalus runtime configuration from a YAML file. |
|
metricsrelay
Per-round content capture — the tee's second retention, beside the usage ledger.
|
Per-round content capture — the tee's second retention, beside the usage ledger. |
|
provider
Package provider probes agent API providers for liveness and quota state — the live check behind `worker status`'s API column, and the quota-reset parser the worker's provider failover shares.
|
Package provider probes agent API providers for liveness and quota state — the live check behind `worker status`'s API column, and the quota-reset parser the worker's provider failover shares. |
|
runstart
Package runstart is the run-submission seam shared by the CLI's `run` command and the htmx dashboard's New Task form (htmx-ui pt2, Q4=C): config in, validated input build, one ExecuteWorkflow — (Run, error) out.
|
Package runstart is the run-submission seam shared by the CLI's `run` command and the htmx dashboard's New Task form (htmx-ui pt2, Q4=C): config in, validated input build, one ExecuteWorkflow — (Run, error) out. |
|
shrink
Package shrink implements the slim flow's input diet: three pure, deterministic passes over a run's task input — structural collapse, dictionary-driven prose minification, extractive trimming — applied in a fixed 1 → 2 → 3 order (structure first so the later passes see clean lines; extraction last so it scores the minimized text) and gated by the caveman invariants: the compressed text is accepted only if it counts strictly fewer o200k_base tokens than the original AND every output-contract line survives byte-verbatim; otherwise the original is forwarded unchanged and nothing is claimed (fail closed, the same pass-through discipline as the caveman engine's own gate — the approach is adopted, no caveman code is).
|
Package shrink implements the slim flow's input diet: three pure, deterministic passes over a run's task input — structural collapse, dictionary-driven prose minification, extractive trimming — applied in a fixed 1 → 2 → 3 order (structure first so the later passes see clean lines; extraction last so it scores the minimized text) and gated by the caveman invariants: the compressed text is accepted only if it counts strictly fewer o200k_base tokens than the original AND every output-contract line survives byte-verbatim; otherwise the original is forwarded unchanged and nothing is claimed (fail closed, the same pass-through discipline as the caveman engine's own gate — the approach is adopted, no caveman code is). |
|
template
Package template holds the Markdown prompt templates that drive the pipeline's two review-gated loops — implementation ↔ code review, then tests ↔ test review.
|
Package template holds the Markdown prompt templates that drive the pipeline's two review-gated loops — implementation ↔ code review, then tests ↔ test review. |
|
toolrelay
Package toolrelay implements the worker-owned loopback reverse proxy that lifts text-encoded tool calls into the native tool_calls wire pi executes.
|
Package toolrelay implements the worker-owned loopback reverse proxy that lifts text-encoded tool calls into the native tool_calls wire pi executes. |
|
version
Package version exposes the CLI's version.
|
Package version exposes the CLI's version. |
|
workflows
Package workflows defines the Temporal workflows that orchestrate a Daedalus feature-development pipeline.
|
Package workflows defines the Temporal workflows that orchestrate a Daedalus feature-development pipeline. |