Multi-agent orchestrator for Claude Code, Codex, and Antigravity CLI (agy). Start an interactive coordinator session that can fan out work to parallel autonomous agents, each in its own git worktree and its own tmux pane in a detached session — off your screen, but still yours to inspect.
klaus
That's it. Run it from a repo or from your home directory — either works. You're in an interactive Claude Code session. Talk to Claude normally — plan features, debug issues, review code. When there's work that can run in parallel, Claude (or you) spawns agents:
You: We need to fix the flaky auth test, add dark mode to settings,
and update the API docs. Can you launch agents for each?
Claude: [runs klaus launch for each task]
Three agents start in the background. Each works independently in its own worktree, pushes a branch, and opens a PR. Your window is untouched — the agents' tmux panes live in a separate detached session, and you watch them through the dashboard, klaus status, and klaus logs.
Choose the coordinator and workers independently. Existing configuration and sessions continue to use Claude unless you select another backend:
klaus new --backend codex --agent-backend agy
klaus new --backend agy --agent-backend codex
klaus launch --backend codex "Fix the failing tests"
klaus launch --backend agy --effort high "Implement the settings page"
klaus, klaus session, and klaus new accept --backend for the coordinator
and --agent-backend for the session's worker default. klaus launch and
klaus scaffold accept --backend for that worker. Automatic CI/review fix
agents use the saved session worker default too. klaus status includes a
MODEL column: the backend and the model the run used (the one the worker reported, else the one launched with, else (default model)), as review jobs show it.
Set persistent defaults in ~/.klaus/config.json or .klaus/config.json:
{
"default_coordinator_backend": "codex",
"default_agent_backend": "agy",
"backends": {
"codex": { "effort": "high" },
"agy": { "effort": "medium" }
}
}
Each entry in backends can set model, effort, review_model, and
review_backend (see Cross-model review). Model
identifiers are passed to that CLI unchanged. Legacy default_agent_model,
default_agent_effort, and pre_review.review_model remain Claude defaults;
they never send a Claude model name to Codex or agy. Explicit worker --model
and --effort flags override defaults. A per-backend entry replaces the legacy
model/effort defaults for that backend, including when its fields are empty.
Worker backend precedence is --backend, saved session selection,
KLAUS_AGENT_BACKEND, configuration, then Claude. Backend selection is stored
on each run. Resuming a coordinator keeps its saved backend; use klaus new
to switch to a different coordinator backend.
| Capability | Claude | Codex | agy |
|---|---|---|---|
| Interactive coordinator, headless workers, local/SSH execution | Yes | Yes | Yes |
| Formatted logs, PR detection, failure reporting | Yes | Yes | Yes |
| Coordinator resume | Saved conversation ID | Most recent conversation in the session workspace | Workspace-specific conversation ID |
Worker --resume-from |
Fork with staged transcript | Fork a recorded local Codex thread on the same machine | Fresh conversation, with a warning |
| Dollar budget / budget pause | Yes | Unavailable | Unavailable |
Stored trajectory replay (--replay) |
Yes | Unavailable | Unavailable |
--pr / --change follow-up conversation |
Replays the latest run's stored conversation, on any machine with the data ref | Forks the latest run's Codex session on the same machine | Fresh conversation |
| Shared coordinator memory | Claude memory directory | CLI's own memory behavior | CLI's own memory behavior |
For Codex and agy, explicit --budget and --replay are rejected; the legacy
default_budget does not apply, and unknown cost is displayed as unavailable.
agy workers use a 24-hour print timeout instead of its short CLI default.
agy runs every command through bash, so a local agy worker needs bash on
the PATH it gets, which is the PATH klaus launch runs with: launch fails,
naming the command and the PATH it searched, when that PATH lacks bash or
the worker's own CLI, rather than start a worker that can't run commands.
A worker's writes to its clone's git config, info/attributes or hooks reach
every worktree of the clone, so launch snapshots those files and finalize
compares them. On a difference, beyond the branch tracking git push -u
writes, finalize emits agent:shared-git-changed and klaus status shows the
run as needs-attention, with a line naming the files to check.
--resume-from never transfers conversations between backends or machines;
when a compatible conversation is unavailable, the worker starts fresh. Use
--pr (or --change in a repo without PRs) to continue the actual branch
changes. agy coordinator IDs are recovered
from its workspace cache; missing cache metadata starts a fresh conversation
with a warning rather than attaching to an unrelated conversation. A resumed
agy coordinator receives a short continuation prompt instead of repeating the
original instructions.
Pre-PR peer review prefers a different family from the worker's (see below)
and falls back to the worker's own backend, so Codex workers do not require
Claude to be installed. Authenticate each selected CLI beforehand and
make it available on PATH on the machine that executes it (including any
sandbox_host). Workers retain Klaus's existing unattended permission policy;
Codex uses its explicit approval/sandbox bypass, and agy uses its permission
bypass. Run untrusted tasks on an appropriately isolated worker host.
Same-family review shares blind spots, so klaus reviews code with a different model family from the one that wrote it:
{
"cross_review": { "enabled": true, "order": ["codex", "claude", "agy"], "post": true, "max_rounds": 2 },
"backends": { "claude": { "review_backend": "codex" } }
}
The reviewer family is backends.<author>.review_backend if set, else the first
cross_review.order entry that is not the author and whose CLI is on PATH, else
the author's own family. enabled: false skips the order and keeps the author's
family. All three backends can review. The model is
backends.<reviewer>.review_model, else
pre_review.review_model for Claude (default haiku), else the CLI default.
The same choice drives the _pre-review agents run before opening a PR; it
prints the reviewer it used.
Beside its peer review, _pre-review has another model family rewrite the
prose the change adds, and a third fact-check the rewrite, so a worker's docs
and commit messages say what the code can't, not what it already shows:
{
"pre_review": { "prose": { "enabled": true, "order": ["agy", "codex", "claude"],
"paths": ["internal/cmd/*.go"], "timeout_seconds": 180, "max_input_bytes": 40000 } }
}
Prose is the added lines of Markdown files and READMEs, of files paths
matches (a pattern without a slash matches a file name anywhere; dir/**
matches a tree), and the head commit's message. A change with no prose beyond
a short commit message is skipped. The writer is the first installed family
in order that isn't the author's; the fact checker is the next, else the
author's. The writer lists each passage's claims and conditions in a ledger
before rewriting, using the writing sections of the repo's AGENTS.md and
CLAUDE.md and the house rules; the checker reports each rewrite that changes
the meaning.
_pre-review prints each rewrite with any fact-check failure, and saves the
ones that passed as a patch for git apply and a commit message for
git commit --amend -F. Nothing is applied for the worker, and the pass never
fails _pre-review: a missing, slow or failing model is reported and skipped.
Each pass is a prose job (see klaus jobs); when the run finalizes, each
rewrite is marked applied or rejected by whether the worktree has it.
klaus review <pr> gets a second opinion on an open PR:
klaus review 303 # reviewer chosen as above
klaus review 303 --repo owner/repo --backend codex # explicit reviewer
klaus review 303 --post # submit it to the PR
The PR's author family comes from the klaus run that opened it (Claude if none).
klaus refuses a same-family review unless you pass --backend together with
--allow-same-family. The reviewer sees only gh pr diff plus the PR title and
description and runs read-only in an empty temp directory, using the same
isolation as consultations. It returns findings and a
short verdict on whether the change matches its stated intent.
--post (default: cross_review.post) submits one GitHub review with event
COMMENT: findings on lines in the diff become inline comments, the rest go in
the body next to the verdict. Cross-model reviews are second opinions for the
operator, never approvals; klaus never posts APPROVE or REQUEST_CHANGES,
and approval stays with klaus approve. Posted inline findings feed the normal
trusted-review fix loop (see docs/PIPELINE.md).
Posting is refused when the same reviewer already reviewed the PR's head
commit, or when the PR already has max_rounds cross-reviews posted from your
gh account. That check runs again right before posting, along with a check that
the PR head hasn't moved, so stale findings are never posted after a push. A per-PR lock under the
session directory makes a second concurrent --post in the same session fail
immediately.
Inside a Klaus session, klaus review emits review:started and
review:completed (with backend, model, repo, pr_number, author,
post, and at the end duration_ms, success, findings, review_url or
error), and leaves a job record like a consult's with its verdict, findings
and summary (see Consulting other models). SIGINT
and SIGTERM end it as interrupted.
The coordinator prompt describes klaus watch for its backend. Claude arms
it in its Monitor tool. agy, whose background tasks hand it their output only
when they exit, and Codex get a chain of one-shot watches instead: each
klaus watch --once runs as a background task, and when it exits the
coordinator starts the next one from its next: line (see
Real-time event channel). Klaus does not emulate
Claude's Monitor tool.
CLI event contracts: Codex non-interactive mode and Antigravity headless mode.
Use klaus consult for a read-only thinking partner without a worker, worktree,
or branch. It chooses an installed family other than KLAUS_BACKEND, in the
configured order; --backend overrides that choice. Model and effort use the
same per-backend defaults as launch.
klaus consult --role critic --file plan.md --thread auth "Critique this plan"
klaus consult --panel "Should we use polling or webhooks for build status?"
Configure selection and the default role in ~/.klaus/config.json or
.klaus/config.json:
{
"consult": { "order": ["codex","claude","agy"], "default_role": "partner" }
}
Consultations and reviews share the same read-only isolation.
Codex uses its read-only sandbox and --ignore-user-config to avoid loading
MCP servers from $CODEX_HOME/config.toml; authentication still uses
CODEX_HOME. Model and effort come from Klaus flags/defaults, and apps, plugins,
hooks, and subagents are explicitly disabled. Claude uses safe mode (no custom
hooks/plugins) and exposes only Read/Grep/Glob; agy
uses plan mode and a temporary primary-agent definition exposing only file reads
and search. The agy definition is removed after the call. Every backend reads
the question on stdin, so no prompt meets the OS argument-size limit.
Roles are partner, critic, reviewer, or your own system-prompt text.
--dir selects a workspace (default: cwd), read as it stands. Repeat --file to inline attachments, up to 200KB total, or supply the
question via --prompt-file. File paths resolve from the invoking directory.
Each attachment goes in the prompt under a header with its path, so the partner
needn't open it. Headless agy can't read a path outside its workspace, and a
denied read ends its turn with no answer, so for agy each file sits between
BEGIN and END lines, behind a note that it is there in full.
--repo NAME reads a registered local project as origin has it, never the
clone's working tree, which may sit on an old agent branch: at the start of
each turn klaus fetches origin's default branch (the project's default_branch,
else main) into the clone and checks it out, detached, in a snapshot
worktree of the clone under ~/.klaus, so the partner still has git log and
git blame. The first line of the prompt names what it reads, as
Workspace: <project> at origin/<branch> <commit>, and the commit is recorded
in the thread, the consult:started event and the job record. A one-shot
consult's snapshot, which a --panel's members share, is in
~/.klaus/consult-snapshots/ and is removed when the consult ends. A thread
started with --repo keeps the project and a snapshot of its own, at
~/.klaus/sessions/<session-id>/consults/NAME.workspace; every later turn,
with or without --repo, moves it to the current origin branch before the
partner runs. If the fetch fails (offline, auth), klaus says so on stderr and
the thread reads its last snapshot, naming its commit; a first turn with no
snapshot fails. klaus cleanup --all removes the session's thread snapshots
(the next turn makes a new one), and one deleted by hand is pruned from the
clone and made again on the next turn. A snapshot is detached, so it never
holds a branch a launch needs.
Keep a --thread NAME per topic. Threads retain their backend, model, effort,
role, and workspace; incompatible overrides are rejected. Metadata and readable
transcripts live in ~/.klaus/sessions/<session-id>/consults/NAME.{json,log}.
New transcript turns are JSON lines, persisted as pending before the backend
starts and atomically updated to ok or error: <message>. Each records backend,
model, resume ID, repository revision (a --repo thread's full snapshot
commit), and session-ID source (stderr,
stdout-json, diagnostic-log, or text; assigned/resume identify known IDs).
An interrupted turn stays pending; legacy transcript text is retained. Failed
turns preserve stdout/stderr in NAME.<turn>.raw, and extraction errors report
the paths tried and raw-output filename. Turn numbers include failed attempts;
--list counts successful turns.
klaus consult --list shows backend, turns, and last-used time. Outside a pane,
threads use the most recent Klaus session; one-shot questions need no session.
--panel queries every installed family other than the caller concurrently and
prints responses as they finish under backend/model headings. Responses are
buffered to avoid interleaving; a failed member makes the command fail after
printing the other answers. --timeout sets a positive per-backend deadline
(default 10m, e.g. --timeout 30s); timed-out panel members print timed out
while the others finish. The deadline also applies to single-backend consults.
Panel mode cannot be combined with --thread or
--backend. Consults never create agent run state.
Inside a Klaus session, each backend call emits a consult:started event
before the model runs and a consult:completed event when it ends; both appear
in the default klaus watch filter. The pair shares a run_id of the form
consult-YYYYMMDD-HHMM-xxxxxxxx, and a panel emits one pair per member. Both
events carry backend, model, effort and role (after defaults), thread
(empty for one-shot), dir (absolute workspace), repo and commit (with --repo, or a
--repo thread),
panel, prompt (first line, up to 120 characters) and prompt_file (with
--prompt-file). consult:completed adds duration_ms, success, and on
failure a short error such as timeout after 10m0s or interrupted; it never
contains model output. SIGINT and SIGTERM still produce the completion, so
every start has a matching completion unless the process is killed with
SIGKILL. Consults outside a session emit nothing.
Each backend call also leaves a job record in
~/.klaus/sessions/<session-id>/jobs/<run_id>.json, written when it starts and
again when it ends, so you can see what is running and how it ended: klaus status and the dashboard list every running job and those that finished in the
last 3 hours, and klaus jobs lists them on their own (--all for every record
kept). A record holds kind (consult or review), thread and turn,
role, backend, model, effort, dir, repo and the commit a
--repo consult read (a review's pr), the
prompt excerpt, started_at and finished_at, the klaus pid (with its
start time from /proc/<pid>/stat on Linux, so a reused PID isn't mistaken
for it), and status: running, succeeded, failed, timed out or
interrupted, with a short error. A record still running whose process is
gone, as after SIGKILL, reads as died. Panel members share a panel ID.
klaus jobs show <id> prints a record and its answer, and --answer only the
answer, so a background consult needs no redirect of its own: a thread's answer
is read from its log, a one-shot consult's from <run_id>.answer beside the
record, and a failed one-shot consult keeps what its backend wrote in
<run_id>.raw. <id> can be a panel ID (every member is shown) or a unique
ending of a job ID, such as the four characters the dashboard shows. Records of
jobs that finished more than 7 days ago are removed when a new job starts.
klaus consult --thread design "Draft the migration plan" & # runs in the background
klaus jobs # what's running, what ended
klaus jobs show 1a2b --answer # read it when it's done
- In a repo: a fresh git worktree is created from
origin/main. Anywhere else: a scratch workspace under~/.klaus/sessions/ - The selected backend starts interactively in that workspace
- You talk to Claude as usual — it has
klauson PATH - When Claude runs
klaus launch, an autonomous agent starts in a detached tmux session — off your screen, but still a real process you can inspect and kill - Agents push branches and open PRs —
klaus dashboardpicks them up automatically - The pipeline monitors CI, dispatches fix agents on failure, and auto-merges when approved
- Your job shifts from babysitting agents to reviewing and approving PRs
klaus cleanup --done(orxon the dashboard) clears what's finished; when you're done,klaus cleanup --alltears everything down
Klaus is repo-agnostic. You can run it from your home directory and target any repo with klaus launch --repo owner/repo or set a session default with klaus target owner/repo.
The coordinator's Claude Code persistent memory is shared across sessions: each session's project memory directory (~/.claude/projects/<encoded-path>/memory) is symlinked to ~/.klaus/memory, so what the coordinator learns in one session carries over to the next. Existing memory files from older sessions are migrated into the shared directory when a session is resumed. Launched agents don't share this memory — each agent starts clean.
The session is the experience. The pipeline handles the rest.
Once an agent opens a PR, the dashboard's event-driven pipeline takes over:
PR created → CI pending → CI passed → Approved → Merged
↓ ↓ ↓
CI failed Changes Req. Conflicts?
↓ ↓ ↓
Fix agent Fix agent Rebase agent
- CI fails — a fix agent is dispatched automatically (via
--pr) to push a correction - Review comments — an agent is dispatched to address requested changes
- Approved + CI green + no conflicts — auto-merge (when
auto_merge_on_approvalis enabled) - Behind the base branch — the branch is updated from base first, then merged once CI re-runs
- Merge conflicts — a rebase agent resolves them before merging
Each fix agent's run records why it was dispatched (ci-fix, review-fix, review-comment-fix or conflict-fix); see Where agent time goes.
When a pr-fix run is already active on a PR — including coordinator-launched runs from klaus launch --pr — the pipeline will not auto-dispatch additional fix or rebase agents on it. This prevents races during multi-step refactors where intermediate commits may fail CI before the run completes.
Pipeline stages per PR: ci_pending → ci_passed → approved → merged, with failure paths back through ci_failed or changes_requested. Budget-exhausted agents land in budget_paused — see Budget pause and resume below. A PR whose merge or branch update keeps failing is retried once a minute for three attempts, then moves to stalled and emits a single pipeline:stalled event for a human to pick up.
You can also drive the pipeline manually with klaus approve and klaus merge.
A valley's host runs the required checks of each ready change, and valley changes --json --checks lists where they stand on the change's active version (checks: pending, failed or passed, with each required check's state and, for a failed one, why). klaus treats them as a PR's CI: klaus status and the dashboard show them in the CI column (CI …, CI ✗ with the failed checks' names, CI ✓), the dashboard emits change:checks-failed and change:checks-passed once per verdict, and it dispatches a run to fix each failed head of a ready change klaus owns (its record's owner is the repo's valley.principal), the way the pipeline dispatches a fix agent for failing CI. The checks are a column, not a status: a ready change reads awaiting-approval (READY) whatever they say, since you may approve it before they finish, and it lands once both hold. klaus asks for them with --checks, since the host takes a few seconds per project to compute them. A valley CLI that refuses the flag (one from before it existed) is listed again without it, and klaus remembers the refusal for 10 minutes before trying the flag again, logging the fallback once at info level; a valley that lists no checks shows nothing and dispatches nothing. Any other failure of the listing is an error, never "no checks".
After each integration check the dashboard launches klaus launch --change <change-id> --run-id <id> --expect-owner <principal> --expect-branch <branch> --expect-head <head> --reason checks-failed <brief> from a clone of the repo. The brief names the failed checks and what the worker said of each, how to read the worker's build output (valley telemetry --json read --source worker --logs, paging with --since), that the run amends the change's single commit, and that it builds every check valley checks --names origin/main HEAD lists before it pushes. As for CI, klaus never dispatches while a run works on the change, acts once per failed head, counts a fix run whose next version still fails as a failed attempt, and stops after 3 until the checks pass. What it did is kept in the session's checks-dispatches.json. klaus failed-checks does the same once, for a session with no dashboard.
A valley's restacker rebases ready changes onto main after each landing. When a rebase conflicts, it writes a report beside the change (refs/the-valley/restack/main/<change-id>), and valley changes --json lists the latest one with the change, saying whether it is current: whether every ref it observed still holds what it observed. After each integration check, about once a minute, the dashboard reads those reports for the valley repos its runs deliver to, and dispatches a run to resolve each current conflict on a change klaus owns, the way the pipeline dispatches a rebase agent for a PR. klaus restack-conflicts does the same once, for a session with no dashboard.
- Ownership — a change is klaus's when its record's owner is klaus's principal (the repo's git config
valley.principal). The latest version's uploader doesn't count: the restacker uploads the versions it rebases. - Only current reports, read again — a report counts while the valley says it is current and the change's record, active version, head and branch match it, and each other change it rebased is listed on the branch the report names. Before dispatching, klaus reads the change and origin's refs itself (main, the report, and each rebased change's record and the branch its record names) and skips the report if any moved.
- Draft first — klaus returns the change to draft (
valley unready) and launches nothing unless the valley then lists it as klaus's draft at the same head and branch. A failed unready leaves the change as it was, and klaus says why. - The dispatch —
klaus launch [--repo <project>] --change <change-id> --run-id <id> --expect-owner <principal> --expect-branch <branch> --expect-head <head> --expect-draft --reason restack-conflict "<brief>", from a clone of the repo. The--expect-*flags have launch check that the change is still klaus's draft at that branch and head: under its continuation lock it fetches the branch and asks the valley just before it creates the worktree, and asks both again just before the worker starts, after all other setup. It fails closed, rather than start a worker on a change that was readied or moved meanwhile, or that it couldn't check: a valley or origin it can't ask, runs it can't read, or an unready that fails or that the valley on PATH doesn't offer (seeklaus launch --change). Launch's own refusals still hold: a run working on the change or its branch, or a branch with work origin lacks. The run rebases ontoorigin/mainand pushes with the leased push its prompt gives; finalize marks the rebased head ready. - Nothing from the report is run or passed on — the brief names the change, its version, head and branch, and main's tip, as klaus read them. The report's conflict paths and refusal text stay out; the run finds the conflicts by rebasing.
- Limits — as for a PR's conflicts: no second run on a change, 60 seconds between dispatches, and at most 3 runs until a report says the change restacked cleanly (one reset per clean report, and only while origin still holds it, so a listing read before a newer dispatch can't erase it). klaus acts once on each set of inputs (the change's head and main's tip) and remembers what came of each, so an unchanged conflict isn't retried, nor one that comes back to inputs klaus acted on before, nor one whose unready or launch failed; a conflict back on old inputs shows what came of them then. Once no run is working on a change whose attempts are spent, klaus says it stopped, even when the last run left the conflict as it was.
- Conservative accounting — klaus records the attempt and the run's ID before it launches, and gives the attempt back only when it knows no worker started: it stopped before launching, or launch exited with status 3. Any other launch failure keeps the attempt and the cooldown, and so does a pass that ends mid-launch; either leaves the launch pending, and the change busy, until a run by that ID is recorded (a dispatch) or klaus proves no worker of it runs: no launch holds the clone's continuation lock, no run by the ID is recorded, and no live tmux pane's command names it. Time alone settles nothing. Launch stops a worker whose run it couldn't record, so every worker that runs is recorded or found by its pane.
- Left alone — a conflict in another change below this one (klaus never takes over a change it doesn't own), and a conflict in a change that sits on other changes.
Each dispatch emits change:restack-dispatched under the new run's ID; a conflict klaus stops short of resolving (attempts spent, unready or launch failed) or leaves alone (it sits on other changes, or the conflict is in a change below) emits change:restack-stalled once for each set of inputs, with a reason: the change needs the coordinator. What klaus did is kept in the session's restack-dispatches.json, which is bounded: a change's entry goes once the change lands or is withdrawn (unless a launch for it is pending), and an entry keeps what came of its latest 16 sets of inputs; older ones may be acted on again. An older klaus's ledger is read and rewritten in this form. klaus status lists where each conflict stands below its runs table and shows restack in the CONFLICTS column; the dashboard shows restack conflict on the change's row, and a conflict klaus stopped short of resolving or left alone makes the row yours.
Pipeline events (PR created/approved/merged, CI passed/failed, branches pushed without a PR, valley changes marked ready, approved, integrated, stale, rejected, failed, withdrawn or given a new version, valley checks failed or passed, agents needing attention, agent errors, restack conflicts dispatched or stalled), consult events (consult:started, consult:completed) and review events (review:started, review:completed) are appended to ~/.klaus/sessions/$KLAUS_SESSION_ID/events.jsonl and exposed as a streaming channel via klaus watch. It's designed for Claude Code's Monitor tool — each matching event becomes a notification injected into the coordinator's context between turns, so the coordinator can react to merges or CI failures without you having to mention them. Each PR's pr:approved and pr:merged fire once, across dashboard restarts; a PR already merged when klaus first sees it emits neither. A valley change's change:approved, change:integrated, change:stale, change:rejected, change:failed, change:withdrawn, change:new-version, change:checks-failed and change:checks-passed do the same, from the dashboard's integration check (see Repos that deliver to a valley): wait on change:integrated for a landing rather than polling valley status.
klaus watch # default filter, follow new events
klaus watch --since-start # replay everything in the file, then follow
klaus watch --filter pr:merged # narrow to a single event type (repeatable)
klaus watch --filter-out pr:approved # subtract from the default filter
klaus watch --filter pipeline:stalled # only PRs the pipeline gave up on
klaus watch --json # raw JSONL passthrough
klaus watch --run <run-id> # only one run's events (a PR number for pipeline events, a handle for a change's)
klaus watch --list-types # show live and reserved event types
klaus watch --once # wait for the next events, print them, exit
klaus watch --once --after 18342 # ...from where the last one's next: line left off
klaus session and klaus new create events.jsonl when they create or resume a session, so a watch armed at session start has a file to follow. A watch in a session with no events file yet (one from an older klaus) waits for it for as long as the session's directory exists, and reads it from the start once it appears; a watch on an unknown session ID fails at once.
klaus watch --once is for a coordinator that can't take a stream of notifications. It waits until at least one matching event has arrived, prints every matching event that has, and exits 0. Its last line is the command that waits for the events after those:
17:02:03 20261026-1701-abcd agent:pr-created PR #85: https://github.com/acme/widget/pull/85
next: klaus watch --once --after 18342
--after is a byte offset into events.jsonl, always at the start of a line, and the next: line keeps the other flags given (--filter, --filter-out, --run, --json, --session-id). Starting each watch from the last one's next: line delivers every event exactly once: events that land between two watches are past the offset, so the next one prints them at once, and running the same command again prints the same events, so a watch that died without a next: line loses nothing. Without --after, --once starts at the end of the file (at its start with --since-start). Interrupted before any event, it prints just its next: line. With --json the last line is {"after":18342,"next":"klaus watch --once --after 18342 --json"}. An --after past the end of the file or inside a line is an error, and so is an events file replaced or removed while a watch waits on it.
The coordinator session's system prompt tells a Claude coordinator to arm a persistent Monitor on klaus watch at startup, and an agy or Codex coordinator to run one-shot watches as background tasks, starting the next from each one's next: line, the first from the offset the events file had when the session started. The dashboard reads the same events.jsonl file independently — both consumers can run side by side.
Every run event (agent:*, phase:*, check:*, and pr:approval-changed from klaus approve) carries fields that say what the run worked on and why, alongside its existing fields:
| Field | Meaning |
|---|---|
repo_key |
The repo, qualified: owner/repo on GitHub, valley:<name> for a repo that delivers to a valley, else the repo's short name |
change_id, pr_number |
The run's change, and its PR once it has one |
key |
The change, qualified by repo_key: owner/repo#12 once the run has a PR, else <repo_key>@<change_id> |
dispatch_reason |
Why the run was launched (below) |
head_sha |
The branch tip finalize found on origin, else the worktree's HEAD when the agent finished, else when it started |
The pipeline's PR events (pr:approved, pr:merged, agent:ci-*, ...) carry repo_key and key when their PR URL is known, and a valley change's transitions (change:approved, change:integrated, ...) carry them for the change; review:* events carry them for the reviewed PR, and consult:* events carry repo_key for the consult's workspace. Fields that aren't known are left out, and no existing field changed.
The dispatch reason is launch for new work, pr-follow-up for --pr, budget-resume for --pr on a budget-paused PR, change-follow-up for --change and resume for --resume-from; the pipeline's fix agents are ci-fix, review-fix (changes requested), review-comment-fix (a trusted reviewer's comments) and conflict-fix, and a run resolving a valley change's restack conflict is restack-conflict. klaus launch --reason <reason> names any other, such as docs-timer for a timer's launch.
Each run's phases are events too: phase:completed with phase: setup (launch's work before the agent starts: fetch, worktree, hooks, pane), then phase:started/phase:completed for agent (pane start to finalize; with cost_usd where the backend reports it) and finalize (up to killing the pane). Each phase:completed carries started_at, ended_at and duration_ms. A launch that fails records no run, so setup has only a completion.
Checks an agent runs, where klaus can see them, are check:started/check:completed with kind, command, source, and at the end duration_ms and success when known: klaus _pre-review reports itself (kind: pre-review, with lint_ms, review_ms, findings, blocking and lint_failed), and _format-stream times the build, test and lint commands it sees in the agent's stream (go test, go vet, cargo build, npm test, make <target>, nix flake check, ...) from the line that calls one to the line with its result. A command line that runs a check and klaus _pre-review, such as go test ./... && klaus _pre-review, is a check with includes_pre_review: true; klaus report time takes the pre-review's own interval out of it. Launch exports KLAUS_RUN_ID in the agent's pane so both know the run. A sandbox run's agent gets it on the sandbox host too, with KLAUS_RUN_EVENTS=output: there _pre-review can't reach the run's event log, so it prints its check:completed as a klaus-check-completed: {...} line, which the agent's stream carries back to the pane's _format-stream to record (via: stream). If the agent cuts that line from the output (| tail -1), or the sandbox's klaus predates it, _format-stream times a call that runs klaus _pre-review and no other check from the stream instead (source: stream); a line such as go test ./... && klaus _pre-review may have stopped before the pre-review, so it gets no pre-review check then. Launch saves the run just after the pane starts, so a check that ends first waits up to two seconds for it, then is recorded under the run's ID without the run's fields, which klaus report time takes from the run's other events. These events are not in klaus watch's default filter.
klaus report time reads the event logs of every session (--current for the current one only) and sums, per repo and overall, the runs that started in the window (--since, default 7d; 0 for all time) and the consults and reviews: runs by dispatch reason, time by phase, checks by kind, the time a change had no run going between its runs (from the last end among its earlier runs, since runs can overlap; unknown after a run with no end), the time from a PR's last run to its first approval, follow-up runs per change and their reasons, consult and review time, and cost where known. Runs that share a change ID in a repo, or a PR, are one change: a PR's runs from before change IDs join its later ones, a Change-Id copied into another repo is another change, and every name a run's events give its repo counts, so a renamed repo's runs stay together. --repo keeps one repo, named by key, valley name or short name; --json prints the report with durations in milliseconds.
klaus report time # the last week, every session
klaus report time --since 2w --repo klaus
klaus report time --current --json
Events from before these fields exist still count: a run without phase events has its agent time from agent:started to its last event, a run without a recorded reason counts as unknown, and a run that names its repo only by short name takes the qualified key other runs give that name when there is just one.
When an agent exhausts its --budget cap, klaus does not silently kill the worktree. Instead it parks the in-progress work on GitHub:
- Commits any uncommitted changes in the worktree (
WIP from klaus run <id> (budget paused)). - Pushes the branch to
origin(with--force-with-lease). - Opens a draft PR — or updates the existing one — and applies the
klaus:budget-pausedlabel. - Posts a one-line PR comment explaining the pause and how to continue.
- Cleans up the worktree and tmux pane.
Claude structured result events remain authoritative when the process exits nonzero after reaching its budget or turn limit; those stops remain resumable.
The draft PR + label is the persisted state — klaus does not keep an in-process "paused" status. The dashboard surfaces these as budget paused, awaiting decision via the pipeline FSM (which reads the label off GitHub).
To resume, dispatch a fresh agent against the PR's branch:
klaus launch --pr <num> "continue the work"
The new agent sees the WIP commit and picks up from there. When the follow-up agent's _finalize runs, the klaus:budget-paused label is automatically removed and an agent:resumed event is emitted. If the follow-up agent also exhausts its budget, the cycle repeats (a new WIP commit, label re-applied).
By default, klaus launch --pr against a budget-paused PR, and klaus launch --change on any change, does more than pick up the branch's work: it continues the previous agent's Claude conversation instead of starting one cold. At finalize time klaus commits the resume-able conversation trajectory (the JSONL claude itself writes under ~/.claude/projects/…) to the local clone's refs/klaus/data at sessions/<run-id>.jsonl. On resume it makes a best-effort fetch of the ref from origin, reads that blob from the local ref, restores it into the new worktree's project dir, and invokes claude --resume <uuid>. The conversation continues from the exact point of pause — no re-grepping or re-orienting — which is faster and cheaper than a fresh agent.
Only the most recent run on the PR (or the change) is replayed: an older conversation never saw the work that came after it, so when the most recent run's conversation can't be resumed, the agent starts fresh rather than resuming an older one. Replay is best-effort and falls back to a fresh agent when any of these hold; the replay: line names the run and the reason:
- The most recent run used another backend: a Claude worker can't resume a Codex conversation, or the reverse. A Codex worker continuing a Codex run forks that run's session (
codex exec fork <thread>) instead of replaying a stored trajectory; that works on the machine that ran it, not on a sandbox host. agy workers can't resume a conversation and always start fresh. - The trajectory was never stored (skipped because
klausdetected potentially sensitive content), or the PR pre-dates this feature. - The paused agent ran on another machine. The data ref stays in the clone that ran the agent unless
push_data_refis true there, so replaying on a different machine needs that push. - The trajectory is larger than the size threshold (default 300KB; replaying a very large trajectory can cost more than re-exploration). Configure with
replay_threshold_kbin~/.klaus/config.jsonor--replay-threshold-kb. - No Claude session UUID could be determined for the paused run.
- You pass
--no-replay.
Flags:
klaus launch --pr 42 "continue" # replay if eligible (default)
klaus launch --pr 42 --no-replay "continue" # force a fresh agent
klaus launch --pr 42 --replay "continue" # force replay, bypassing the size threshold
klaus launch --pr 42 --replay-threshold-kb 500 "..." # raise the per-launch threshold
Staleness semantics — what you opt into with replay. The restored conversation is a snapshot from when the previous agent paused, replayed into a new worktree at a different path:
cwd/ path mismatch. The trajectory's tool calls reference the original (now-deleted) worktree path; the resumed agent runs in a fresh worktree.claudere-anchors to its current working directory, so new file operations are correct, but the conversation history still mentions the old path.- Tool-result staleness. Cached
Read/grep results in the history reflect the files as they were at pause time. The branch head carries the WIP commit, so current contents may differ. This is the same staleness any long-livedclaude --resumehas; the agent is expected to re-read before relying on stale output. klaus adds no special mitigation beyond restoring the trajectory faithfully. - No prompt-cache benefit. Resumes happen long after the 5-minute cache TTL, so the replayed history is re-read as fresh input tokens. The size threshold exists precisely to keep that cost below the cost of a fresh re-exploration.
Note: replay relies on the conversation file being available where finalize runs. For agents executed on a remote
sandbox_host, the conversation file lives on the sandbox and is not synced back, so those runs fall back to a fresh agent.
To abandon the work, close the draft PR. To redirect, push manual commits to its branch.
klaus launch reads the target repo's origin to decide how the agent delivers. A github.com origin (git@github.com:o/r.git, https://github.com/o/r, ssh://git@github.com/o/r.git) gets the PR workflow. Any other origin, such as a plain ssh remote like git@10.100.0.1:cosmo.git or a local path, is a direct-push repo: the default worker prompt tells the agent to push its branch, not to open a PR or use gh, and to start its final report with the run ID, the branch and its head SHA, which is what you review before integrating. Launch prints delivery: push branch, no PR for these runs. Sandbox runs are decided from the same local repo their worktree is made from. Launch records the decision on the run as delivery (pr or push; a --pr fix is always pr), and _finalize honours it: a run that delivers by push never records a PR, even when its log carries a GitHub PR URL, such as a link the agent read or a test fixture. A run recorded before delivery existed is decided from its clone's origin the same way.
A run that finishes cleanly with its branch already on origin — the normal finish in a direct-push repo, where you integrate branches by hand instead of through PRs — is not salvaged. _finalize checks origin with git ls-remote before cleanup, records the branch and its head SHA on the run (pushed_branch, pushed_sha), and emits agent:completed plus agent:branch-pushed with repo, branch and sha. klaus status shows the run as awaiting-integration, and the dashboard lists it as yours to submit: SUBMIT <branch>@<short sha> (a repo that delivers to a valley goes further; see Repos that deliver to a valley). The local branch is deleted; the branch on origin is the record. Runs with a PR emit agent:pr-created instead, never both.
The branch and change finalize records are the ones the run pushed, which aren't always launch's: an agent launched to continue a change (--resume-from, --change) may make a new change instead, switching to a branch of its own and committing with its own Change-Id trailer. Finalize looks at the branch the worktree ends on and launch's branch, other than the default branch. A branch's head on origin counts as the run's push when the local branch holds it, as it does after a push from the worktree, and the head the run started from doesn't. Of those heads, the run's change is the one its final head belongs to: the pushed head whose Change-Id trailer matches the final head's, else the head of the branch the worktree ends on. Finalize records that change as the run's change_id, its branch as branch and pushed_branch, and its head as pushed_sha, and emits agent:change-recorded with change_id, branch and head as recorded, launch_change_id and launch_branch, and, when the run pushed heads of more than one change, pushed, listing each head's change_id, branch and head. That event comes before agent:completed, and a valley's valley ready marks the recorded head ready as a version of the recorded change, so the dashboard and klaus status show the run under the change it pushed. A run that moves to another change drops the runs launch recorded as taken over (took_over), which were the launch change's, and launch's unready record keeps the change it returned to draft as unready.change_id. A run that pushed nothing new, or nothing that names another branch or change, keeps launch's values.
Whether a pushed branch has landed is observed, not recorded: klaus status and the dashboard check it against the run's own clone (clone_dir) each time they look, and nothing is written to the run. One check per repo takes a snapshot of origin with git ls-remote, fetches the default branch and the pushed branches that still exist, and then judges each run:
- integrated (
INTEGRATEDon the dashboard): the pushed SHA, or the branch's current tip, is an ancestor of the default branch. The current tip counts because you may rebase the branch onto the default branch and force-push it before it fast-forwards. - integrated by content (
INTEGRATED~): a first-parent commit on the default branch, made after the run started, has the samegit patch-id --stableas the branch's change (its diff from the merge base). This catches an integrator that lands a change as one new commit on the default branch. It is strong evidence, not proof. If the default branch changed lines next to the edit, the patch-ids differ and the run keeps waiting; check it yourself. - branch-gone (
GONE): the branch was deleted fromoriginand neither check matched, for example a rejected or superseded change. - otherwise awaiting-integration (
SUBMIT).
If the clone is missing or origin can't be listed or fetched, the run stays awaiting; an incomplete check never reports a branch as gone. A deleted branch also stays awaiting when the clone can't rule out that it landed: the pushed SHA was pruned from the clone, the clone's history is too shallow to find a merge base with the default branch, or the content check stopped at its cap of 500 default-branch commits before reaching the run's start.
A direct-push repo can deliver to a valley, the self-hosted integration host from the-valley, instead of being integrated by hand. Finalize treats a repo as a valley's when its origin isn't GitHub, the pushed head carries policy/valley or a policy/instance/ directory, and the valley on PATH lists the ready and changes verbs in valley help. Any other repo, including one whose valley predates those verbs, is handled as described above.
In a valley's repo, once agent:branch-pushed is out, finalize marks the pushed head ready as a version of the run's change:
valley ready --change <change_id> --head <pushed_sha> <branch>
The change is the run's change_id, the jj-form ID klaus stamps on every agent commit as its Change-Id trailer. The valley identifies the pusher from git config valley.principal, which the host sets, not klaus. On success valley prints <repo> <handle> v<n> ready. Finalize records the repo, the change's handle, the version and the head's subject (its title) on the run as valley, and emits agent:change-ready with repo, change_id, handle, version, title and head. The call is bounded by 30 seconds. If valley refuses, times out or prints anything else, finalize records a short reason as valley.error, prints a warning, and finishes as usual; the dashboard shows the run as SUBMIT with the reason.
What you approve is what shows as ready, so a change shouldn't show as ready before its review is done. Set "ready_at_finalize": false in ~/.klaus/config.json, or in a project's .klaus/config.json to cover that project alone, and finalize records the version but leaves the change a draft awaiting review. It defaults to true. Finalize reads the setting from the checkout the run's worktree belongs to (its clone, or the main checkout of the repo it was launched in) over ~/.klaus/config.json, never from the agent's worktree, so an agent can't change it.
The valley has no verb that records a version as a draft, so finalize runs valley ready as above and then, at once, valley unready <change_id> (bounded by 30 seconds). It records the version on the run with valley.draft set, and emits agent:change-draft, with the fields agent:change-ready has, instead of agent:change-ready. Approve refuses a draft, and the integrator lands nothing for one, so a request filed in the moment between the two calls stands until the change is ready again. If valley help doesn't list unready, finalize doesn't ready the head at all and records why as valley.error. If unready fails, the change stays ready: finalize records the failure as valley.error, prints a warning and emits agent:change-ready.
klaus status shows such a run as awaiting-review, and the dashboard as REVIEW in the pipeline group, with no approve command; the footer offers the klaus ready <handle> --head <head> that marks the head on screen ready. Once the change's review passes, the coordinator runs:
klaus ready <run-id|handle> --head <reviewed sha>
It marks the change's current head ready (valley ready --change <change_id> --head <head> <branch>, run in the run's clone): its active version's when the valley lists the change, else the head its newest run, in any session, pushed. --head names the head you reviewed, in full or by at least 7 hex digits: klaus ready then marks exactly that head ready, and refuses, naming the current head, when the change has moved on to another, as when a follow-up pushed after the review. With --head, the current head is the tip of the change's branch on origin when that differs from the valley's active version, as when a follow-up's push isn't a version yet (its valley ready failed); if origin can't be asked, it refuses. Without --head it readies whatever the current head is. A draft at its active version's head is ready again at that version. It clears valley.draft on this session's run that pushed the head, if any, emits agent:change-ready with "via": "klaus ready", and prints <repo> <handle> v<n> ready at <head> (run <id>). The change then reads READY (awaiting-approval). <run-id|handle> is a run ID, or the change's valley handle, change ID or a prefix of it, as this session's runs record them; one that names several changes is an error listing them. klaus ready refuses while an agent in any session is running on the change, and when the run's repo doesn't deliver to a valley; it holds the lock that --change, --pr and --resume-from launches in the same clone take, so a follow-up can't start between that check and the ready.
klaus status and the dashboard then ask the valley where each change stands, with one valley changes --all --json per check, covering every repo. They read the valley-changes/v1 view and refuse any other schema. A run's version is the one in its repo, with its change ID, whose head is the run's pushed SHA. The run's repo is the one valley ready reported; or else, for a run that continues another's change, that run's repo, which launch copies onto the new run as change_repo; or else the one launch's valley unready reported; or else the last component of its target repo without .git; a run whose repo klaus doesn't know is never matched, and neither is a run in a repo the listing marks with an error, whatever records it lists beside it:
| Dashboard | klaus status |
Group | The valley's record |
|---|---|---|---|
REVIEW |
awaiting-review |
pipeline | a draft finalize left for review (ready_at_finalize off) that klaus ready hasn't marked ready since; approve refuses it |
READY |
awaiting-approval |
yours | the run's version is its change's active one, ready and waiting for your approval |
APPROVED |
approved |
pipeline | an integration request is filed and nothing is asked of you: the integrator hasn't decided it yet, or it judged the request stale only because the version's checks are pending (and no current restack report says conflict) and re-judges when the host's check worker finishes. The CI column shows the checks' state; no change:stale |
INTEGRATED |
integrated |
done | the version landed (landed or landed-recorded) |
UNCERTAIN |
uncertain |
yours | a request was consumed or failed with no landing evidence, or the record holds a value klaus doesn't know, on a change that isn't a draft; look at it |
CHECKS FAILED |
checks-failed |
yours | stale, and the version's checks failed (shown in red) |
CONFLICTING |
conflicting |
yours | stale, and the change's current restack report says conflict |
STALE, REJECTED, FAILED |
stale, rejected, failed |
yours | the integrator's decision on the request; stale here means the checks passed, or the valley lists none, so the evidence was lacking for some other reason |
WITHDRAWN |
withdrawn |
done | the change was withdrawn |
OLDER VERSION |
older-version |
done | the change has a newer version, made by the restacker or a later run; this says nothing of whether the change is done: the dashboard shows the change's row as its active version stands |
DRAFT |
draft |
yours | the change was returned to draft (valley unready), as launch does for a follow-up run, and no run of it is running; whatever the valley says of its active version short of landing, even uncertain, there is nothing for you to do: approve refuses it until a head is ready again |
A change in a state klaus doesn't know isn't matched. A version that landed shows INTEGRATED even after a later version replaced it, or its change went back to draft. The valley is asked only when finalize found one for some run. If valley fails, or a run isn't in its listing, the run is checked against its clone as described above, and a change waiting on that check reads READY (awaiting-approval), or REVIEW (awaiting-review) when finalize left it a draft. A deleted branch alone never settles a change finalize marked ready: it stays yours (awaiting-approval) rather than GONE. When the dashboard has seen the valley's record of a change and a later check can't place it, the row keeps that last verdict, marked (last known), unless git finds the work landed. A failed listing (the valley gets three minutes to answer) is logged at warn level once per streak of failures, and once when it recovers; while the latest check failed, the dashboard shows valley unreachable: <reason>; showing git's view above its footer, and klaus status prints the same line on stderr.
The dashboard also reports each change's transitions as events, as the pipeline reports a PR's pr:approved and pr:merged (see Real-time event channel). Its check reads, for the changes of the session's runs and tracked changes, the change's own record in the listing, whatever became of any one run's version, and records what it saw in the session's change-transitions.json: the highest version and its head, the version first seen approved, the landing and its commit, the last outcome (stale, rejected, failed or uncertain), and the withdrawal, each with when it was first seen. A mark the file didn't hold is reported once, under the change's handle:
| Event | When |
|---|---|
change:approved |
a ready version has an integration request: you approved it |
change:integrated |
the change landed (landed or landed-recorded); carries landed_commit |
change:stale, change:rejected, change:failed |
the integrator's decision on the active version's request; again only for a new version or a new decision. A stale decision shown as APPROVED (checks pending) is not reported; if it later turns stale for a real reason, change:stale fires then |
change:withdrawn |
the change was withdrawn |
change:new-version |
a version neither this session's finalize nor its klaus ready recorded: the valley's restacker's, or a human's; carries uploader |
change:checks-failed, change:checks-passed |
the host's check worker failed, or passed, the required checks of a ready change's active version; again only for a new version or head, or the other verdict; carries failed, the failed checks' names (see Failed checks on valley changes) |
Each carries repo, handle, change_id, and the version, head and title of the version it concerns, with repo_key (valley:<repo>) and key (valley:<repo>@<change_id>) as run events name the change. A version is this session's when one of its runs pushed its head or records it. A change the file holds nothing for, as on a session's first dashboard start, is recorded as it stands, with no events, so a restart reports nothing again and a first start reports no history. Dashboards on one session share the file, locking change-transitions.json.lock while they update it, so only one of them reports each transition. A listing older than one a later version was recorded from, as another dashboard's or an earlier check's can be, reports nothing of the superseded version but its landing: its approval, outcome or withdrawal is old news. The events come off the dashboard's check, once a minute; klaus status only reads, so without a dashboard nothing reports them.
A run that continues a change — --change, --pr, --resume-from, or a pipeline fix, any launch whose change ID is inherited (see Provenance trailers) — pushes more commits to it. On the dashboard, the runs without a PR on one change share a row, as the runs on a PR share the PR's row (see klaus dashboard): while a follow-up runs, the change's row names it (agent:<short run id> running), sorts with the agents and never offers the approve command, and the footer says which agent is working on it (agent:<short run id> is working on this change). When it exits, the row goes back to where the change stands. Runs share a change when they have the same change ID in the same repo, the repo named as above (the one valley ready reported, else the repo of the run a follow-up continues, else the one valley unready reported, else the last component of the target repo); a run without a change ID shares it with none. A follow-up launched from a run takes that run's repo even when its own target repo has another name (a valley's widget launched as acme/source) and valley unready didn't run or failed. A run is running when its pane is running it, judged the same way by the dashboard and klaus status: a pane whose command exited, or a finished run's pane left idle at a shell, isn't running. klaus status stays per run: a run that isn't running or done shows working while another run of its change is running.
In a valley's repo, launch also returns an inherited change to draft before the agent starts, so the valley's approve refuses it while the agent replaces it:
valley unready <change_id>
Launch checks the repo as finalize does, against the commit the worktree starts from, and runs this only when valley help also lists unready. On success valley prints <repo> <handle> v<N> draft, naming the version that was ready (a valley that names none prints <repo> <handle> draft), and launch records the repo and handle on the run as unready. A change that wasn't ready (already a draft, or no record) is noted and left alone. The call is bounded by 30 seconds; a refusal, timeout, unexpected output or other failure is recorded as unready.error and printed as a warning, and the launch goes on. Finalize's valley ready marks the pushed head ready again: the valley re-readies the same version when the head is unchanged, or adds a version.
When a run finishes without a PR for any other reason (for example a Claude session-limit stop, or uncommitted changes left behind) or crashes, _finalize salvages the worktree instead of discarding it:
- Commits any uncommitted changes, including every untracked file
.gitignoredoesn't exclude (wip: klaus finalize salvage <id>). Build output belongs in the worker's$KLAUS_SCRATCH(see Under the hood), outside the worktree, so it never reaches this commit. - Pushes the branch to
origin(plaingit push -u, never forced) unlessorigin's live tip already matches. Push failures are logged; nothing is deleted. Branches of runs that already have a PR are not pushed. - Keeps the local branch and removes the worktree (the worktree is kept if the WIP commit failed). Finalize never deletes a branch unless its work is on the default branch or
origin's live tip (checked withls-remote, not cachedorigin/*refs) matches it; an unreachable remote counts as not pushed. - Emits
agent:needs-attentionwithbranch,pushed, andreason(no_pr,session_limit,background work still running (<commands>), or the crash reason) instead ofagent:completed.klaus statusshowsneeds-attention; the dashboard showsATTN. The attention stays with the run until a run of its change records taking its pushed work on (took_overin its state): launch records it for a--pr,--changeor--resume-fromrun that continues a branch holding the salvaged run's pushed head (thetakes on:line names it), and finalize records it when the head a run left on origin contains that head. Once that run is running or finished, it carries the attention:klaus statusshows the salvaged runsuperseded, the dashboard no longer counts its attention, andklaus notificationsleaves itsagent:needs-attentionout of the summary. If the run that took the work on in turn ended needing attention or failing,klaus statusshows itneeds-attentionand its dashboard row stays yours (ATTN), even once its PR is merged, closed or out of reach: what became of the PR settles nothing about the work it took on. One that never finished and isn't running hands nothing off. A--resume-fromrun that got a new branch takes nothing on, and creation times play no part. The records and events of both runs stay as they are.
A run ends when the agent's turn ends, so an agent that stops while a build it started is still running never sees the result, and cleanup interrupts the build. On Linux, finalize looks for processes still running inside the worktree and records them as the run's failure_reason, for example background work still running (nix).
Runs that ended without a PR, left no commits beyond the default branch, and left no background work running still finish as agent:completed.
There is no subcommand named klaus resume or klaus finalize — resuming happens through flags on klaus launch, and _finalize handles the WIP commit automatically:
klaus launch --pr <num> "..."resumes a budget-paused PR (WIP commit + trajectory replay, as above).klaus launch --change <ref> "..."continues a change in a repo without PRs on its own branch, replaying the conversation of its most recent run (seeklaus launch --change).klaus launch --resume-from <run-id> "..."continues any prior run's conversation, paused or not, in a fresh worktree, and keeps everything the previous agent learned — use it to correct an agent instead of killing it and re-briefing a cold one. Prior errors produce a warning but do not prevent resume. It starts fresh if the transcript can't be located or staged (Claude), or the local thread ID is missing (Codex). Combined with--pror--change, it works on that PR's or change's branch. Otherwise, when the run it resumes pushed its branch without a PR (as salvage does at a session limit) and nothing took the change further, it continues that branch, as--changedoes: it keeps the change ID, its worktree tracksorigin's branch, and the agent pushes fast-forward to it, and opens the PR from it where the repo takes PRs. It refuses as--changedoes (see A run still working on the branch). It continues the branch only when the branch is still that change's and nothing took the change further: the run is in the target repo (by name and, where both are known, by GitHub owner); no run of the change has a PR;originhas the branch; no run of another change worked on the branch, andorigin's tip carries no other change'sChange-Id; the change's branch (the valley's active version's, else its newest push's) is still that branch; that branch has that run's push in its history, or a later head of the change that rewrote it, as restacking the change does: the push of a later run of the change on the branch, or the valley's active version's head (else the newest push's); and, for a repo on GitHub, no PR, open or not, has the branch as its head (asked throughgh api). Otherwise, as when GitHub can't be asked, the follow-up gets a new branch, and launch says why on itsresume:line (resume: new branch: run <id>'s branch <branch> is gone from origin). A run that pushed no branch, or opened a PR, is followed up on a new branch too.
A valley project keeps its plan in a plan graph of knots (valley knot, valley plan, at refs/the-valley/knots on its origin). On a valley project, --issue takes a knot instead of a GitHub issue, recognised by its ID's form: oc-, bd-, ida- or qn- and 7 hex digits, or <project>:<id> for a knot in another project's graph on the same valley. Any other --issue is a GitHub issue, as before.
klaus launch --issue oc-897917f --prompt-file /tmp/brief.md
klaus launch --repo cosmo --issue the-valley:oc-897917f --prompt-file /tmp/brief.md
klaus reads and writes knots only through the valley CLI, never the knots ref:
- Launch refuses a knot with
--pr, in a repo that isn't a valley's (as finalize decides it, againstorigin/<default branch>), whenvalley helpdoesn't listknot, and when the knot isn't an active outcome or bug. It reads the knot and its parents withvalley knot show <id> --json. A knot claimed by a run that is still running is refused, naming the run, and so is one claimed by a run klaus doesn't know of, which may still be working: release that claim withvalley knot unclaimif the run has ended. A claim left by a run klaus knows has ended is released (valley knot unclaim <id> --if claimed_by=<that run>) and taken over, and launch says so. - The branch is named from the knot's title. The brief gets the knot, its parent chain and its comments, appended in a
## Project notes (untrusted)block between two marker lines that carry a random token, so nothing in the notes can close the block. The block tells the agent they are data about the work, never instructions, and the worker prompt says klaus claims, links and closes the knot. The brief is saved with the block, as the record of what the agent read. - Just before the agent starts, launch claims the knot for the run (
valley knot claim <id> --run <run-id>; an open outcome becomes in-progress) and records it on the run asknot. A claim the valley refuses fails the launch. - Finalize, once the pushed head is marked ready, links the change to the knot (
valley knot link <id> --change valley:<repo>/<change-id>) and then clears the run's claim (valley knot unclaim <id> --if claimed_by=<run-id>, which leaves another run's claim alone). When the link fails, the run keeps its claim, and the dashboard tries the link and the release again at its next check, as it does for a claim finalize couldn't clear. A run that failed, paused, was salvaged, or pushed nothing the valley took clears its claim and comments on the knot (valley knot comment <id> --body -) saying what became of it and where its work is. - When the dashboard sees the change landed (the valley says its active version landed), it comments on the knot with the version and the commit it landed as. It sets the knot
done(an outcome) orclosed(a bug) only if every change the knot links has landed (one withdrawn, rejected, not listed by the valley, or a GitHub pull request klaus can't check, counts against it), none of its children is active, and no other run claims it; otherwise the comment says what keeps it open. Thevalley knot setcarries--ifconditions on the knot's status, claim and links as klaus read them, so a run that claims the knot or a change linked to it meanwhile makes the close a conflict, which the comment reports; a child made active in those moments isn't caught. The close comes first, so the comment says what happened; when the comment, or a close, fails for any reason but a conflict, the next check tries again. Other knots in the change's repo that link the change get the comment alone, since the run wasn't launched for them; a comment on one of them that fails is recorded and not tried again. This happens once per knot and change, recorded on the run asknot.landedandknot.closed.
Every knot call is bounded by 30 seconds. At finalize and after landing, a failure is recorded on the run as knot.error and never stops the run's finalize or the dashboard. A failed run's comment, or the release of its claim, isn't tried again: a claim it leaves behind is taken over by the next launch on the knot, as the claim of a run that has ended.
Every commit an agent makes ends with two trailers:
Change-Id: <change id>
Klaus-Run: <run id>
From any commit, Klaus-Run leads to the run that made it, and through the run to its prompt, log, cost and trajectory. Change-Id names the change the commit belongs to, in jj's change-id form: 32 characters from k to z. A tool that tracks changes can compare it with a jj change-id header as it is.
The trailers are labels for finding and auditing commits. They grant nothing: no tool may trust a commit or skip a check because of one.
klaus adds them mechanically, never through the agent's prompt. klaus launch installs a commit-msg hook in the agent's worktree. On each commit the hook strips Claude/Anthropic attribution, adds Change-Id unless the message already has one, adds Klaus-Run unless that run is already listed, and then runs the repository's own commit-msg hook, if it has one. A commit amended by a later run therefore keeps its Change-Id and lists both runs in order. A message that is empty or made only of lines starting with # gets no trailers: git strips # lines from a message written in the editor but keeps them for git commit -m, and a hook cannot tell which applies, so the hook leaves such a message alone rather than turn an aborted commit into one holding only trailers. The commits _finalize makes when it salvages unfinished work or pauses at the budget cap skip hooks, so _finalize adds the same trailers itself.
Each run records its change ID as change_id in its state. A fresh launch gets a new one. A follow-up keeps the change ID of the work it continues:
--pr <num>uses the change ID of the run that opened the PR, found in any session on the machine. If no run on the PR has one, it uses theChange-Idon the PR's head commit.--change <ref>uses the change ID of the change it names, even with--resume-from.--resume-from <run-id>uses the change ID of the run it resumes.- A budget-paused replay is a
--prrun, so it keeps the paused PR's change ID.
A follow-up to a run whose state predates change IDs gets a new one, and klaus launch says so in its change: line.
klaus launch also installs a pre-commit hook that formats each commit. It runs the project's formatter from the worktree root, stages again the files that were staged, so the commit carries their formatted content, and then runs the repository's own pre-commit hook, if it has one. Files the agent hasn't staged keep what the formatter wrote, and a file with changes both staged and not keeps its staged content, so a commit never takes a change that wasn't staged. A formatter that fails stops the commit, with its output shown. The formatter is the format field of the project's klaus config, a command line such as ["nix", "run", ".#fmt"]. When it is unset, launch uses nix fmt if the flake has a formatter output for the current system, and otherwise nothing formats.
The hooks apply to the agent's worktree only. klaus sets core.hooksPath with git config --worktree, which needs extensions.worktreeConfig; klaus enables that once on the clone. Other worktrees and the main clone keep their own hooks. Earlier klaus versions set core.hooksPath in the clone's shared config, pointing into one worktree's git dir. When the shared value points inside .git/worktrees/, klaus removes it the next time it installs the hook. Any other shared value is the repository's own, so klaus keeps it and runs the hook it names. The coordinator's session worktree gets the same hooks without trailers, because the coordinator is not a run that produces a change.
Add as a flake input to your repo:
inputs.klaus = {
url = "github:patflynn/klaus";
inputs.nixpkgs.follows = "nixpkgs";
};
Then add klaus.packages.${system}.default to your devShell's buildInputs.
A host can follow a branch of klaus without a system rebuild. Turn it on in the host config, ~/.klaus/config.json (a repo's .klaus/config.json can't set it):
"self_update": { "repo": "https://github.com/patflynn/klaus", "branch": "main" }
repo is an https://, ssh://, git:// or file:// URL, an absolute path, or scp-like user@host:path, which is read as ssh://user@host/path (write user@host:~/path for a path in your home on a plain server). Any other transport (ext::, fd::) and any character outside [A-Za-z0-9._~/:@-] (which nix's flake-ref parser would re-encode, as + to %2B, or read as syntax) is refused, and git ls-remote and nix read the repo by the same URL, so the source nix builds is exactly the one ls-remote read, and every git self-update runs, directly or through nix, has GIT_ALLOW_PROTOCOL=https:ssh:git:file. branch defaults to main. Then klaus self-update, typically from a timer every few minutes:
- takes a lock under
$XDG_STATE_HOME/klaus/, so runs never overlap; a run that finds another in progress exits 0 and says so; - resolves the branch's commit with
git ls-remote <repo> refs/heads/<branch>; - exits 0 when the installed klaus was built from that commit (quietly;
-vsays so), or when that commit already failed to build or test, naming the failure; a failed commit is retried when the branch moves, or with--retry; - builds exactly that commit:
nix build --no-link --print-out-paths 'git+<repo>?ref=refs/heads/<branch>&rev=<sha>#packages.<system>.default'; - smoke-tests the build: its
klaus --versionmust report the commit's short SHA, and itsklaus statusmust exit 0, which shows it reads the existing run state. A failure records the commit as failed, installs nothing, and exits non-zero; - installs it into the user's default nix profile in place of the profile element named
klaus, leaving other elements alone. The system klaus stays underneath on PATH as the fallback; - records the current and previous store paths and commits, the time and any failure in
$XDG_STATE_HOME/klaus/self-update.json.
Each step prints one short line, so the timer's journal reads as a history:
main is at 1a2b3c4 (installed: e949dba)
building git+https://github.com/patflynn/klaus?ref=refs/heads/main&rev=1a2b3c4…#packages.x86_64-linux.default
built /nix/store/…-klaus-0.4.1-1a2b3c4
smoke test passed: klaus version 0.4.1-1a2b3c4; klaus status exits 0
installed 1a2b3c4 into the nix profile, replacing e949dba
The trust boundary: only the configured branch installs, pinned to the commit ls-remote returned, and only after it builds and passes the smoke test. Nothing else, such as a PR head, installs by itself.
klaus self-update --rollback reinstalls the previous store path; a second --rollback returns to the newer one. klaus self-update --pin leaves the current install alone until --unpin: runs while pinned exit 0 with a note, so a bad tip isn't reinstalled while it gets fixed. Without the pin, the next run after a rollback installs the branch's tip again.
A running klaus dashboard restarts onto the new klaus by itself (see klaus dashboard). Agents already running finish on whichever klaus their pane resolved; new launches resolve klaus from PATH, so they use the new one at once.
git clone https://github.com/patflynn/klaus.git
cd klaus
go build -o ~/.local/bin/klaus ./cmd/klaus/
make test # unit tests (go test ./...)
make test-e2e # end-to-end tests against a real, isolated tmux server
make vet # go vet ./...
The e2e suite (e2e/, guarded by the e2e build tag so go test ./... is
unaffected) drives the real klaus binary against a private tmux server and
real git repositories, faking only claude/gh. It requires tmux on PATH.
See e2e/README.md for the design and how to add scenarios.
The coordinator session uses these — you generally don't run them directly:
| Command | Purpose |
|---|---|
klaus session |
Start an interactive coordinator session |
klaus launch "<prompt>" |
Spawn an autonomous agent |
klaus launch --prompt-file <path> |
Spawn an agent with the prompt read from a file |
klaus launch --repo owner/repo "<prompt>" |
Launch an agent against a different GitHub repo |
klaus launch --repo <project-name> "<prompt>" |
Launch an agent using a registered project |
klaus launch --pr <number> "<prompt>" |
Push fixes to an existing PR's branch |
klaus launch --change <ref> "<prompt>" |
Continue a change in a repo without PRs, on its branch |
klaus launch --branch <name> "<prompt>" |
Name the new run's branch instead of deriving it from the brief |
klaus launch --model <name> --effort <level> "<prompt>" |
Pick the model and reasoning effort for the agent |
klaus launch --reason <reason> "<prompt>" |
Say why the run is dispatched (e.g. docs-timer), for its events and klaus report time |
klaus target owner/repo |
Set session-level default target repo |
klaus status |
Dashboard of all runs (with CI, conflict, and merge-readiness columns), then running and recent jobs |
klaus failed-checks |
Dispatch runs to fix the valley checks that failed on ready changes klaus owns (the dashboard does this every minute; see Failed checks on valley changes) |
klaus restack-conflicts |
Dispatch runs for the restack conflicts a valley reports on changes klaus owns (the dashboard does this every minute; see Restack conflicts on valley changes) |
klaus track <ref>... |
Track existing PRs (number, URL, owner/repo#N) or valley changes (handle, change ID, <project>:<handle>) on the dashboard and in klaus status (see klaus track) |
klaus untrack <ref>... |
Stop tracking PRs or valley changes |
klaus jobs [--all] |
List running and recent consults, reviews and prose passes |
klaus jobs show <id> [--answer] |
Print a job's record and its answer |
klaus report time [--since 7d] [--repo <r>] [--current] [--json] |
Where agent wall-clock time went, per repo and overall (see Where agent time goes) |
klaus logs <id> |
View agent output (live, replay, or raw) |
klaus cleanup <id>|--done|--all |
Tear down worktrees, panes, and state; --done only what's finished |
klaus push-log <id> [--push] |
Store a log held back for sensitivity on the data ref (--push also pushes the ref to origin) |
klaus project add <owner/repo> |
Register a project (clones if needed) |
klaus project list |
Show registered projects |
klaus project remove <name> |
Unregister a project |
klaus project describe <name> <desc> |
Set a one-line description (empty string clears it) |
klaus project set-dir <path> |
Set the default projects directory |
klaus sync |
Fetch and fast-forward every registered project |
klaus new <project-name> |
Scaffold a new project using principles-based generation |
klaus webhook check |
Check registered projects for GitHub webhook configuration |
klaus webhook setup [project] |
Create missing webhooks for registered projects |
klaus dashboard |
Live TUI dashboard for monitoring agents and PRs |
klaus watch |
Stream pipeline events line-by-line (designed for Claude Code's Monitor tool), or with --once wait for the next ones and exit |
klaus review <pr> [--post] |
Second-opinion review of a PR by a different model family |
klaus approve <pr>... |
Approve PRs for merging (operator task) |
klaus ready <run-id|handle> [--head <sha>] |
Mark a valley change ready for approval once its review passes, only at the reviewed head with --head (see Drafts awaiting review) |
klaus merge <pr>... |
Sequentially merge PRs with conflict resolution |
klaus init |
Scaffold .klaus/ config (optional, for customization) |
klaus self-update [--rollback|--pin|--unpin|--retry] |
Install the tip of the configured klaus branch into the nix profile (see Self-update from upstream) |
Push fixes to an existing PR's branch instead of creating a new PR. The agent checks out the PR's branch, makes changes, and pushes directly — the PR updates automatically. Useful for addressing review comments or fixing CI failures on an existing PR.
klaus launch --pr 42 "Address the review comments"
klaus launch --pr 42 --issue 10 "Fix the auth bug mentioned in review"
The --pr and --issue flags can coexist (the agent may reference the issue in commits).
--pr and --change, and --resume-from when it continues its run's branch, refuse to launch while a run in any session is still running (judged as the dashboard judges it) on the branch they would work on, or on the change they continue, in the target repo; the error names that run. A --pr launch's change is that of the run that opened the PR. Wait for the run to finish, or stop it and clean it up (klaus cleanup --force <run>, which kills its pane and removes its worktree, discarding its uncommitted work, its local branch and its state), then launch again.
Launch never removes a worktree to make way, with or without --force-takeover: while one has the branch checked out, whether a finished run's, a running one's or your own checkout, launch refuses, names it (and the run whose worktree it is, if any), and says whether it holds uncommitted changes or commits that aren't on origin/<branch>. Save that work, then klaus cleanup <run> (with --force while its agent runs), or remove the worktree yourself, and launch again. Git knows a worktree only by its path, so klaus can't make sure the one it would remove is the one it checked: a checkout recreated at the same path in between would go with it. Nor does launch discard commits on the local branch that aren't on origin/<branch>: it refuses and names the branch.
--force-takeover launches past a run still working on the branch or change, and resets the local branch to origin's tip, discarding commits origin lacks; it still refuses while a worktree has the branch checked out. A local branch that only points at what origin has is reset to origin's tip, and a new run's worktree path must not already exist.
In one clone, these launches take a lock from the check for a running holder through the new run's registration, so two of them can't both pass it: the second waits (up to 5 minutes, saying so), then finds the first run and refuses. klaus merge likewise never removes a worktree that has the PR's branch checked out.
Continue an existing change in a repo that delivers without PRs (its origin isn't on GitHub, as in a valley's repo), the way --pr continues a PR. Use it for every follow-up on a change, instead of telling a --resume-from agent to check out the old branch.
klaus launch --change nxvp "Handle the review notes" # a valley handle
klaus launch --change 20261013-0900-1a2b3c4d "Fix the failing check" # a run on the change
klaus launch --change nxvp --no-replay "Start over on the tests" # fresh agent, same branch
<ref> names the change, resolved in this order:
- A run ID names that run's change.
- A full change ID or a handle is matched against the runs recorded in any session for the target repo: a run matches when the ref is its change ID, starts its change ID (a handle is the change ID's shortest unique prefix, so a handle that has since lengthened still matches), or is the valley handle recorded on it. A run is in the target repo only when its repo, named as for the dashboard, is the last component of the target repo: a run whose valley named its repo
widgetis inwidget, not in theacme/sourceit was launched against. A launch whose target repo is unknown is an error. - When no run matches, and the repo delivers to a valley that lists
changes,valley changes --jsonnames the change: one whose ID or handle the ref is, or whose ID it starts.
A ref that names more than one change is an error that lists them; name it by its full change ID or a run ID. --change refuses a GitHub repo (use --pr), and can't be combined with --pr or --branch. Like --pr, it refuses while another run is still working on the change or its branch, unless you pass --force-takeover (see A run still working on the branch).
The worktree tracks the change's branch on origin, as --pr tracks the PR's: the branch of the change's active version when the valley lists one (it may have been marked ready from another machine), else the branch the change's newest run pushed. Launch fails, naming the change and its last known head, when no branch is known or the branch is gone from origin. Then:
- The run keeps the change ID; launch prints
change: <id> (inherited from change <handle or id>). - In a valley's repo, launch returns the change to draft before the agent starts, as for any follow-up run.
- A dispatcher that checked the change first passes what it checked, with
--change <full-change-id>:--expect-owner <principal>,--expect-branch <branch>,--expect-head <sha>and--expect-draftrequire the valley to list the change with that record owner, its active version on that branch (also the branch launch works on) and at that head (also origin's tip of the branch and the worktree's start), and as a draft. Launch asks the valley under its continuation lock before it creates the worktree, and again after it has returned the change to draft and before the worker starts; a valley it can't ask, a change that isn't as expected, or a failedvalley unreadyfails the launch.--run-id <id>names the run (YYYYMMDD-HHMM-and 8 hex digits) for a dispatcher that records it first; with it, a launch that fails before starting its worker exits with status 3. Restack conflicts are dispatched this way. - The agent's conversation continues from the change's most recent run, by the trajectory replay rules of
--pr:--replay,--no-replay, the size threshold and the fallback to a fresh agent all apply. A change has no budget-paused label, so replay doesn't wait for one. Launch prints thereplay:line. --changeworks with every worker backend, and from a coordinator of any backend. Every backend keeps the change's branch, its change ID and theChange-Id/Klaus-Runtrailers, and finalize marks its pushed head ready. Only the conversation differs: Claude replays the stored conversation, on any machine that has the data ref; Codex continues its own session on the same machine; agy starts fresh.- The worker prompt tells the agent it is continuing the change, to commit on top of its branch and push fast-forward to it, and not to create a new branch, rebase or force-push. Finalize's
valley readythen marks the new head ready as a version of the same change. - In a valley's repo the prompt differs. The valley lands a change as one commit under its head commit's message, so the agent amends the change's head commit, or ends with a commit whose message describes the whole change, and keeps that message true to the whole change. It must never merge another branch into the change, since the valley takes a change only as a line of commits on main, and it restacks the change when the brief asks it to rebase or the change conflicts with
origin/main: it rebases ontoorigin/mainand resolves the conflicts. Either way it pushes to the same branch with the command the prompt gives,git push --force-with-lease=refs/heads/<branch>:<head> origin HEAD:refs/heads/<branch>, where<head>is the branch's tip on origin as launch found it, the head the worktree starts from (an argument whose branch name a shell would read specially is single-quoted). A push someone made meanwhile makes that push fail rather than be lost; the prompt tells the agent to stop and report it, and finalize's salvage never force-pushes either. The valley keeps every version's head, so the rewrite loses nothing: finalize marks the amended or rebased head ready as the next version of the same change, on the same branch, and no other branch is made. The prompt says nothing else about when to restack, so a brief that asks for a rebase, from you or from a dispatch for a conflict the valley reports, is all it takes. - On the dashboard the run joins the change's row, like a fix agent on its PR's row.
With --resume-from, that run's conversation is used instead of replay, and --change still picks the branch and the change ID. When that conversation can't be resumed (its transcript is missing or can't be staged), the agent starts fresh rather than replaying another run's, and the replay: line says so: replay: fresh agent (--resume-from <run-id>: its conversation can't be resumed). The same holds for --pr.
Every run without --pr or --change works on a new branch named <slug>-<tail>. The tail is the random end of the run ID (bd92f081 for run 20261011-1843-bd92f081), which keeps names unique and ties each branch to its run. The slug comes from the first of these that yields one:
- the
--issuetitle, when it can be fetched: a GitHub issue's, or a knot's; - the brief's first
#heading, outside code fences; - the brief's first non-empty line.
The text loses a leading <repo-name>:, is lowercased, has each run of characters outside [a-z0-9] turned into one -, and is cut at a word boundary to at most 40 characters. A brief that opens with # cosmo: the klaus-env guest starts with classic-laddie in cosmo names its branch the-klaus-env-guest-starts-with-classic-bd92f081. When nothing yields a slug, or git rejects the name, the branch is agent/<run-id>.
klaus launch --branch fix/healthz-timeout --prompt-file /tmp/task.md
--branch sets the name exactly. It must pass git check-ref-format --branch and must not already exist locally or on origin, and it can't be combined with --pr or --change, which work on the PR's or the change's branch. A --resume-from follow-up gets a new branch named the same way, unless it continues the branch its run pushed (see Runs that end without a PR).
Read the agent's prompt from a file instead of the positional argument. The file is read verbatim, so backticks, quotes, $VAR, and newlines survive intact:
klaus launch --prompt-file /tmp/task.md
klaus launch --prompt-file /tmp/task.md --issue 42 --budget 10
Prefer this for long or technical prompts. A prompt passed as a shell argument goes through the shell first — in zsh, backticks inside a double-quoted string are command substitution, so code spans are deleted or replaced by command output before klaus sees them, and the agent gets a briefing with holes exactly where the detail was.
A prompt can be any length. However it is given, klaus writes it to ~/.klaus/sessions/<session>/prompts/<run-id>.md (kept after the run as the record of the brief) and every worker reads it on stdin, so it never goes on the tmux command line, which tmux caps at about 16KB. A local claude worker also reads its system prompt from prompts/<run-id>.system.md. agy has no system prompt flag, so an agy worker reads the system prompt and the brief together on stdin, from prompts/<run-id>.input.md. What still goes inline (the worker system prompt for codex and for sandbox claude runs) is checked up front: if the pane command would exceed 12000 bytes, launch fails before creating a worktree and names the part that is too large.
The positional prompt and --prompt-file are mutually exclusive: passing both, or neither, is an error. Everything downstream — the stored prompt in run state, --issue/--pr composition, budget handling — behaves identically either way.
Launch an agent against a different GitHub repository. The repo is cloned (or fetched if already cached) and the agent gets its own worktree in that clone. State is still tracked in the host repo.
klaus launch --repo owner/repo "Fix the bug in their API"
Pick the model and reasoning effort for the agent's claude run — e.g. a cheap mechanical task on a smaller model at low effort while a large build keeps the big one:
klaus launch --model claude-haiku-4-5 --effort low "Update the CHANGELOG for v1.2"
klaus launch --effort max "Redesign the pipeline state machine"
--effort must be one of low, medium, high, xhigh, max. The model is passed through verbatim — claude itself rejects unknown models. Per-repo defaults come from default_agent_model / default_agent_effort in .klaus/config.json, with the flag overriding config; agents dispatched by the pipeline (klaus launch --pr fix agents) pick up the config defaults too. When neither flag nor config sets a value, the claude command carries no --model/--effort at all, so claude's own resolution applies unchanged. The chosen values are recorded in the run state.
When sandbox_host is set in ~/.klaus/config.json, agents run remotely via SSH on the sandbox host instead of locally. The worktree is synced to the sandbox before launch, and results are synced back after completion. Log streaming, formatting, and finalization still happen locally.
{"sandbox_host": "klaus-worker-0"}
A sandbox worker gets neither the private TMUX_TMPDIR nor KLAUS_SCRATCH that a local one does: launch creates nothing on the sandbox host but the worktree's copy, which nothing removes, so its prompt tells the agent to keep scratch files in a mktemp -d directory there.
If the sandbox is unreachable, execution falls back to local automatically. Use --local to force local execution, or --host <name> to override the configured sandbox host.
klaus launch --local "Run this locally"
klaus launch --host my-sandbox "Run on a specific host"
The dashboard shows [sandbox] tags on remotely-executed agents and displays sandbox reachability status in the header. The status command includes a HOST column.
Set a session-level default target repo. When the coordinator session is not inside a git repo, this avoids needing --repo on every klaus launch. Accepts a registered project name or owner/repo.
klaus target owner/repo # set default by owner/repo
klaus target my-project # set default using registered project name
klaus target # show current target
klaus target --clear # remove default
The targeting priority for klaus launch is:
--repoflag — if it matches a registered project name (noowner/prefix), resolves to that project's local path--repoflag —owner/repoor full URL (clones/fetches from GitHub)- Current git repo (if in one)
- Session target (
klaus targetsetting) - Error with usage hint
After the runs, klaus status lists the session's jobs (see Consulting other models): every one running and those that finished in the last 3 hours, with their ID, kind, thread or PR, backend/model, status, how long they have run or took, and prompt. The table is left out when there are none.
The status dashboard shows these columns for each run:
| Column | Values | Meaning |
|---|---|---|
| STATUS | running / needs-attention / superseded / pr-created / merged / closed / no-access / awaiting-integration / integrated / branch-gone / awaiting-review / awaiting-approval / approved / checks-failed / conflicting / uncertain / stale / rejected / failed / withdrawn / older-version / draft / working / tracking / exited |
Where the run stands. tracking: a PR or valley change added with klaus track, while GitHub doesn't say it is merged or closed, or the valley can't be asked; a tracked valley change otherwise shows where the valley says it stands, as a valley run does (awaiting-approval and the rest, below). running: the agent's pane is running it, as the dashboard judges it (a dead pane, or an idle one once the run finished, isn't). working: another run of its change is running (see Follow-up runs on a change). superseded: a run that needed attention, whose pushed work a run of its change recorded taking on, and that run is running or finished (see Runs that end without a PR). older-version: a valley run whose change has a newer version. no-access: GitHub won't show the PR with the current credentials, and the other PR columns aren't queried. awaiting-integration, integrated and branch-gone apply to runs that pushed a branch without a PR (see Runs that end without a PR); the rest apply to runs whose change is on a valley (see Repos that deliver to a valley) |
| RUN ID | run ID | The run's ID |
| CHANGE | #N / handle / change ID prefix / - |
The change the run belongs to: its PR's number, else the number of its change's PR (the newest run of the change with a PR), else its valley change's handle, else the first 8 characters of its change ID, as the dashboard names the change's row. Every run of a change shows the same handle, running or not: the valley listing's, else one a run of the change recorded at ready or unready |
| PROMPT | text | The prompt, or the title of the run's change on a valley, cut at 40 characters |
| CI | passing / failing / pending / unknown |
CI check status for the PR; for a ready valley change, its required checks on the active version, with a list after the table naming the failed ones |
| CONFLICTS | none / yes / unknown / restack |
Whether the PR has merge conflicts; restack for a run of a valley change klaus owns that has a current restack conflict, which a list after the table details (see Restack conflicts on valley changes) |
| BEHIND | - / N |
Commits the base branch has that the PR head lacks (stale-but-mergeable when >0) |
| MERGE | ready / behind N / blocked / pending |
Overall merge readiness (combines CI, conflicts, behind-ness, and review status) |
Live TUI view of the PR pipeline. Auto-refreshes via filesystem watching and GitHub polling every 30s.
Runs are listed by what they work on: the runs on a PR share its row, a PR named by the owner and repo in its URL and its number, so alice/widget#7 and bob/widget#7 have a row each, whatever the project registry calls their repos; and so do the runs without a PR on its change (the same change ID in the same repo), as when a run that stopped at a session limit before opening the PR was continued by one that opened it. A repo is named by the valley's name or its short name, and by its GitHub owner where a run knows it, so the same change ID in alice/widget and bob/widget is two changes; a run that knows only the short name joins the one owner its change's other runs know, if there is one. Where the change stands apart from its PR keeps its own row: when a valley lists the change, or one of those runs pushed a branch that waits for integration (SUBMIT, READY), they share the change's row, as the runs without a PR on a change no PR carries do. A run with neither has a row of its own. A run's need for attention stops counting once a run of its change records taking its pushed work on and is running or finished; that run then shows ATTN if it in turn ended needing attention or failing, even once its PR is merged, closed or out of reach. Rows are grouped by whose move it is, most recent first within each group:
| Group | What's in it |
|---|---|
| yours | runs needing attention (ATTN: salvaged work, stalled or budget-paused PRs); valley changes to approve (READY v<n>@<short head>) or to look at (STALE, REJECTED, FAILED, UNCERTAIN, DRAFT); pushed branches to submit (SUBMIT <branch>@<short sha>); open, non-draft PRs that are ready to merge and not yet approved with klaus approve |
| agents | rows with a running agent, whatever else is true of them: a fix agent on a PR, a follow-up on a change (see Follow-up runs on a change) |
| pipeline | every other open PR: CI pending or failing (fix agents are dispatched automatically), conflicts, behind, approved and waiting to merge, CI unknown; valley changes approved and waiting on the integrator (APPROVED) |
| done | merged, closed, integrated (INTEGRATED, or INTEGRATED~ for a content match), gone (GONE), withdrawn (WITHDRAWN), no access (NO ACCESS), and exited agents |
The line above the list counts each group, starting with yours (e.g. 2 yours · 3 agents · 5 pipeline · 12 done), and shows the position (e.g. 12–30 of 34). A repo column appears when the session spans more than one repo or lists a valley change. A change's row is built like a PR's: it shows the valley's handle, else the first 8 characters of the change ID, in place of #<n>; the valley's title, else the subject of the head its latest run pushed, in place of the PR's title; and where the valley says the change's active version stands, with that version's head to approve, wherever it was marked ready; for a change no valley lists, where its latest push stands (see Repos that deliver to a valley). Earlier versions fold into the row rather than showing as SUPERSEDED. A newer push the valley doesn't hold as a version, as when its ready failed, doesn't take the row over: the footer notes it (a newer push 1a2b3c4 isn't ready). A valley change's row always shows its repo. While an agent runs on a PR or a change, the row names it, and the footer says which agent is working on it. The list scrolls when it doesn't fit the pane. The selection stays on its row as runs reorder, and is kept in view.
The dashboard checks pushed branches every minute in the background, independently of GitHub polling, with at most one valley changes per check (see Runs that end without a PR). While it shows valley changes, it also follows each of their valley hosts' telemetry (valley telemetry --json follow, the integrator and worker sources, from one checkout per host), and checks at once when the host reports a decision, stored evidence or a batch's end; a burst of records costs one check, and a record that comes during a check asks for one more after it. A record only wakes the check: the listing stays what says where a change stands. Where telemetry can't be followed (a valley without it, a key without the host's observe grant, or repeated failures) the dashboard logs that once to dashboard.log and keeps to the minute. A PR the agent can no longer read on GitHub (a 404, a 403 that isn't a rate limit, or "Could not resolve to a PullRequest/Repository") shows NO ACCESS and is done; the dashboard skips its CI, conflict and review queries and asks again every 30 minutes, so a fixed credential recovers on its own. Each poll asks GitHub one GraphQL query per repository for all of its tracked PRs, plus a compare for a PR whose head or base moved, so its cost doesn't grow with the number of PRs. A PR seen merged or closed isn't asked about again, even after a restart (the session's pr-transitions.json remembers it); a closed PR that is reopened on GitHub stays closed to klaus. When GitHub rate-limits the dashboard, it stops polling until the time GitHub gives (or for two minutes when it gives none), the header says GitHub rate-limited until HH:MM, dashboard.log notes it once, and every PR keeps its last status.
Done rows older than an hour are hidden by default; the position line says how many (e.g. 21 hidden), and h toggles showing them. Exited agents count only once their worktree is cleaned up. A row the dashboard watched finish (a PR it saw merge, a branch it saw as SUBMIT integrate) stays for the full hour after it finished. Rows in yours, agents and pipeline are never hidden. Set the threshold with "dashboard": {"hide_finished_after_minutes": 60} in config; a negative value never hides anything.
x clears what's finished, as klaus cleanup --done does: the runs of PRs and changes that are merged, closed, integrated, withdrawn or gone, and the older runs of open ones. It keeps the latest run of each open PR or change, any run that needs attention, and any running run. The runs it removes still count in the header's cost and in the Session cost line of klaus status.
Set "dashboard": {"approve_command": "cd ~/hack/{repo} && valley approve {handle} --head {head}"} and the footer shows the expanded command (approve: …) whenever a READY row is selected, and submit: … for a SUBMIT row. {repo} is the repo as the row names it (the valley's name for a valley change, else the short project name), {branch} the pushed branch, {change} the full change ID, {handle} the change's handle and {head} the pushed head, so the approval binds the head you saw. Each value is shell-quoted unless it is only letters, digits and ._/-, so a branch name can't inject commands; don't put quotes around placeholders in the template. A row with no value for a placeholder the template uses, such as {handle} on a branch no valley knows, shows no command, and neither does a DRAFT row or one with an agent running on it. It's there to copy and run on your own machine; the dashboard never runs it. submit_command is the deprecated name of approve_command and is read only when approve_command is unset.
Below the run rows, a jobs section lists the session's consults, reviews and prose passes, as klaus status does: every running job (RUNNING, with how long it has run), and those that finished in the last 3 hours (SUCCEEDED, FAILED, TIMED OUT, INTERRUPTED, or DIED when the job's klaus was killed without recording its end). Jobs don't join the change and PR rows. The section follows the job records through the same file watcher as the runs, and re-reads them every 30 seconds, which also notices a job that died. Press tab to select a job: the footer says how it stands, with its error and where its raw output was saved, and enter opens its record and answer as klaus jobs show prints them, in place of the list (j/k scroll, esc goes back).
On a narrow or short pane the layout degrades rather than wrapping: the prompt column shrinks and long lines are cut at the edge, then spacing, status, job and help lines are dropped, before any rows are.
Keyboard shortcuts:
| Key | Action |
|---|---|
j / k (or ↑ / ↓) |
move the selection |
PgUp / PgDn |
move a page |
g / G (or Home / End) |
jump to the top / bottom |
h |
show all runs / hide old done rows |
a |
approve the selected PR |
d |
discuss the selected PR with the coordinator (pre-fills WRT PR#<num>: in the session pane and switches focus there) |
o |
open the selected PR in a browser |
x |
clear finished: after a y to confirm, remove the runs klaus cleanup --done would, and hide finished jobs (klaus jobs still lists them) |
tab |
select a job in the jobs section / go back to the runs |
enter |
read the selected job's record and answer (esc goes back) |
r |
refresh, and redraw the whole screen |
q |
quit |
While it runs, the dashboard writes its notes, such as a valley that refuses changes --checks, to dashboard.log in the session directory, never to the screen. Anything else that writes to its pane stays on screen until r.
The dashboard follows klaus on PATH. Every 30 seconds it resolves klaus on PATH, symlinks and all, and compares it with the binary it runs. When PATH names a different file that runs --version, as after klaus self-update, the footer says klaus updated to <version>; restarting, and once no key has been pressed for 10 seconds the dashboard shuts down as on q, closing its watcher, its log and its webhook server, and execs the new klaus with the same arguments and environment. The restarted dashboard clears the screen and rebuilds its view from the state on disk. If the exec fails, the dashboard keeps running on the old klaus, shows the error in the footer, and doesn't try that file again.
When webhook mode is enabled, the data-source line shows a freshness indicator for the most recent webhook delivery (e.g. · last event 5m), color-coded as it ages (dim under 30m, yellow under 2h, red beyond). Note this surfaces the age of the last delivery, not delivery health — a long silence can mean a quiet repo or a broken delivery path, so it's a hint for a human to judge rather than a definitive check.
Adds existing work that no agent of the session made to the dashboard and klaus status, for monitoring. It takes GitHub PRs and valley changes, in any mix:
klaus track 405 # a PR in the session target repo
klaus track 405 --repo cosmo # a PR in another repo
klaus track https://github.com/org/repo/pull/405 # or org/repo#405
klaus track --repo the-valley vpkz # a valley change, by handle
klaus track the-valley:vpkz # the same change
klaus track --repo the-valley vpkzrpxvokultrxwoomvtwqvqqxuxvyw # by full change ID
klaus untrack 405 the-valley:vpkz # stop tracking both
A PR is named by its number, its URL or owner/repo#<number>; --repo names the repo of a bare number, else the session target. A tracked PR shares a row with any agent on it and reads tracking in klaus status until GitHub says it is merged or closed.
A valley change is named by its handle, its full change ID, or <project>:<handle>. The project is the one before the colon, else --repo, else the session target, else the repo of the current directory: a registered project name, owner/repo or URL with a checkout on this machine. klaus runs valley changes --json in that checkout, finds the one change the ref names (a handle, the start of a change ID, or the ID), and records its ID, the valley's name for its repo, its handle, and its active version's title, branch and head. A change a run of the session is on, or one already tracked, isn't added again; a landed or withdrawn change is added with a warning. The dashboard then shows the change as a change row, beside any run on it, and klaus status as a run whose STATUS, CHANGE and PROMPT are where the valley's listing says the change's active version stands (awaiting-approval, approved, integrated, draft, withdrawn and so on), its handle and its title, with one valley changes --all --json per look as for pushed runs. Until the dashboard's first look, the row shows the change as klaus track found it, provisionally READY; klaus status reads tracking while the valley can't be asked. With approve_command set, the footer offers the approve command for a tracked change's READY row as for any other.
klaus untrack takes PR numbers and the same change refs (--repo names the project of a bare handle or change ID) and removes only what klaus track added; agent runs are never removed. A change ref that names tracked changes in more than one project removes none until the project is named.
Mark PRs as approved for merging. PR approval is a human task that exists only to ensure that all changes are gated by operator approval. By default, klaus merge requires approval before merging.
klaus approve 42 43 # approve specific PRs
klaus approve --all # approve all merge-ready PRs
klaus approve --run 20260328-1603-a3f2 # approve by run ID
Sequentially merges a list of PRs. Waits for CI to pass before merging (up to 10 min).
A PR that only conflicts is rebased onto main, verified, and force-pushed. Verification runs merge_verify_command when configured, else go build ./... when the repo has a go.mod, else nothing (relying on CI — so non-Go repos merge cleanly).
A PR that is merely behind main is merged as-is. Rebasing it would force-push a new head, which under a branch policy that dismisses reviews on new commits throws away the approval the merge depends on. If GitHub then refuses the merge because the branch must be up to date, klaus runs GitHub's "Update branch" (a merge from main, which keeps the head and the approval intact), waits for CI, and merges again.
By default, PRs must be approved with klaus approve before merging. Unapproved PRs trigger an interactive prompt (or are skipped with --yes).
klaus merge does not have to run inside a clone: pass --repo owner/repo (or let the repo be auto-detected from run state) and klaus resolves the checkout it needs — the current repo, a registered project, or a fresh clone under worktree_base/.repos. The target repo's .klaus/config.json is what applies, not the current directory's.
klaus merge 42 43 44
klaus merge --dry-run 42
klaus merge --merge-method rebase --no-delete-branch 42
klaus merge --force 42 # bypass approval check
klaus merge --yes 42 43 # skip unapproved PRs without prompting
Flags: --dry-run, --merge-method (squash/merge/rebase), --no-delete-branch, --repo (target repo for all PRs), --force (bypass approval), --yes (skip unapproved).
Manage a persistent registry of projects. The registry maps short names to local paths and is stored in ~/.klaus/projects.json.
klaus project add owner/repo # clone into projects dir and register
klaus project add owner/repo --path . # register an existing local checkout
klaus project add owner/repo -d "CLI for X" # register with an explicit description
klaus project add my-tool # search your GitHub repos by name
klaus project list # show all registered projects
klaus project remove my-tool # unregister (does not delete the clone)
klaus project describe my-tool "CLI for X" # add or update the one-line description
klaus project describe my-tool "" # clear the description
klaus project set-dir ~/hack # set the default clone directory
When klaus project add is called without -d/--description, klaus tries gh repo view --json description to pick up the GitHub description automatically. If gh is unavailable or the repo has no description, the project is registered without one — you can backfill later with klaus project describe. Descriptions show up in klaus project list and in the session coordinator's ## Registered projects block.
Check and create GitHub webhooks for registered projects. Webhooks let the dashboard receive real-time events (CI status, PR reviews, pushes) instead of relying solely on polling.
klaus webhook check # show webhook status for all registered projects
klaus webhook setup # create missing webhooks for all projects
klaus webhook setup my-project # create a webhook for a specific project
Both commands require webhook.relay_url in your config. setup also requires webhook.secret_file — a path to a file containing the HMAC secret used to verify incoming payloads.
{
"webhook": {
"relay_url": "https://your-relay.example.com",
"secret_file": "/path/to/webhook-secret"
}
}
The created webhooks subscribe to push, check_run, check_suite, pull_request, pull_request_review, and issue_comment events. pull_request_review and issue_comment are what let comments from trusted_reviewers dispatch fix agents promptly: both inline review comments and PR conversation comments count (conversation comments are re-evaluated when created or edited). Webhooks created before issue_comment was added don't include it; add it to the hook's events in GitHub (or to your relay's forwarded events).
Additional webhook config knobs:
poll_fallback(bool, defaultfalse): whentrue, the dashboard polls GitHub every 30s in addition to receiving webhooks.reconcile_interval_seconds(int, default300): in webhook-only mode (poll_fallback: false), a slow reconcile heartbeat runs a full PR status re-fetch on this interval. This bounds worst-case staleness if a webhook is ever dropped or missed — without reverting to full 30s polling. Set to a negative value to disable. Ignored whenpoll_fallbackistrue(polling already re-fetches every 30s).
{
"webhook": {
"relay_url": "https://your-relay.example.com",
"secret_file": "/path/to/webhook-secret",
"poll_fallback": false,
"reconcile_interval_seconds": 300
}
}
Creates a new GitHub repo and launches a Claude agent to scaffold it. Reads principles from .klaus/principles.md (or built-in defaults). The agent makes all scaffolding decisions based on those principles — no templates. The new project is automatically registered in the project registry.
klaus new my-project
Klaus works out of the box with sensible defaults. To customize, run klaus init to scaffold a .klaus/ directory (or ~/.klaus/config.json when outside a repo), or create the files yourself. Configuration layers: defaults → ~/.klaus/config.json → .klaus/config.json.
.klaus/config.json — Override defaults:
{
"worktree_base": "/tmp/klaus-sessions",
"default_budget": "5.00",
"data_ref": "refs/klaus/data",
"push_data_ref": false,
"default_branch": "main",
"trusted_reviewers": ["gemini-code-assist[bot]"],
"pr_reviewer": "octocat",
"require_approval": true,
"auto_merge_on_approval": false,
"ready_at_finalize": true,
"merge_verify_command": "nix build",
"default_agent_model": "claude-sonnet-5",
"default_agent_effort": "medium",
"agent_display": "detached",
"dashboard": {"hide_finished_after_minutes": 60, "approve_command": "cd ~/hack/{repo} && valley approve {handle} --head {head}"}
}
push_data_ref controls whether finalize pushes data_ref to origin. It defaults to false: each run's state, log and Claude conversation are committed to the data ref in the local clone, where trajectory replay reads them, and nothing is pushed. Set it to true (in ~/.klaus/config.json or the target repo's .klaus/config.json) to push the ref after every run, which lets replay work across machines. Agent transcripts contain prompts, file contents and environment details, and a pushed data ref is readable by anyone who can read the repo, so leave it off for public repos. A push sends the ref's whole history, including runs committed while pushing was off. klaus push-log <id> --push pushes once without changing the setting.
trusted_reviewers lists GitHub logins whose comments dispatch a fix agent even without a formal "changes requested" review. Both inline review comments and PR conversation comments count. A comment stops counting once a newer commit is pushed; editing a comment counts as new, so feedback added to an older comment after the latest push is picked up.
Conversation comments by the PR author are the exception: the operator and fix agents post through the same GitHub account, so an author's conversation comment is ignored unless it opts in, even when the author is in trusted_reviewers. Opt in either by starting the comment with a /klaus fix line, or by putting the hidden marker <!-- klaus-actionable --> on a line of its own. Quoted lines (> /klaus fix) don't count. Trusted reviewers other than the PR author need no opt-in, and inline review comments are unaffected by this rule.
pr_reviewer is the GitHub login a launched agent requests review from when it opens its PR (gh pr create --reviewer). Klaus uses it as given, without asking GitHub. When it is unset, klaus picks the reviewer at launch. It lists the target repo's collaborators with push access, then leaves out bots and the gh user the agents run as, since that user authors their PRs and GitHub ignores a review request aimed at a PR's author. If the repo's owner is still on the list, the owner is the reviewer; otherwise, if exactly one user is left, that user is. If several users are left, or a gh call fails, launch prints a one-line warning naming the candidates or the error, and the agent opens its PR without requesting a review. Runs that don't open a new PR (--pr fixes and direct-push repos) skip the lookup.
ready_at_finalize decides whether finalize marks a change it pushed to a valley ready for approval (true, the default) or leaves it a draft awaiting review, for klaus ready to mark ready once its review passes; see Drafts awaiting review.
merge_verify_command runs in the rebased worktree during klaus merge to sanity-check the branch before force-push. When unset, klaus runs go build ./... only if a go.mod is present, otherwise skips the check (non-Go repos rely on CI).
default_agent_model / default_agent_effort set the claude --model / --effort for launched agents when the launch doesn't pass the corresponding flag. When unset, the flag is omitted from the claude command entirely.
dashboard.hide_finished_after_minutes sets how long done rows (merged, closed, integrated, gone, no access, cleaned up) stay on the dashboard before they're hidden behind h (default 60; negative never hides them).
dashboard.approve_command is a template for the command that approves a valley change, shown in the dashboard footer when a READY row (or a SUBMIT row) is selected. {repo} expands to the repo as the row names it, {branch} to the pushed branch, {change} to the full change ID, {handle} to the change's handle and {head} to the pushed head, each shell-quoted unless it is only letters, digits and ._/- (so leave placeholders unquoted). A row with no value for a placeholder the template uses shows no command. It's empty by default, and klaus never runs it. dashboard.submit_command is its deprecated name, read only when approve_command is unset.
agent_display controls where agent panes live. detached (the default) puts each agent in its own window of a detached tmux session named klaus-agents-<session-id>, so your coordinator window is never split. pane restores the old behaviour of splitting the coordinator's window for every agent.
house_rules names a file of rules you hold across all your projects, in the repository of a registered project: "house_rules": {"repo": "qinling", "path": "guidance/house-rules.md"}. At launch klaus fetches that repository's origin default branch and reads the file from it with git show, never from a working tree. It adds the text after klaus's own worker rules, including for repos with their own .klaus/prompt.md, and to the context of klaus consult --repo, the _pre-review reviewer and its prose rewrite and fact check. The run's state records the file's source (with commit) and sha256 digest, and launch prints them. A repo's .klaus/config.json may name different rules. If the file can't be read, launch warns once, records it as missing, and goes ahead. Unset means no house rules.
forbidden_terms lists words a repo's changes must never add, such as the names of your own hosts, projects, accounts or addresses in a public repo. The list is itself private, so klaus reads it only from ~/.klaus/config.json and ignores it in a repo's .klaus/config.json:
"forbidden_terms": {
"example-project": {"terms": ["example-host", "example.internal", "10.1.2."], "allow_paths": ["docs/hosts.md", "third_party"]}
}
An entry's key is a registered project name (see klaus project) or owner/repo. A project name covers that project's checkout and its worktrees, and any clone with the same GitHub origin; owner/repo covers any checkout whose origin is that GitHub repo. When both forms name a repo, both entries apply. klaus _pre-review runs this check first, before linting and peer review, so a diff that adds a term is never sent to a reviewer. It runs even when pre_review.enabled is false. The check fails a change if any of these contains a term: the lines it adds (in binary files too) since its merge base with the default branch (origin/<default_branch>, or the local branch when there's no origin copy), the names of files it adds or renames, or the messages of its new commits. Deleted lines don't count; a moved file's lines count as added at its new path. Terms match case-insensitively as literal substrings (dots are just dots). Files that match an allow_paths entry exactly, or sit under it as a directory, are skipped. The report lists file:line: term for each hit, or commit <sha>: term for a commit message, and asks for a neutral name. It names only the terms it found, never the list. A repo with no entry is unaffected.
To check any range, for example a change you're about to klaus ready, run klaus _forbidden-terms [--repo <name>] [<base>] [<head>] in a checkout of the repo. <head> defaults to HEAD and <base> to the default branch. --repo picks the entry when the checkout's origin or registration doesn't match it. The command prints the same report and exits 1 on a match.
.klaus/prompt.md — Custom system prompt for launched agents. Go template variables: {{.RunID}}, {{.Issue}}, {{.Branch}}, {{.RepoName}}, {{.Reviewer}}, {{.DirectPush}} (true when the repo's origin is not on github.com; see Runs that end without a PR). Customize this to match your repo's conventions, test commands, and PR workflow. Klaus appends its worker rules to every worker prompt, custom or default (including pr-fix-prompt.md): the run ends with the agent's turn; the test guidance; scratch and build output stay out of the worktree; a broken environment is reported, not worked around; a change whose cost is out of proportion to what it delivers stops and reports what the implementation taught; and the final report has fixed headings (Intent, Decisions, Shape, What the implementation taught us, Checks, Diff summary).
.klaus/session-prompt.md — Custom prompt for the coordinator session. Same template variables.
.klaus/pr-fix-prompt.md — Custom prompt for PR-fix agents. Additional variable: {{.PR}}.
- Agents run headless —
claude -p,codex execand agy's print mode read the prompt on stdin from its file, so there is no way to type at a running agent. Correcting one mid-run means relaunching withklaus launch --resume-from <run-id>; see docs/AGENT_MESSAGING.md for why in-place messaging isn't offered and what it would take. The run ends when the agent's turn ends, so every worker prompt tells the agent to run long builds and tests in the foreground and never end its turn with background work still running - Worktrees isolate each agent — they can't step on each other or your working tree
- tmux panes manage each agent's process lifecycle. They live in a detached
klaus-agents-<session-id>session owned by the tmux server, so they cost you no screen space and agents survive the coordinator exiting (agent_display: "pane"splits your window instead) - Workers can't drive that tmux server. A local worker starts with
$TMUXand$TMUX_PANEremoved andTMUX_TMPDIRset to its run's own empty directory (~/.klaus/sessions/<session>/tmux/<run-id>, mode 0700). Atmuxcommand the worker or its tools run, eventmux kill-server, talks to that empty location, not to the server running your session, the coordinator and every agent's pane. klaus's own commands inside the worker (klaus _pre-review,klaus status) reach the real server throughKLAUS_TMUX_SOCKET, its socket path recorded at launch;_finalizeruns after the worker in the pane's own shell. tmux falls back to/tmpwhenTMUX_TMPDIRnames a directory that doesn't exist, so a worker that points it at a missing directory can still reach a server on tmux's default socket - Scratch files stay out of the worktree. A local worker gets
KLAUS_SCRATCH, its run's own directory outside the worktree (~/.klaus/sessions/<session>/scratch/<run-id>, mode 0700), and every worker prompt tells the agent to put build output, test binaries and other scratch files there, for examplego build -o "$KLAUS_SCRATCH/<name>" ./<pkg>, never in the worktree, whose uncommitted files finalize salvages into a commit. Finalize removes the directory with the worktree, as doesklaus cleanup. A sandbox worker gets noKLAUS_SCRATCH, and is told to use amktemp -ddirectory instead - JSONL logs are saved for replay and post-run analysis
- Sensitivity scanning checks logs for private IPs, SSH keys, and credentials before persisting (the run repo's own remote hosts are exempt; a log that cannot be scanned in full is kept off the data ref)
- State storage — session state lives in
~/.klaus/sessions/(ephemeral, machine-local), while finalized run artifacts are committed to the local clone's data ref - Data ref (
refs/klaus/data) stores run metadata without polluting your branch list. It is pushed to origin only whenpush_data_refis true
tmux(sessions run inside tmux)- At least one configured backend CLI:
claude,codex, oragy(authenticated) git2.30 or later (needed for agent worktrees; sessions can run without it)gh(GitHub CLI, for PR operations)