README
¶
clankerbar
Drive a coding agent through your clankerbar backlog,
unattended. clankerbar respawns fresh harness sessions that work the backlog one
task at a time, and survives usage limits — pausing on the cap and resuming
(including on an early reset) instead of dying until you come back.
It's a local, open-source client of the clankerbar control plane: the hosted plane holds the state (your backlog, over MCP), this runs on your machine and drives your coding agent. Because it holds your credentials and can edit your code and run shell commands, the source is open so you can audit exactly what it does before you trust it with an overnight run.
Status: pre-1.0, released. The loop runs and is what drives clankerbar's own backlog; the harness adapters and the limit/budget machinery are still being hardened. It is a supported surface rather than an internal tool - see Versioning for what the
v0.xline does and does not promise.
How it works
Each iteration is a fresh harness session told to work one task, landing a PR
at in_review. That is the prompt knob, and the wording is exact: the served
protocol reads "work the next backlog item" as one task and "work the backlog" as
drain the whole ready queue in this session. One task per iteration is the default
because it is what keeps a session's context bounded no matter how long the queue
is, and because the operator's controls — console pause, HALT, max_iterations,
the budget breaker — are consulted between iterations, so a shorter iteration is a
shorter wait for them to bite. Set prompt to the drain phrase if you want the old
behaviour, and expect both properties to go with it.
When a session ends, the driver spawns a new one — so context never rots across a long run, and a session killed mid-task is fine: the driver hands the task straight back to the queue (see Resilience) and the next iteration picks it up. The backlog is the durable state; the loop is thin.
Phases: splitting one task across two sessions
A session's context grows monotonically and is re-read on every turn, so a long
task gets expensive at the end: one measured task spent 66.2M tokens over 370
turns, and its last four deciles were 53% of that purely because each of those
turns re-read a quarter of a million tokens. The served protocol already tells a
session to shed its context at a safe checkpoint, and says in the same breath
that the rule is a no-op if the harness cannot discard context. Inside a claude -p run there is no tool and no slash command the model can use to discard
its own context, so for the agent that rule is dead letter.
The driver does have options, and they were evaluated rather than assumed away
(claude 2.1.226): --autocompact <auto|tokens> compacts at a threshold, and
--continue / --resume / --fork-session restore a session rather than
shedding one. A phase boundary was chosen over compaction because it lands where
everything load-bearing is already durable somewhere else — code pushed, bar and
standing decisions on the plane — so the next phase re-reads them from source,
where a summary that garbled the bar would not announce itself. It is also
harness-agnostic, where --autocompact is Claude-only. The two compose, and
trying autocompact inside a long phase costs nothing.
phases splits one task across several sessions, so the context resets at that
checkpoint:
"phases": [{ "name": "implement" }, { "name": "review" }]
Phase 1 claims the task, implements it in a worktree cut from the repo's
integration branch, self-verifies, commits, pushes and records the branch — then
stops. When phase 1 instead finds an existing branch or worktree already
carrying the task's work (a recovered claim, a prior session's WIP), its first
step is to merge the integration branch into it - a merge, never a rebase,
since rewriting pushed history breaks tip comparison - and re-validate the task
against the merged tip before touching what it found: superseded means record
the decision and park the task with an outcome saying so, or salvage only what
still adds value - never verify-and-push stale work. Phase 2 starts on a
fresh context, resumes the same run (the driver substitutes the task and
run ids into its brief, so it calls heartbeat instead of claiming), re-reads
the bar and the standing decisions from the plane, works in
that same worktree — never on the integration branch itself — runs the
adversarial review, fixes what it finds, pushes, opens a PR targeting the
integration branch if no PR exists yet for the branch, and hands the task to
in_review. The claim is held across the seam, so the task is never posted back
to the queue mid-sequence. See docs/releases.md for what
happens to that PR after in_review — merged by a clanker only once a human
approves it, promoted to a release only by the operator.
The split works because each phase's prompt is a scope instruction — "your
job this session is implementation only, then stop" — rather than a request that
the model manage its own context. Naming a phase takes its built-in brief; set
prompt on it to write your own, and max_turns to cap it as a backstop for a
session that works past its brief (whatever it leaves uncommitted is then caught
by the salvage). prompt and phases are mutually exclusive.
It is opt-in, and worth understanding before switching it on. A task that reaches its checkpoint and stops is only half finished until the next session runs, so this trades a driver that cannot lose a task for one that can leave it half-done if the sequence is interrupted. The budget breaker fires at the phase boundary too. Two other consequences worth knowing:
max_iterationscounts task sequences, not sessions. A two-phase config spawns roughly twice the sessions for the same limit.- A multi-phase config requires a harness that observes the session's task
claim, which today means
claudeoropencode. The handback across a seam, the salvage and the delivery check all depend on it, so a config namingcodexalongside two or more phases is refused at validation rather than quietly stopping after phase 1 on every task. A single phase hands off to nobody, so it is allowed anywhere. max_turnsis aclaude-only backstop.opencodecan now run phases, butInvocation.MaxTurnsnever reaches its CLI and it has no mid-stream token ceiling either, so a phasedopencodeconfig has no per-session cap at all — only the budget breaker at the phase boundary andmax_wall_clock. Set those deliberately rather than assuming themax_turnsyou wrote is doing anything.- A sequence that ends on the
implementbrief is refused, on any harness: that brief tells its session to stop at the checkpoint and leavein_reviewalone, so with no phase after it every task would stop half-finished, forever, with nothing in the logs reading as an error.
The seam also survives a blind spot in how claims are observed (CLA-451). The driver learns a session's claim by parsing clankerbar tool calls out of its stream; a session that claimed through another channel — a raw API call with the same key, a future harness quirk, a renamed tool — ends its implement phase holding a task the driver never saw it take, and used to end the sequence there: no review phase, no lease renewal, and pushed work stranded behind an expiring lease until a takeover recovered it at a reclaim's cost. Now, before declaring a checkpointable phase empty, the driver asks the plane about the one task it can name — the task it dispatched: if that task is held with a branch recorded and that branch verifies on the origin remote (the same evidence gate every checkpoint passes), the driver treats it as the checkpoint and resumes the run by heartbeat in the next phase, exactly as after an observed claim. The guards are unchanged: an unreadable stream is never filled in from the plane, a session that died producing nothing is never promoted by a plane answer, an observed claim always wins over a peeked one, and a failed peek keeps today's ending.
The saving is still unmeasured: a phased run has now happened, but it is not comparable evidence - its implement phase reached the checkpoint over an implementation that already existed, so its totals capture a checkpoint plus a review rather than a from-scratch split, and quoting them as a saving would overstate the effect by a wide margin. A like-for-like measurement still does not exist, and it costs a full task run to get. Splitting thinner earns less each time while every extra boundary still pays a session's full startup cost.
Session-initiated handoff: a boundary the session chooses
Phases cut at two fixed, driver-chosen points. A session can also choose its own cut: ending its final message with a marker line followed by a prompt makes the driver respawn a fresh session on that prompt, prefixed by driver-authored framing that carries the same "resume this run, do not claim" contract a phase resume gets from its brief — same task, same run-continuity rules as a phase resume — instead of moving on. The built-in briefs teach the mechanism and its trigger: hand off at a genuine pivot (exploration finished and implementation about to start; one sub-goal landed and the next beginning), writing the successor's prompt the way an operator's continuation prompt reads — decisions made, state verified, exact next steps, nothing about the journey. Most tasks need zero handoffs.
A session-authored respawn prompt is self-directed prompt injection, so it is
honoured only inside three guards: each respawn consumes an iteration of
max_iterations (the one exception to "counts task sequences") and the budget
breaker runs before the respawn ever spawns; an over-size prompt (over 4KB)
is refused with a logged fallback to the normal path, never truncated; and a
chain of consecutive handoffs is capped at 3 per phase, after which the
sequence falls back to the standard brief; max_iterations and the budget
still bound the total across every phase. Every respawn is named in the daemon
log and tagged in its iteration log's filename (-h1, -h2, ...), so the
chain reads the way phases do.
The independence the split also buys
Context economy is why phases were built, but it is not the only thing they are worth. Before phases, one session implemented a task, dispatched its own reviewer and applied the fixes: the thing being reviewed wrote the reviewer's brief. An author naming what to attack frames away from its own weak spots without meaning to — measured on one task, a hand-written brief named three things to attack, all three came back fine, and the real defect the review found was something the brief never pointed at.
A phase boundary fixes that structurally rather than by asking anyone to be fair-minded: the driver is the caller, so the implementing session cannot write the reviewing session's prompt. Phase N+1's brief is built from your config plus exactly two values off the held claim — the task id and the run id — and phase N's session output is not an input to it at all. A test pins that absence rather than the wording of the shipped brief, because the wording is the part that is allowed to change.
The second phase also puts the review behind the checkpoint, which is where a session's context peaks and so where a usage limit or a crash is most likely to land. Before the split, a death there destroyed the findings and the half-applied fixes together; now it costs the review, never the implementation.
Per-phase models: a tier map you own
model pins one model alias for the whole run. models and a phase's tier let
different phases run on different models:
"models": { "strong": "opus", "standard": "sonnet", "cheap": "haiku" },
"phases": [
{ "name": "implement", "tier": "strong" },
{ "name": "review", "tier": "strong" }
]
The buckets are yours, and the indirection is the point. clankerbar drives
three harnesses and cannot know which models you have, nor which of them is
cheaper — that needs a price table, which goes stale silently and in the direction
that costs money. So it never learns what models exist; it learns only that you
bucketed them, and a phase names a bucket, never an alias. ("tier": "opus"
does not pin opus. It looks for a bucket called opus.)
Everything falls back, and nothing here can stop a run. A phase with no
tier runs on model; a tier naming a bucket you never defined runs on model
and says so in the iteration log; model itself empty means the harness picks, as
it always has. A config written before any of this existed behaves exactly as it
did. A mistyped bucket costs a log line at 3am, not a stopped drain.
Which way to tier is a question of blast radius, not difficulty. A phase that produces a durable artifact — code, a recorded decision, a bar someone will be measured against — is worth the strong bucket, because a mis-recorded decision is read for months by people who cannot see it was wrong. A phase that only compresses a large output into a small answer can take a cheap one, because the error is visible immediately to whoever asked the closed question. Nothing is tiered by default: an untouched config runs every phase on the run's model, and these two shipped phases both produce durable artifacts.
Per-phase harnesses: implement on one, review on another
A phase can name the harness it runs on, not only the model:
"harness": "claude",
"harnesses": {
"opencode": {
"config_dir": "~/.config/opencode",
"mcp_config_path": "~/.config/clankerbar/opencode-mcp.json"
}
},
"phases": [
{ "name": "implement", "harness": "opencode" },
{ "name": "review" }
]
That runs the implementation on a cheap provider-agnostic backend and keeps the adversarial review on the harness with the subagent machinery. Nothing in the seam resists it: each phase is already a fresh session seeded from the observed claim, so what crosses the boundary is a task id and a run id, and both live on the plane rather than in a session.
The harness-shaped fields stop being run-wide, and that is the whole of the
configuration. config_dir, mcp_config_path, settings_path, model and
models at the top level describe your harness and no other, because each is a
dialect: config_dir is CLAUDE_CONFIG_DIR for one adapter and
OPENCODE_CONFIG_DIR for another, mcp_config_path is Claude's .mcp.json for
one and an opencode-schema config for another — and opencode does not ignore the
difference, it refuses to start on mcpServers. So a phase on another
harness inherits none of them and reads its own harnesses.<name> block instead.
A phase naming a harness with no block is refused at validation, rather than
spawning a session with no clankerbar tools that looks in the log like a model
that declined the work.
Tiers are resolved per harness. A phase's tier names a bucket in the tier
map of the harness that phase runs on, so one "tier": "strong" can mean opus
here and something else there — the bucket name is your policy and travels, the
alias inside it is a provider's and does not. A harness whose block names no
models runs on that harness's own configured default, which is where opencode's
model lives; a claude alias is never handed to another harness's --model.
Every phase's harness has to observe the session's task claim (claude or
opencode), not only the first one's. The first phase has to observe a claim
or there is nothing to hand on; a later phase is handed one - but seeding it is
the adapter's job, and an adapter that does not track claims does not seed
either. A non-tracking phase mid-sequence would end every drain early with a log
line that reads like an ordinary finish, and one at the end would leave the
driver handing the task back over its own work. Validation names the phase and
the harness when it refuses.
A block that declares nothing is refused like a missing one, since an empty
one spawns exactly the session the rule exists to prevent. And --harness /
--model are refused outright on a config that selects harnesses per phase: both
flags assert the run has one harness, and --harness would silently re-point
which harness the run-wide fields belong to. Edit the harnesses block instead.
A single-project run is slug-checked too. With no projects block the poll's
slug comes from the top-level mcp_config_path while each harness's sessions
work whatever its file names, so two files naming different projects are
refused the same way a mismatched projects entry is - the poll would gate on
one project's counts while sessions claimed and worked another's.
Multi-project runs need one MCP file per project per harness. That file
carries two facts at once — which project (its /mcp/<slug>) and which schema
(the harness's) — so a projects entry declares them under mcp_config_paths:
"projects": [
{ "slug": "clankerbar", "workdir": "~/dev",
"mcp_config_paths": { "opencode": "~/.config/clankerbar/opencode-clankerbar.json" } }
]
Omitting one is refused rather than resolved: the single top-level
harnesses.<name>.mcp_config_path names one project, so falling back to it would
poll one queue while sessions worked another. doctor reports each
project-and-harness pair separately, along with each harness's binary, config dir
and permission policy.
On a usage limit the loop doesn't die — it pauses and polls for the reset (catching Anthropic's semi-random early resets), then continues.
Install
Download a prebuilt binary. Each release ships cross-platform binaries on the
releases page - macOS and Linux, amd64 and arm64, as
clankerbar_<version>_<os>_<arch>.tar.gz. Extract it and put clankerbar on your
PATH. No Go toolchain required.
# macOS arm64; swap the os/arch for yours.
VERSION=0.1.0 OSARCH=darwin_arm64
curl -fsSLO "https://github.com/lecstor/clankerbar-cli/releases/download/v${VERSION}/clankerbar_${VERSION}_${OSARCH}.tar.gz"
curl -fsSLO "https://github.com/lecstor/clankerbar-cli/releases/download/v${VERSION}/checksums.txt"
shasum -a 256 --ignore-missing -c checksums.txt # Linux: sha256sum --ignore-missing -c checksums.txt
tar -xzf "clankerbar_${VERSION}_${OSARCH}.tar.gz"
./clankerbar version
Then verify where it came from, not just that it arrived intact. The checksum
above catches a corrupt or truncated download, and that is all it catches: it is
served from the same release page as the archive, so anyone able to replace one
could replace both. Every release is also signed with build provenance -
a Sigstore attestation held by GitHub, not by the release, recording that these
exact bytes came out of this repo's release.yml at a specific commit:
gh attestation verify "clankerbar_${VERSION}_${OSARCH}.tar.gz" \
--repo lecstor/clankerbar-cli \
--signer-workflow lecstor/clankerbar-cli/.github/workflows/release.yml
--signer-workflow is the half that makes the sentence above true: --repo
alone accepts an attestation signed by any workflow in this repo, while naming
the workflow pins it to the one that publishes releases. That is worth the extra
line - this binary holds your credentials and runs shell commands on your
machine, and provenance is the part that would notice if the archive were not
ours. Needs the GitHub CLI and a gh auth login; run
gh attestation verify --help for the offline and JSON-output variants.
Downloading in a browser instead of with curl gets the archive quarantined by
macOS, and the binaries are not notarized, so the first run is refused with
"cannot be opened because the developer cannot be verified". Clear it with
xattr -d com.apple.quarantine clankerbar, or use the curl route above, which
never sets the attribute.
Or build from source (requires Go 1.26+):
go install github.com/lecstor/clankerbar-cli/cmd/clankerbar@latest
# or, from a checkout:
go build -o clankerbar ./cmd/clankerbar
A source build reports the version it was stamped with - go install leaves it at
the 0.0.0-dev default, so clankerbar version naming a real vX.Y.Z means you
are on a release archive.
Versioning
Releases are semver tags (vX.Y.Z) and the tool is pre-1.0, so expect the v0.x
line while the surface settles. Concretely, while pre-1.0:
- A breaking change can land on a minor bump (
v0.1->v0.2). That is what thev0.xline means; pin a version if you are automating against it. - A breaking change is never silent. Permitted is not the same as unannounced:
breaking commits carry a
!(feat(cli)!: ...) and the release config hoists them into a Breaking changes heading at the top of the notes, rather than leaving them as one line among thirty. - Releases are cut when there is something to ship, not on a schedule. The commitment is that a tag produces working, downloadable binaries.
Those promises harden at v1.0.
Usage
clankerbar run --harness=claude
clankerbar run --harness=claude --model=opus --max-iterations=10
clankerbar run --config ./clankerbar.json # or: -c ./clankerbar.json
clankerbar ctl restart -c ./clankerbar.json # tell the RUNNING daemon to re-exec
clankerbar ctl reload -c ./clankerbar.json # re-read the config file, no exec
clankerbar propose-config # import this file into the plane (PENDING)
Flags are GNU-style: --long options, -x shorts. --config (-c) and
--help (-h) are the only short aliases; everything else is long-form only.
clankerbar run --help, clankerbar doctor --help and
clankerbar propose-config --help list them. A short flag's
value is separate (-c ./x.json) or =-joined (-c=./x.json); the inline
-c./x.json form is rejected, so a typo like -cofnig cannot quietly become
--config=ofnig.
If you built from a checkout before v0.1.0, this changed under you. A single dash now introduces short flags, so the Go-stdlib spelling
-harness claudeno longer works - use--harness claude. Anything invokingclankerbarwith single-dash long options (a cron wrapper, a launchd plist, a shell alias) needs updating. The old spelling is rejected with a message naming the double-dash form rather than being quietly reinterpreted. v0.1.0 is the first release, so it ships the GNU-style spelling from the start.
Preflight: clankerbar doctor
Most of what goes wrong in an unattended run only shows up as degraded
behaviour, hours in — a rejected key silently drops the loop into blind mode, a
missing binary kills the first session, an unreachable plane looks exactly like an
empty queue. doctor turns all of that into one cheap answer before you start:
clankerbar doctor
clankerbar doctor --config ./clankerbar.json --harness codex # or: -c ./clankerbar.json
clankerbar doctor && clankerbar run --harness=claude # gate a cron wrapper
It prints one PASS / WARN / FAIL line per check, with a one-line remedy
under anything that isn't a PASS, and exits non-zero if any check FAILs — so
it composes with && as above.
PASS config loaded /Users/you/.config/clankerbar/config.json
harness: claude
workdir: /Users/you/dev
api key origin: https://clankerbar.com
backlog: https://clankerbar.com/api/projects/acme/backlog-summary
PASS harness /usr/local/bin/claude (2.1.0)
WARN config_dir not set — the session inherits the ambient environment
-> set config_dir (or --config-dir) so a cron/launchd run loads the same skills, plugins and auth as your terminal
WARN backlog https://clankerbar.com/api/projects/acme/backlog-summary — 0 claimable, 2 open question(s) — nothing to claim; the loop will idle without spawning
-> answer the open question(s) at clankerbar.com, or expect an idle run
WARN state_dir /Users/you/.local/state/clankerbar/loop/dev-9f70ef211d1e0549 has a leftover STOP marker
STOP — the loop stops on its first tick
-> delete it, or the loop acts on it immediately on start: rm /Users/you/.local/state/clankerbar/loop/dev-9f70ef211d1e0549/STOP
WARN workdir[acme] /Users/you/dev has no agent-instructions file (AGENTS.md / CLAUDE.md)
-> add one here naming each repo below and where its protocol lives — a session started in a multi-repo parent loads nothing from the repos under it
PASS permissions /Users/you/.config/clankerbar/headless.json parses
WARN toolchains no grant for: go (/Users/you/dev/acme-cli)
-> allow the verbs each one needs in /Users/you/.config/clankerbar/headless.json (e.g. Bash(go build:*), Bash(go vet:*), Bash(go test:*)) — a headless session fails closed, so an ungranted tool is refused with no prompt and its task ships unverified
PASS budget no ceiling configured — the loop stops on a STOP/HALT marker or signal; a dry backlog idle-polls rather than exiting
The checks: config (discovered, parses, validates — plus the resolved harness,
workdir, api key origin and derived backlog URLs), harness (binary on PATH
and runnable, with its version), config_dir (resolves, exists, looks initialised for the chosen
harness), backlog (creds present and the summary read succeeds — distinguishing
no creds, a rejected key, a project_required key/route mismatch, and an
unreachable endpoint — plus whether the queue is gated on your open questions,
or paused from the console), state_dir (the driver's own directory: writable,
no leftover control markers (HALT/STOP and the ctl restart/reload family),
and not sitting inside a configured workdir - a state
dir under one a session is spawned in is writable by that session, which hands it
the loop's own STOP/HALT switch; under a workdir nothing runs in yet it is
the same trap for the next projects[] entry that inherits it), workdir (per
project: it resolves, an .mcp.json
reaches it, and it carries an agent-instructions file), permissions
(harness-specific policy sanity), opencode_ambient_config (an opencode
config the driver never named that a session merges anyway: one whose
clankerbar server points at another project, or one carrying keys that shape
what a session may do), toolchains (the build tools the project's
repos need are actually granted), power (whether the machine will stay awake
long enough to do the work), deploy_lag (how far the deployed build lags the
project's integration branch), and budget (ceilings parse and are sane).
A multi-project config gets one backlog check and one workdir check per
project — one queue can be wired wrong while the others are fine. A project
entry that omits workdir or mcp_config_path inherits the top-level one, and
doctor resolves it exactly the way the loop does, so it never reports on a
directory your sessions will not use.
deploy_lag reads the plane's /health (health_url, top level or per
project) to learn WHICH commit is deployed, and compares it against the REMOTE
tip of integration_branch (default staging). A gap is warned about only once
its oldest undeployed commit is older than an hour - one fresh commit is normal
deploy cadence, 21 overnight is not - and the count and age are named either
way. A deployment AHEAD of the branch (a hotfix, or work not yet merged) passes:
it is newer than the shared line, not a stale deploy. The sentinel stamps
unknown and <sha>-dirty each get their own warning naming what they mean,
and anything doctor cannot establish (endpoint down; the commit held by no local
clone even after fetching the branch into them) is an honest WARN, never a green
line. Every git call it makes is bounded by its own deadline, so an unreachable
remote ends as that WARN rather than a hung preflight, and the fetch hunt is
capped in ATTEMPTS so a dead remote cannot turn it into one round trip per
clone. Unconfigured it prints one quiet PASS naming health_url, so the feature
is discoverable without nagging anyone who opted out; in a mixed multi-project
config the unmonitored projects still get their own quiet PASS rather than
disappearing from doctor's output.
The toolchains audit reads every settings file Claude merges — the --settings
policy, the config dir's settings.json/settings.local.json, and each session
workdir's .claude/settings.json/.claude/settings.local.json. A narrow deny
(Bash(go run:*) alongside an allowed Bash(go test:*)) is reported as a hole in
an otherwise-granted tool, not as a blocked one — only a bare Bash(go:*) deny
means the toolchain is unusable.
Three of those exist because of failure modes that cost a real overnight run whole iterations:
- The queue said there was work and there wasn't. A ready task gated on an unanswered question still counts as claimable, so the loop spawns, the session correctly declines to pre-empt your decision, and you pay for that report ten times. Preflight now says so before the window opens.
- A session started in a multi-repo parent reads nothing. A harness loads its
instruction file, skills and project settings from the session's cwd and
upward — never from the repos below it. Spawn in
~/devand every session begins by rediscovering the layout, with none of your conventions. - An ungranted toolchain does not stop a run, it un-verifies one. A headless
session fails closed, so
go testthat was never allowed is refused with no prompt reaching you — and the task ships written, pushed and never compiled. - A sleeping machine does not pause a run, it freezes one. Timers use the monotonic clock, which does not advance while the machine is suspended, so a wait stops mid-flight and the loop goes silent exactly like a hang. One run lost 5h31m of a 10h window this way, waking only in 45-second Power Nap bursts.
The dead-phase rate: clankerbar dead-rate
The opencode implement phase has been observed dying silently — a session whose
final step finishes with reason unknown and no branch recorded, so it produced
nothing. That rate is the number that decides whether a fix to the harness or
provider worked, and the driver now tallies it live: each phase session that
got past its claim counts as a phase run, and one that then died producing
nothing counts as dead, broken down by phase name and harness, and reported
after every drain with its denominator:
dead-phase tally: implement/opencode: 6 dead of 23 (26.1%); review/claude: 0 dead of 4 (0.0%)
A session that never got past its claim — a correctly refused takeover — counts toward neither counter, because it satisfies both conjuncts of the dead signature while being a correct refusal, not a death.
The same predicate, applied to the existing iteration logs, is available as a subcommand — the retrospective view over the logs the driver has already written, per day, per phase and per harness:
clankerbar dead-rate
clankerbar dead-rate --error tool_count_limit # logs whose APIError events match
The scan is verified against known-positive controls. --error tool_count_limit
finds exactly the three 2026-08-19 logs that carry it as an APIError event, not
the later logs where the same string appears as task-body text an agent merely
read. Against the recorded 2026-08-20 hand count (6 dead of 16 implement
sessions, taken mid-day) it reconciles session by session: three of the six are
dead; the CLA-386 takeover held WIP inherited through its own claim result, so
it ran but did not die; the two CLA-390 sessions never got past their claim and
count toward neither counter; and two further deaths fall after the mid-day
snapshot. Over the full day the scan reports 5 dead of 23.
Sleep, on laptops
clankerbar run now holds a no-idle-sleep assertion for its own lifetime (macOS;
it dies with the process, so nothing outlives the run). Two caveats it cannot fix,
both of which have cost a real run its night:
- A closed lid sleeps regardless. No assertion overrides clamshell sleep.
- Start the run on AC. Plugging in later does not wake a sleeping Mac, so a machine that idle-slept on battery stays down even once power arrives — its never-idle-sleep-on-AC setting never gets a chance to apply.
When a wait does get frozen, the loop now says so on the way out, rather than leaving an unexplained gap in the log:
wait of 30m0s took 2h28m11s of wall clock — the machine was suspended for ~1h58m11s;
timers are frozen while it sleeps, so an unattended run stalls silently
When the queue lies about having work
claimable > 0 means work is available, not that it can be done. A task gated
on an unanswered question is claimable and unworkable: the loop spawns, the session
correctly declines to pre-empt your decision, and you pay for that report every
cycle. One run did it ten times.
So after three consecutive sessions that settle nothing — nothing reaching
in_review or done — the loop backs that project off for 15 minutes, then 30,
then an hour, capped at two. Other projects keep draining.
So does the queue simply going quiet. A poll that shows nothing to spawn for at all
means the project is idle, not fruitless — there is nothing for the loop to
spawn, so it has not failed at anything — and the count is forgotten along with
any wait still running. Without that, parking the blocker would leave the count
standing (parked is not progress a backlog can see), and the next task you file
would serve out a two-hour wait it did nothing to earn.
Note "nothing to spawn for" rather than "nothing claimable": abandoned work counts
as something to spawn for. A project can hold a branch whose session died, and the
sweep that hands it back runs only when an agent asks the plane for its next task
— so if the loop declined to spawn while the ready queue was empty, nothing would
ever ask, and the branch would sit there for good. The gate is claimable + stale_claimable, both of which the queue line reports.
What bounds a recovery that never finishes is the plane, not this back-off.
Taking a branch over renews its lease, so the task stops being offerable until the
lease lapses again — and the poll in between reads as idle and forgets the strike.
The count therefore does not climb across successive takeovers of one branch, and
the local back-off will usually never reach its threshold. That is deliberate: the
plane counts hand-offs per task, and once a branch has defeated its allowance it is
parked and raised to you as a question instead of being offered again, which
drops it out of stale_claimable for good. So the spend is bounded per branch by
the plane, at a session or so per lease period, rather than by three strikes here.
The local back-off still bites the case it was built for — a target that keeps
being offered the same unfinishable work without the lease resetting.
Three rather than one, because a genuinely large task can span several sessions before anything reaches a reviewer, and backing off then would throttle exactly the deep work the loop exists to do.
Answering the question ends the wait, and so does any other progress: you
merging a PR, another machine's loop settling something. Both are seen on the next
poll, so "immediately" means within one idle_poll_interval (60s by default), not
sooner. The answer is the one worth spelling out, because it is what the back-off
message asks you to go and do: an answer moves neither in_review nor done, so
a falling open-question count is the only trace of it the loop can see, and it
watches both signals for that reason. Answer while a session happens to be running
and the loop gives that project one immediate retry rather than the next rung of
the ladder. That session still settled nothing, and its strike stands.
WARN vs FAIL is the "would this still make progress?" line: no creds and an unreachable plane WARN, because the loop drains blind and still gets work done; a rejected key and a key/route mismatch FAIL, because they never self-heal and every session the loop spawns carries the same broken credential.
Control an in-flight run with markers in the state dir, which lives OUTSIDE the
workdir - $XDG_STATE_HOME/clankerbar/loop/<workdir-slug>, i.e.
~/.local/state/clankerbar/loop/<workdir-slug> by default. doctor prints the
resolved path:
touch ~/.local/state/clankerbar/loop/dev-9f70ef211d1e0549/STOP # stop gracefully (responsive even mid-wait)
clankerbar ctl restart # finish the session, then re-exec at the iteration boundary
clankerbar ctl restart --now # kill the in-flight session, release its claim, re-exec now
clankerbar ctl reload # re-read the config file at the next boundary, no exec
ctl writes a marker into the same state dir (RESTART / RESTART_NOW /
RELOAD, read exactly where STOP is read), resolved from -c <config> —
plus --workdir, if the daemon was launched with that override — the same way
run resolves them. A graceful restart never cuts a session short:
the daemon finishes what it is doing, holds no claim across the iteration
boundary (the plane keeps all durable state, so resume is by construction),
then re-execs itself with the same argv and environment, through its launch
path - so restarting after installing a new build runs the new build. Reload
re-reads the config file at the next iteration boundary without replacing
anything: budget, ceiling, prompt and poll-interval edits apply to the next
iteration; harness, env and binary changes need a restart.
One limit worth knowing before you reach for restart: re-exec preserves the
daemon's CURRENT environment. It cannot conjure env the daemon was never
launched with - a tokenless daemon restarts tokenless (that class of problem is
env ownership, not restart). And a leftover control marker causes a surprise
restart or reload on the next start - doctor warns about all of them, and
ctl's own output says what to do if no loop is actually running against the
config you pointed it at.
An explicit state_dir wins, but pointing it back inside a workdir gives every
session spawned there the ability to write these markers. doctor WARNs whenever
the resolved state dir sits inside a configured workdir - including the default,
if your workdir happens to contain ~/.local/state.
You can also pause a run remotely from the clankerbar web console (Settings →
Pause) — per project. A paused project stops getting new sessions (other projects
keep draining) and the loop idle-polls until you Resume — without exiting, and
without killing an in-flight session (a pause is honoured between iterations, like
STOP). Remote pause rides on the driver's cheap backlog read, so it needs a
wired poller: a CLANKERBAR_API_KEY and a resolvable plane URL. Your
account key (mint at clankerbar.com/account/api-keys) is the normal choice —
the driver polls the project-in-path summary route whenever it knows the slug, which
it derives from your .mcp.json's /mcp/<slug> URL or from the projects config
(below). A project-scoped key works too (the CI-style setup). With no creds or no
resolvable endpoint the loop drains blind and can't see the flag — the local
STOP/HALT markers (and ctl restart/reload) remain the fallback there. Two misconfigurations hard-stop
(non-zero exit) instead of blind-draining doomed sessions: a revoked/wrong key
(401/403), and an account key with no derivable project slug (400 project_required from the legacy slug-less route) — give the loop a slug via
projects or an /mcp/<slug> .mcp.json.
Visibility
The driver logs milestones to your terminal as it goes (timestamped): spawning,
queue state each poll, done + tokens/cost, usage-limit pauses, transient retries.
The Claude harness runs with --output-format stream-json, so the agent's own
progress — assistant text and → Tool markers — streams live too, and each
attempt is captured to its own <state-dir>/iteration-<ts>.log.
Fleet visibility
The daemon also reports what it is doing to the plane, so the console's Fleet page shows every instance — working and idle, where the run-based panel only sees a daemon that currently holds a task. Two things are reported, both riding cadences the loop already runs (no new timers):
- Presence rides each backlog poll: who the instance is (
instance_name, falling back to the machine's hostname; set it when you run more than one daemon for the same project on one host), the host and binary version, a fingerprint of the config in force, and the state —idle,iteration{n, taskRef, phase} while a session runs,drainingwhile a pause holds spawns,stoppingas the final beacon when the run ends. State changes beacon at phase boundaries too, so the page does not wait out a poll interval to learn which phase is running. The plane stores exactly what you report; it infers nothing. - Iteration history: exactly one record per drain, posted at the boundary it
just crossed — the task, the phases attempted, how it ended (
checkpointwork left for a takeover /releasedback to the queue or settled /parkedfor the operator /dead), duration, and tokens.
Everything is strictly fail-soft telemetry: a failed report is logged once and
dropped, never retried (v1), and can never block, delay, or fail the loop, a
claim, or a phase. Reports go to /api/projects/<slug>/fleet/report on the same
trusted origin as every other credentialed call and are authenticated by your
project API key - the same key the sessions use, never anything extra.
clankerbar doctor checks the wiring in its PASS/WARN table (fleet): whether
a report endpoint could be derived and is reachable, with the key accepted -
verified without writing anything, so a cron'd doctor cannot fight a live
daemon's presence row.
Checking what a session says it delivered
The control plane holds the backlog and takes a clanker at its word. When a session
records a branch (update_task(branch: …)) or declares a delivery merged
(delivery.commit + integrationBranch), nothing on the plane can look at a
repository to see whether either is true. One task read done for four days while
about 900 lines of its work sat unpushed on one laptop, and its own PR merged a
stale snapshot.
The driver is already local and already in the git tree, so it checks both claims after each session — no new credentials, no plane change:
- A recorded branch is really on the remote, and the local tip is an ancestor of the remote tip. A local branch ahead of its remote is the failure above.
- A declared delivery really landed:
commitis an ancestor of the remote tip ofintegrationBranch— the same ancestor check the plane asks the clanker to attest to, run rather than trusted. The integration branch is read from the remote, never from a localmainthat is routinely tens of commits stale. Only a closure is checked this way: a hand-off toin_reviewcarries the delivery it is proposing, and at that moment nothing has merged yet. And only a commit id counts — a revision expression likemainorHEADis trivially its own ancestor, so it is reported as unverifiable rather than checked.
DELIVERY UNVERIFIED — CLA-134: branch "clanker/x" is ahead of origin/clanker/x by
12 commits — local tip 4a91c0de, remote 0b3f21aa; that work is UNPUSHED and
exists only in /Users/you/dev/acme
It warns; it does not refuse. A failed check is logged loudly and names what is
unpushed or unmerged, but the session's own report stands — the driver does not
revert a status a session chose on the strength of a check that could be looking at
the wrong tree. When the merge check ran, its verdict is written back to the task
as delivery.mergeVerified, true or false, so the answer outlives the log.
It fails open. No git, no remote, a workdir that is not a repository, a remote tip that cannot be fetched — all report could not verify and the run carries on. Blocking a legitimate closure because the tree could not be found would be worse than the gap this closes. A check that could not run never writes an attestation: not knowing is not the same as knowing it is fine.
Finding the tree. The driver spawns sessions in a workdir, but the work happens
in a per-task worktree it did not create — and the workdir is routinely a
multi-repo parent (~/dev) that is not a repository at all. Linked worktrees share
their repository's refs, so the specific directory does not matter: the driver
looks for the repository whose refs carry the branch, searching the workdir and
its first two directory levels (enough to reach both ~/dev/<repo> and
~/dev/<repo>-wt/<task>). Repositories are never descended into and bare ones are
skipped, so the search costs a bounded handful of git rev-parse calls. If two
repositories under the workdir carry the same branch — a review clone sitting
beside the session's own tree — that is reported as unverifiable too: checking the
wrong tree is worse than not checking.
This covers unattended runs only, by design: an interactive session bypasses the driver entirely.
The PR a delivery names
When a session's delivery names a pull request, the driver also checks it through
the gh CLI - the one part of the check that reaches GitHub rather than your
local tree. Two conditions, both required:
- The PR is MERGEABLE. A
CONFLICTINGPR is refused: a conflicted branch gets nopull_requestevent at all (GitHub cannot compute the merge ref), so it reports zero checks rather than a failing one, and quiet CI proves nothing. - The check rollup actually ran and passed. An empty rollup is a refusal, not a pass - "no checks found" is the exact signature of the conflicted PR above, so silence never reads as green. Skipped/neutral checks (path-filtered jobs) pass but are named in the log.
GitHub computes mergeability lazily, so an UNKNOWN first read is polled within
a bounded window (30s) and then refused, never assumed. Failing or
unfinished checks refuse by name. If gh is not installed the check degrades
to an explicit could-not-verify - never a pass - and clankerbar doctor's
pr_gate line warns about it up front.
Repos with no CI at all: refusing every delivery there would wedge the
driver shut, so a per-project opt-out exists - set "allow_unchecked_pr": true
(top level, or on a projects[] entry) and an empty rollup is logged as a
WARNING instead of a refusal. The mergeability condition is never relaxed: a
conflicted PR refuses either way. doctor prints the resolved state per
project, so a loose project is visible before it fires.
Rescuing work a killed session left behind
A usage limit kills a session mid-turn. There is no graceful shutdown, so the
protocol's commit, push, record the branch before you let go never runs — there
is no before. The uncommitted work survives in the worktree and nowhere else:
invisible to the plane, invisible to another machine, invisible to the next
session. The lease then expires with no branch recorded, the task correctly
returns to ready, and the next clanker starts from nothing — redoing hours of
work sitting intact on the same disk. One such task cost 112.7M tokens, a third
of a day's spend, for work that was done twice.
So when a session ends still holding a task, the driver looks for the worktree it was working in and, if anything is uncommitted, commits it, pushes it, and records the branch on the task — turning a redo into a takeover:
salvaged CLA-314: committed the uncommitted work in /Users/you/dev/acme-wt/cc561415
as 4a91c0de and pushed it to origin/clanker/cc561415-a-session-killed-mid-task
recorded clanker/cc561415-a-session-killed-mid-task as the hand-off branch for
CLA-314 — the next clanker takes it over instead of starting again
On every abrupt ending, not only a usage limit. A crash, a killed process and a limit are different to the supervisor and identical to the worktree, and the limit is only detected by parsing a stream that may not have arrived whole. Running it when it was not needed costs nothing: a clean worktree commits nothing, pushes nothing, and records nothing — an empty hand-off would send the next clanker to fetch a branch with nothing on it, which is worse than recording none at all.
It commits work it cannot vouch for, and says so. The driver cannot tell a
half-applied refactor from good work, and the failure it exists to prevent is
caused by dropping work, never by keeping it. So the commit lands with a subject
that reads WIP salvage: … (unreviewed, may not build) and a body saying in as
many words that nothing here was reviewed, built or tested. It skips hooks and
commit signing on purpose: a lint hook would reject exactly the half-finished
tree this exists to save, and a signing passphrase has nobody to ask at 3am.
One exception: a worktree holding an unresolved conflict is left exactly as
it is, because committing it would record a state nobody chose — it would
fabricate a resolution out of the conflict markers and push a file that reads as
source with <<<<<<< in it. That covers both a git operation still in flight (a
merge, rebase, cherry-pick or revert) and a conflict nothing is holding open: a
git stash pop, git apply --3way or git checkout -m leaves unmerged entries
with no state file at all, so the unmerged index is checked as well as the
operation. Either way it is logged as STRANDED WORK LEFT AS IS with the path.
It cannot touch a tree that is not this task's. The worktree is identified by
its checked-out branch carrying clanker/<first 8 characters of the task id> —
the task-id half, not the title-derived slug, so a task retitled mid-flight is
still matched. A detached HEAD is never matched, main and staging can never
match, and two trees answering to the same task is reported rather than guessed
at. Nothing is ever forced: no push --force, no checkout, no branch creation,
no worktree removal. A push the remote rejects leaves the commit safely on this
machine and records no branch — a recorded branch is a promise that another
machine can fetch it.
Recording a branch is not settling a claim. The record carries no status, so
it cannot move a task, clear a holder, or post ready over work already in
review — which is why it is still safe on a session whose output could not be
read whole, where handing the claim back is forbidden. Once a branch is
recorded, the claim is deliberately not handed back either: the lease expires and
the task stays a takeover, so the next clanker is told there is work to fetch.
Skills, plugins, and auth
A headless session loads project skills from the working directory
(.claude/skills/) and user/plugin skills + auth from the config dir. Set
config_dir (→ CLAUDE_CONFIG_DIR / CODEX_HOME) so an unattended run loads the
same skills, plugins, and login as your interactive session — a bare cron
environment would otherwise have none of them.
Config
Flags override the config file, which overrides defaults. Exactly one file is
discovered on its own: ~/.config/clankerbar/config.json. Any other file has to
be named - clankerbar run -c ./clankerbar.json - and an unnamed
clankerbar.json sitting in the directory you launch from is refused, not
read; where the key may go
says why. (JSON today; TOML is the likely final format.)
{
"harness": "claude",
"backlog_url": "https://clankerbar.com",
"model": "opus",
"models": { "strong": "opus", "standard": "sonnet", "cheap": "haiku" },
"prompt": "Work the next backlog item.",
"max_turns": 400,
"max_session_wall_clock": 0,
"mcp_config_path": "./.mcp.json",
"config_dir": "~/.claude",
"instance_name": "",
"env": {},
"idle_poll_interval": "60s",
"poll_interval": "30m",
"max_retries": 0,
"retry_cap": "5m",
"max_zero_spend_attempts": 3,
"budget": {
"max_tokens": 0,
"max_cost_usd": 0,
"max_wall_clock": "6h",
"max_session_tokens": 0,
"per_harness": {}
}
}
max_turns bounds every session's turns when its phase sets none of its own:
the phase's max_turns wins, then this, then a built-in default of 400
(CLA-343 — an unphased run used to have no cap anywhere in the chain, and one
session ran 1093 turns / 285.9M tokens with nothing able to stop it). "0" no
longer means uncapped; it means "defer upward". Claude only: the other
harnesses take no turn flag, so for them the cap is inert.
budget.max_session_tokens is the per-session runaway detector: when a
session's own cumulative usage crosses it, the harness kills the session
mid-stream and the salvage commits whatever it left (CLA-343). Unlike the
run-wide breaker, which only checks BETWEEN sessions, this one can stop a
single huge session in flight — the 285.9M runaway was 3.8x its run's whole
max_tokens ceiling. 0 defers: to 2x budget.max_tokens when that is set,
else to a 150M floor (~2.3x the largest legitimate session measured, CLA-309).
It is deliberately always on — it is a detector, not a budget. Claude only
today: the other harnesses' streams are parsed but never killed mid-session,
so for them the dial is inert until an adapter grows the kill.
max_session_wall_clock is the same backstop measured in TIME, for a harness
whose CLI takes no turn flag: when a session outlives it the adapter ends the
process and the driver treats that exactly like a hit turn cap — the session
ends, nothing is retried, nothing fails, and the run carries on (CLA-368). A
phase's own max_wall_clock wins over it, and 0 is off, which is the
default: unlike the turn cap it ships no built-in number, because a duration
that catches a runaway on one model/provider is a routine session on another. It
is measured per SESSION, so it is not budget.max_wall_clock, which is the
run-wide ceiling and counts the hours a run spends waiting out a usage limit.
Since CLA-406 the cap bounds an opencode session's WHOLE life including any
quiet-death resurrections: the budget is computed once at spawn and spent down
across the original run plus up to 5 resume rounds, not refreshed per round.
The inter-round overhead (25s backoff plus a 30s probe box per round) is
charged against it like any other time, so a fully-capped session does not
materially outlive the dial; a resurrection round is not started unless the
remaining slice covers that overhead plus a minute, and a continuation is not
started on a remaining slice under a minute.
opencode only today — it is the harness with no turn flag, so this is its
backstop; under claude or codex the dial is inert and doctor says so. Two
consequences of that pairing are worth knowing before you set it. First, the
salvage applies here: since CLA-365 opencode observes the session's task claim,
so when a capped session ends, whatever it left uncommitted is committed and
pushed by the salvage, the same as for a turn-capped claude session - unless
the salvage refuses (an interrupted merge, rebase, cherry-pick or revert;
unresolved conflicts; an ambiguous worktree match) and leaves the work
untouched, or the push is rejected and the work stays committed locally only.
Both of those are named in the log. If instead no worktree at or below the
run's workdir has a branch starting clanker/<first 8 characters of the task id>, the salvage finds nothing to act on and stays silent.
Second, phases runs on opencode on that same claim-tracking basis (a
multi-phase sequence is refused over a harness that observes no claim, which
is codex today), so the per-phase max_wall_clock is reachable in a phased
config too - though such a run still has no per-session turn or token cap (see
max_turns and budget.max_session_tokens above), so set this dial
deliberately.
mcp_config_path defaults to <workdir>/.mcp.json when that file exists — Claude's
headless mode does not auto-discover it, so under harness: "claude" the default
is what gives spawned sessions their clankerbar tools. It always gives the driver a
project slug to poll with, whichever harness is configured.
The other two harnesses do not read that file, and one of them chokes on it. The default is applied regardless of harness, so this matters the moment you switch:
| harness | what it does with mcp_config_path |
|---|---|
claude |
passed as --mcp-config (with --strict-mcp-config) and read as Claude's .mcp.json. |
codex |
ignored. codex has no per-run MCP flag; its servers come from [mcp_servers] in config.toml under CODEX_HOME (which config_dir pins). |
opencode |
passed as OPENCODE_CONFIG, and must be an opencode config - servers under mcp, not Claude's mcpServers. Pointed at a Claude-shaped .mcp.json, opencode refuses to start and every session dies at spawn. |
Under opencode, set mcp_config_path explicitly to an opencode config. Leaving
it out does not opt out: an empty value re-runs the <workdir>/.mcp.json discovery,
so a workdir that carries a Claude .mcp.json hands it over no matter what. Run
clankerbar doctor - it prints the caveat instead of a verdict where the file is not
read, and FAILs the workdir check when opencode is pointed at a Claude-shaped one.
A discovered .mcp.json may not choose what runs
A file that arrives through the default above was FOUND, not named - and a checkout can write it as easily as you did (sessions run with edit permission in that directory, and the damage would land on the next unattended start). Since it is handed to the harness whole, a config nobody pointed at is held to three rules:
- An MCP entry carrying a
command- a locally spawned server - is refused. It starts at session init, before any permission rule applies, running as you with the session's whole environment (CLANKERBAR_API_KEYincluded). Entries you meant go under"allow_local_mcp_servers": ["docs"]- top level, or on aprojects[]entry (which replaces the top-level list for that project). The other way out is naming the file: an explicitmcp_config_pathadopts it wholesale. - Keys that decide what a session IS are refused outright. Under opencode the
file is the session's ENTIRE config, so a discovered one may not set
permission(it would replace the fail-closed policy the driver pins),plugin, oragent. No allowlist reaches these; name the file if you mean them. - A discovered file that cannot be read is refused, not passed through.
allow_local_mcp_servers gets the same visibility as every other loose state:
doctor's mcp_servers check reports the list beside the entries it admits. A
file you NAMED with mcp_config_path is exempt from all three rules - naming it
is the statement that you vetted it - and its local servers are still disclosed
by doctor's WARN as they always were.
Point it at your workdir, not at a checkout of this repo. The .mcp.json at the
root here is the maintainers' own agent wiring and names the clankerbar project
slug; running the loop from inside this checkout would have it poll a queue you
cannot read, which it refuses rather than drains.
env: the daemon owns each session's environment
env declares extra environment for spawned sessions, so load-bearing variables
do not depend on how the binary happened to be launched - with GH_TOKEN
declared here, the wrapper script that used to export it stops being
load-bearing and starting clankerbar run directly is safe (CLA-462). A value
is either a literal string or a command whose stdout becomes the value:
{
"env": {
"GH_TOKEN": { "fromCommand": "gh auth token -u your-account" },
"CLAUDE_CODE_OAUTH_TOKEN": "@~/.secrets/claude-oauth"
}
}
The map overlays at four levels, most specific wins per key, in the same order
the MCP config uses: top level, then harnesses.<name>.env, then
projects[].env, then that project's env_per_harness.<name>. In multi-project
mode declare each project's token on its own entry, so no session ever carries
another project's credential.
Three properties carry the security and reliability weight:
- Command-derived values are resolved fresh at every spawn. A rotated token reaches the next session with no daemon restart. A command that fails, times out (10s), or prints nothing REFUSES that spawn with a log line naming the variable - never a silent partial env, because a session missing its declared environment is the incident, not a degraded mode.
@pathsecrets stay owner-only, enforced. A file any other local account can read refuses the spawn, and so does a file that has been emptied - an invisible token rot must fail at spawn with the variable named, not inside git later.- Values come from the operator's own config only. Nothing in a workdir or checkout feeds this map; the credential-origin rule for the account key is unchanged.
Variable names must be valid names ([A-Za-z_][A-Za-z0-9_]*) and commands must
be non-empty; both are refused at load. doctor runs every declared command and
re-checks every @path file, so a source that has stopped working is a FAIL in
the preflight instead of a refusal at 3am, and it WARNs when a project declares
repos but no GH_TOKEN source anywhere.
Where the account-scoped key is allowed to go
CLANKERBAR_API_KEY is an account-scoped bearer token — one key covers every
project you belong to, not just the one being drained. So where it may be sent is
pinned, not inferred, and the pin is your own backlog_url:
backlog_urlis the one trusted origin. It is the only origin the driver ever sends the key to. It defaults tohttps://clankerbar.com; to point at a self-hosted plane, name it there instead (e.g."backlog_url": "https://plane.example.com"). It is no longer derived from.mcp.json— a checkout's.mcp.jsoncontributes at most a project slug, and never redirects the key.- Set it in a config file the sessions cannot rewrite.
backlog_urlis read from~/.config/clankerbar/config.json- the one file discovered automatically - or from a file you name with--config. Aclankerbar.jsonsitting unnamed in the directory you launch from is refused, not read, and the loop stops rather than falling back to your home config: that directory is, by default, the checkout the spawned sessions edit, so a session could rewrite the field that decides where tomorrow's key goes. Naming it (-c ./clankerbar.json) does lift the refusal - but it lifts only the implicit adoption and guards the file no further, so keepbacklog_urlitself in~/.config/clankerbar/config.json. - The scheme floor is TLS:
httpsanywhere, orhttponly to loopback (a plane under local development).
Fixating on the origin is what makes the file-for-credential leak closeable. The
MCP config is handed to the harness whole, and the harness's session carries the
key in its environment, so a checkout's .mcp.json could name any host it liked;
that is the exfil it refuses rather than silently polls.
clankerbar run logs where the key will go, once, at startup (with the usual
clankerbar: <time> log prefix in front of it):
clankerbar: 12:54:24 sending the API key to https://clankerbar.com
and clankerbar doctor prints the same origin as a preflight line:
api key origin: https://clankerbar.com
That line is the thing to check in an overnight log: if it names an origin you did not choose, stop the run before it drains anything.
Refusals, each with its remedy. A config or MCP file that would move the
account key off that origin is refused at startup and flagged by doctor — never
silently dropped, because dropping the file would spawn a session with no
clankerbar tools and read as "the backlog was empty":
| Refusal | When | Remedy |
|---|---|---|
| Foreign origin | an MCP server that carries the key points at an origin other than backlog_url |
set backlog_url to that origin if you mean it, or fix the file — a checkout's .mcp.json is not trusted to redirect an account-scoped key |
| Cleartext non-loopback | backlog_url (or a server URL) is plain http to a host that is not loopback |
use https — the key would cross the wire in cleartext |
| Local server handed the key | a server entry that carries the key (clankerbar, or any entry whose headers/env reference the variable) declares no url — a local/stdio server |
give the key only to a URL-based (HTTP) server on backlog_url — a spawned process may send it anywhere |
| Unparseable MCP config | an MCP config that exists (at mcp_config_path, or a per-harness/per-project equivalent) cannot be read or parsed, so its destination cannot be checked |
make the file readable and valid JSON so the check can see where it points |
Each of those errors is written to carry its own remedy, so they read as policy rather than as a bug.
Multi-repo projects: repos and primary_repo
A task can name the repo it belongs to, and a session should start in THAT checkout rather than somewhere unconsidered. Two keys configure it - top level, or per project in multi-project mode, where a project's own non-empty values replace the top-level ones:
{
"repos": {
"lecstor/clankerbar-cli": "~/dev/clankerbar-cli",
"clankerbar": "../clankerbar"
},
"primary_repo": "lecstor/clankerbar-cli"
}
repos maps a repo identity to its local checkout. The key is what a task's
repo field carries: the full owner/name, or just the bare name. The value is
a directory path, relative to the workdir or starting with ~. It holds paths,
never credentials.
primary_repo names where a session starts when its task carries NO repo:
a repos key, or any identity resolvable by the same steps below. With exactly
one repo declared and no primary named, that one is primary implicitly. Left
empty with two or more repos declared, a repo-less task fails the iteration
instead of guessing.
Resolution of an identity to a checkout, first match wins:
- a
reposentry keyed by the full identity (owner/name); - a
reposentry keyed by the bare name; - the workdir itself, when its basename IS that bare name;
<workdir>/<bare name>, when such a directory exists;- nothing: the iteration fails loudly with
repo_not_foundnaming the identity, rather than starting the session in the workdir anyway.
With NOTHING configured at all the feature is off and the legacy workdir behaviour runs unchanged, so existing single-repo configs need none of this. Every resolved path is verified to exist as a directory before use; doctor reports each entry's resolution so a broken path is seen before it fails a run.
What each harness receives. Declaring repos also widens every session's reach past its spawn checkout, so a session started in repo A can still work on repo B:
claude:--add-diron the session argv, followed by one argv ELEMENT per extra checkout (the flag is variadic over separate elements). The settings file's deny rules still govern what it may actually do there.opencode: anexternal_directoryallow per declared checkout in theOPENCODE_PERMISSIONpolicy, matched absolutely regardless of cwd.codex: nothing on argv. Codex's sandbox has no per-invocation flag for extra writable roots; declare them yourself under[sandbox_workspace_write] writable_rootsin yourconfig.toml. The driver does not rewrite a sandbox you own.
The conventional task-worktree area beside a checkout (<checkout>-wt as its
sibling) joins the grant list automatically whenever that directory exists, so
a session can create and edit the worktree it is told to work in.
Execution config from the plane
A project's execution dials - harness, model, tier buckets, budget, review-tier
escalation rules, and the two backstops (max_turns, max_session_wall_clock,
as integer seconds) - can live on the plane instead of in this file. The
daemon reads each project's stored document (per project, so two queues can run
two postures) and overlays it over this file; until a project has one, this
file rules byte for byte. doctor reports which is in force per project, and
warns when a stored dial cannot fire on your machine (a turn cap under opencode,
say) - shape-validates on the plane, machine-fit is doctor's.
Edits apply at the next iteration boundary, never mid-session: the daemon
watches the version on the backlog-summary poll it already makes, and on a bump
re-reads and re-overlays before spawning again. A document that fails local
validation keeps the previous config and says so loudly. Flags still outrank the
stored document: --model on the command line wins over a ratified value.
Import today's file as the starting point with:
clankerbar propose-config # --slug <project> when projects[] names several
That records a PENDING proposal; nothing changes until you ratify it in the console (Settings -> Run configuration). Phases, prompts, wiring, env and paths stay local: the plane custodies policy, never machine fit or credentials.
Multi-project: one instance, many queues
One loop instance can drive several projects with a single account-scoped
key — you never need an instance per project, or per-project keys. Declare the
projects; the driver polls each queue and round-robins sessions across whichever
have claimable work, each session spawning in its own project's workdir (whose
.mcp.json names /mcp/<slug>, selecting that project's tools):
{
"harness": "claude",
"projects": [
{ "slug": "clankerbar", "workdir": "~/dev" },
{ "slug": "ezyapp", "workdir": "~/work/ezyapp" }
]
}
Per entry: slug (required, the <slug> in /mcp/<slug>), workdir, and
optionally mcp_config_path (defaults to <workdir>/.mcp.json). Budgets,
max_iterations, and the control markers (STOP/HALT plus the
ctl restart/reload family) stay instance-global — one
operator, one spend pool; the console pause is per project. With no projects
list, the top-level fields drive a single project exactly as before.
Resilience
A fresh session can die on a server blip (API 5xx / overloaded) or a network
hiccup — nothing to do with the task. These are retried with exponential
backoff (30s → 60s → … capped at retry_cap), re-running the same iteration; a
fresh session reclaims any half-done task, so a retry costs minutes, not work.
Detection is anchored (Claude's API Error: prefix, connection-error strings), so
a task log that merely mentions an HTTP 500 isn't mistaken for a dead session,
and under Claude a 400 still stops. max_retries: 0 (the default) means never give up —
keep retrying at the ceiling until the API recovers, right for a daemon; set a
positive number to bound it. A usage-limit pause and a transient retry both re-run
the same iteration and neither advances the iteration count. STOP stays
responsive during any wait — and so do the restart/reload markers (ctl restart, --now, reload).
One thing is bounded even under max_retries: 0: attempts that report no spend
at all. A budget ceiling can only stop spend it is told about, and the figures it
reads arrive in the harness's own usage events. A session killed before any of
them arrives - the very thing a transient retry exists for - contributes zero,
so with a token- or cost-only budget the ladder is: attempt, zero spend, back off
at retry_cap, attempt, forever, with only max_wall_clock able to end it. So
max_zero_spend_attempts (default 3) caps consecutive attempts within one
retry ladder that ended without the harness reporting any usage, and the run stops
with a distinct error naming the zero-spend loop - not a budget stop, not a
wall-clock stop, because what it means is that sessions are dying early rather
than working. An attempt that does report resets the count, including one that
honestly reports zero, so an ordinary retry ladder is untouched and a session
that legitimately did nothing is never counted against you. A usage-limit pause
is counted neither way: a session the cap turned away often reports nothing at
all, and waiting out a quota overnight must not read as sessions failing to start.
The ladder is per phase, so a phased config allows the bound in each. Raise it if a
harness of yours dies silently more often than that.
And one more thing is bounded: retries taken on a failure the harness could not
name. opencode's error surface changes between versions, so beyond the anchored
patterns it also guesses: an exit 1 from a session that had already reported
usage is retried, on the reasoning that a session which authenticated and did paid
work hit something mid-flight rather than a bad config. That guess is what carries
a stream drop from a version the patterns haven't caught up with — but a
deterministic fault satisfies it just as well, and every dial above is blind to
that one. max_retries and budget are off unless you set them, and
max_zero_spend_attempts cannot fire here at all, because the reported usage the
guess requires is exactly what resets its counter. So a guessed retry is capped at
2 per phase, after which the run stops with its own distinct error quoting the
diagnostic nothing recognised. Retries the harness did recognise are not counted
against it — a provider flapping 5xx is still yours to bound with max_retries.
The cap is deliberately not configurable: the dials you set are for failures the
tool understands, and the honest number of guesses is small at any budget. If you
hit it, the error text is the thing to report — it is a failure shape the adapter
should learn a pattern for.
The quiet-death signature. A session that dies with a final step_finish
reason of unknown and all-zero usage on its own last step is the quiet-death
signature. The cause is an opencode client defect, not the provider - see
docs/opencode-build.md. The marker itself is
described in
docs/opencode-tool-schema-limits.md,
which is a separate failure with a separate cause.
Since v0.10.1 the adapter does not accept the signature as terminal: it backs off,
resumes the SAME session in place (opencode run --session <id>), and asks the
agent to prove it is intact by naming its task ref. A mechanical match continues
the session where it left off (bounded at 5 resurrections per session, one probe
per death); a failed probe falls through to the dead-phase path unchanged.
What gets read as a failure. A session's output is the whole event stream, and
the events quote the backlog verbatim — the task the session claimed is sitting in
the same bytes. So both classifications read only what the harness said: its
stderr, its own non-event output, its typed error events, and Claude's
terminal_reason. A task whose body happens to say "hit your", "usage limit" or
"api error: 500" is narration, and narration is never a cap and never a blip.
Scoping to the harness is necessary and not sufficient, which is why the patterns
are anchored on JSON fields and word pairs rather than single words: a harness's
stderr still carries text it did not write — a provider SDK's error prose, a Node
stack trace — and the MCP SDK names its own classes StdioClientTransport and
SSEClientTransport, so a bare transport would read a dead MCP server (a config
fault that must stop the run) as a network blip to retry.
Handing the task back. A claim carries a 30-minute lease that the session
heartbeats. When a session ends mid-task, that lease is left ticking with nobody
renewing it — and clankerbar charges the task a reclaim to sweep it up, of which
there are only two before the task is parked for a human. So the driver watches
the session's stream for what it claimed, and hands the task straight back
(update_task(status: ready)) on every exit: usage limit, transient blip,
outright failure, or a clean finish with the work unfinished. The usage-limit case
is why this matters most — the pause that follows can be hours, and the release is
ordered before it.
One case is deliberately left to expire: a task whose session pushed a branch. clankerbar computes its takeover hint only for an in-progress task with a dead lease, so handing that one back would discard the very flag telling the next clanker there is work to pick up. There it takes the reclaim and keeps the hand-off. Releasing is best-effort throughout — if the plane refuses or is unreachable, the loop logs it and carries on, since an expiring lease is merely the behaviour it already had.
Not getting locked out
No coding-agent harness lets a headless caller read its remaining subscription
quota, so clankerbar cannot stop at a precise "80% of the window". Two reliable
alternatives, in order of preference:
-
Separate credentials — run the loop under a different account / API key than your interactive agent use, so it physically cannot spend your daytime quota. This is the only can't-be-wrong option.
-
Budget circuit breaker — set
budget.max_tokens/max_cost_usd/max_wall_clock; the loop self-accounts and stops early. Blunt but simple — tune it by watching a couple of runs.Prefer
max_cost_usd- underclaudeoropencode. It comes straight from the harness's own reported cost, so it measures the thing you actually care about.max_wall_clockcounts the hours a run spends waiting out a usage limit, in which almost nothing is billed - one run elapsed 10h23m against an 8h ceiling with 5h31m of that asleep. Keep it as an outer bound on how late a run may finish, not as your spend ceiling.doctorwarns if it is your only dial.Under
codex,max_cost_usdis inert - setmax_tokensbeside it.codex exec --jsonreports tokens, not money, so the adapter never populates a cost figure and no code path can reach that ceiling: it is not approximate, it is absent. A codex run with cost as its only dial therefore has no effective ceiling at all, whatever the config says. Give itmax_tokens(ormax_wall_clock) alongside;doctorWARNs on the cost-only case rather than reporting a budget that cannot fire.A mixed-harness run needs one block per harness -
budget.per_harness."per_harness": { "claude": { "max_tokens": 75000000 }, "opencode": { "max_cost_usd": 2 } }The dials above are one ceiling over every session a run spends, and no single number means the same thing on two backends: 75M tokens is a sane week of Claude and roughly $2 on a DeepSeek-class backend, so the same
max_tokensthat paces a week ends a metered drain after a task or two; and a dollar figure is meaningless for a session billed to a subscription, which reports a price nobody is charged. So each harness can carry its own block, keyed by harness name, counted over only that harness's own sessions and measured in the unit that harness understands - tokens forclaude, dollars for a metered backend. Any block that trips stops the whole run: these are circuit breakers, not per-harness quotas.A token ceiling in a block derives the per-session runaway detector (
max_session_tokens) exactly as the run-widemax_tokensdoes, so moving a ceiling into a block does not quietly loosen it.The dials above keep working exactly as they always have, over the run's whole spend, and a config that sets no
per_harnessblock behaves as before. Set both and both apply, whichever is reached first. Wall clock is deliberately not available per harness: it measures the run, not a harness. The same goes for the CLA-262 stop above - a session whose spend cannot be measured stops the run when that session's harness is under a spend ceiling, its own block's or the run-wide one.doctorreads these the way it reads every other ceiling: what can actually fire. A block naming a harness this config does not run, or amax_cost_usdon a harness that never reports cost, is annotatedINERT, and a config whose only ceilings are unreachable is told it has none. A block keyed by a name no adapter answers to is refused at load, since nothing could ever be charged to it.max_tokenscounts about ten times more than it used to. Token totals now include cache reads and writes, whichinput_tokensexcludes and which dominate a long agentic session — one run reported 140,387 tokens against $147.98 of real spend, about $1.05 per thousand, which is no model's price. The old number was the wrong one, but amax_tokensyou tuned against it will now trip roughly ten times sooner. Re-tune it, or move tomax_cost_usd.max_wall_clockis measured on the monotonic clock, so time the machine spends suspended does not count against it. That is also the clock the limit-reset check uses, so the two cannot disagree about how much of a run is left.When a limit's stated reset falls beyond
max_wall_clock, the loop stops right then and tells you when the quota returns, rather than sleeping through the window to run one session against the fresh quota and stop on the next check.Every ceiling is also checked inside an iteration, before each re-run of a session that paused on a usage limit or backed off on a transient blip. Those waits can repeat for hours, and a run that reached its ceiling during one stops there instead of spending another session first.
The polls during a pause count too. Waiting out a usage limit means probing for an early reset, and a probe is a real harness session - cheap (a one-character prompt, with the harness locked down to no tools or read-only), but not free, and a week-long cap polled every 30 minutes is a few hundred of them. Their tokens and cost go into the same running total the breaker reads, so a pause whose probes alone cross a ceiling ends on that ceiling rather than polling on.
A pause holding a stated reset aims at it. When the limit carries a reset time, the first probe lands just past that time instead of on the next tick of the
poll_intervalgrid (still capped atpoll_interval, so a STOP marker never goes stale), and the paused log line says so. A stated reset is a claim, not a guarantee: once the pause crosses it, it falls back to probing everypoll_intervaluntil a probe actually confirms the cap has lifted - it never resumes on the stated time alone after having slept through it.A session whose spend cannot be measured stops the run, if you set a spend ceiling. Every figure the breaker reads is parsed out of the harness's event stream, and one enormous line — a
tool_resultcarrying a large file read — can end that stream early. The session's own total arrives in its last event, so a stream cut short reports near-zero for a session that may have cost hundreds of dollars. The loop refuses to count that figure, says so loudly, and then stops ifmax_tokensormax_cost_usdis set, because the ceiling you asked for can no longer be honoured. Undermax_wall_clockalone, or with no ceiling at all, it carries on: the clock does not depend on anything the child said. The same session's task is left to its lease rather than handed back — the settle that released it may be in the bytes that never arrived.
Harnesses
| Harness | Status |
|---|---|
Claude Code (claude) |
primary |
Codex (codex) |
adapter present; parsing being hardened |
OpenCode (opencode) |
adapter present; provider-agnostic (model comes from opencode's own config) |
New harnesses are a small adapter (internal/harness). Contributions welcome.
Permissions are set per harness, and the loop fails closed under all of them.
claude gets its allowlist from the settings file (--settings), codex from
config.toml, and opencode from OPENCODE_PERMISSION — a JSON policy baked
into the spawned process env. The opencode policy is a path-scoped
PermissionConfig: the run's workdir subtree (where the session's per-task
worktrees live, e.g. ~/dev/clankerbar-cli-wt/<task>) is allowed for
read/edit (the workdir root's own listing included), external_directory
— opencode's gate for any path-taking tool on a path outside the project
boundary — allows the same subtree while denying every other path (so
bash cp ~/.ssh/... stays blocked, exactly as before), and bash stays
allowed tool-level (its permission patterns match parsed commands, not paths).
Everything else — reads or edits outside the workdir, lsp/task/skill,
and the network tools (webfetch, websearch) in every shape — is denied by
a * catch-all rather than asked, and denied tools are hidden from the
session's catalog entirely. The read-only search tools are an exception
(CLA-390): grep, glob and list are allowed so a session can search the
code it is editing instead of burning turns on bash fallbacks. Their asks
carry the search pattern (a regex, a glob, a directory) rather than a
workdir-relative path, so the workdir boundary cannot live in the ask for
them — it lives in external_directory, which each tool runs before
searching and which stays scoped to the workdir subtree, so they reach
exactly what read already can and nothing more. Three more exceptions keep
the session alive inside that catch-all: *_* allows the MCP tools (tool
names are <server>_<tool> in opencode — clankerbar_get_backlog_summary,
context7_query-docs, chrome-devtools_click — and the plane itself is
reached over MCP); read also allows mcp:*, because opencode's MCP
resource tools
(list_mcp_resources, list_mcp_resource_templates, read_mcp_resource) do
not ask under their own names — they ask under read, with
mcp:<server>:<uri> patterns that the path-scoped read rules never matched,
so the catch-all denied every one of them and a session could call the plane's
tools while being unable to read the protocol served over the same connection;
and a probe/read-only run additionally denies edit and bash for zero
writes. The policy is pinned by unit tests in opencode_test.go, including
the JSON key order the catch-all depends on and a replica of opencode's rule
evaluator over a table of effective decisions.
Verifying the policy against a live session. The unit tests replicate
opencode's evaluator; to watch the real one decide, run a session with the
policy in OPENCODE_PERMISSION and read the evaluated permission=... pattern=... action.action=... lines in ~/.local/share/opencode/log/opencode.log — one
line per ask, naming the rule that won. That log is the cheapest live proof
available, because it records the decision whether or not the session goes on
to produce any output.
The policy covers both instance-directory shapes, and it did not always.
opencode makes its read/edit asks relative to the git worktree it resolves at
the session's instance directory, so there are two ask forms and which one
arrives is not the policy's choice. Outside any repo - the multi-repo parent,
~/dev - the worktree is / and asks are the absolute path minus its leading
slash (Users/jason/dev/...). Inside a checkout the worktree is the repo root
and asks are repo-relative (AGENTS.md). Until CLA-441 only the first form was
emitted, which was fine while every session ran in the parent and silently fatal
once they did not: the second form matched no rule, fell to the * catch-all,
and every structured Read/Edit was denied with no error and with the grants
apparently in place. Widening their scope could not help, because the shape
was wrong rather than the reach. Both forms are emitted now, and a session
started in a subdirectory of a checkout is scoped to that subdirectory rather
than to the repo above it.
Each session's instance directory is pinned to its workdir. cmd.Dir sets
the child's real cwd and nothing else; the inherited PWD still named wherever
the daemon was started, and opencode honours $PWD - it creates a second
instance there and runs the session in it. So every session every daemon spawned
ran in the daemon's start directory whatever the project's workdir said, which
is the same defect as the wrong project's MCP server (below), the wrong ask shape
(above), and the wrong repo's AGENTS.md. Every adapter now sets PWD to the
directory it is about to run in and drops OLDPWD.
And the project's MCP block is pinned past every layer opencode merges after
it. opencode's config order is: its global file, then OPENCODE_CONFIG (the
file the driver names), then any project-level opencode.json discovered from
the instance directory, then the OPENCODE_CONFIG_DIR directory again, then
OPENCODE_CONFIG_CONTENT. Two of those layers land after the driver's file,
and both were observed redirecting the clankerbar server at another project -
a global block for sessions running in ~/dev, a committed repo-level
opencode.json for sessions running in a checkout. The named file's bytes are
therefore also sent as OPENCODE_CONFIG_CONTENT, which opencode merges after
every layer this driver or a checkout can write, so a drain session reaches the
backlog it polls. (Three layers do follow it - the console active-org config, a
managed config directory, and managed preferences - none of them writable by a
repo or by us.) A file that still names another
project is reported by doctor's opencode_ambient_config check and logged once
at startup - a warning, not a refusal, since spawned sessions are pinned past it
and the file is often a checked-in artifact of somebody else's repo, but every
interactive session in that tree still gets the wrong backlog. The same scan
covers opencode's global config directory ($XDG_CONFIG_HOME/opencode, then
~/.opencode, then whatever config_dir names) and reports a second kind of
finding that the content pin does not mitigate: a discovered config carrying
permission, plugin, an agent with tool or permission authority, or an
mcp server that starts a local process. mcp_config_path files are refused
for those keys; a file opencode discovers by itself is not, and it is merged
into every session started in that tree. permission is the sharpest of them -
the env policy is applied after every config layer, but the merge is per key and
the flatten is in insertion order, so a file declaring permission.read moves
that key ahead of the sorted-first * catch-all and the catch-all matches last,
denying everything.
Docs
docs/releases.md- the release/publish pipeline: how a change gets from a task PR to a published binary.docs/large-tool-results.md- how the driver reads a tool_result that Claude Code persisted to a file instead of inlining.docs/opencode-tool-schema-limits.md- the opencode-go request-time tool-schema rejection family (unsupported_tool_schema/tool_count_limit): the incident, the real root cause behind the misleading error code, and what the CLI does about a recurrence.docs/opencode-build.md- which opencode build the fleet runs and why notopencode2, how to tell which one actually ran, and the upstream defect that makes a session exit 0 having done nothing. Start here for a session that dies withreason: "unknown"and zero usage.
License
MIT (see LICENSE).
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
clankerbar
command
Command clankerbar drives a coding-agent CLI (Claude Code, Codex, ...) through a clankerbar backlog, unattended: it respawns fresh harness sessions that work the backlog one task at a time, and survives usage limits.
|
Command clankerbar drives a coding-agent CLI (Claude Code, Codex, ...) through a clankerbar backlog, unattended: it respawns fresh harness sessions that work the backlog one task at a time, and survives usage limits. |
|
release-tool
command
Command release-tool is the release pipeline's two decisions, in one binary the workflows call: what version a promotion implies, and whether the `ci` check on the commit that ships actually passed.
|
Command release-tool is the release pipeline's two decisions, in one binary the workflows call: what version a promotion implies, and whether the `ci` check on the commit that ships actually passed. |
|
internal
|
|
|
backlog
Package backlog is the driver's own cheap, read-only view of the clankerbar backlog — used to decide whether there is claimable work before spending tokens on a harness session, to keep polling (and logging) while idle so the loop reacts when questions are answered, items are promoted, or new work is filed, and to learn when the operator has paused the run from the web console.
|
Package backlog is the driver's own cheap, read-only view of the clankerbar backlog — used to decide whether there is claimable work before spending tokens on a harness session, to keep polling (and logging) while idle so the loop reacts when questions are answered, items are promoted, or new work is filed, and to learn when the operator has paused the run from the web console. |
|
cli
Package cli wires the command-line surface to the loop driver.
|
Package cli wires the command-line surface to the loop driver. |
|
config
Package config holds the loop's runtime configuration: a file (JSON for now — TOML is the likely final format, matching Codex's own config) overlaid with explicit command-line flags.
|
Package config holds the loop's runtime configuration: a file (JSON for now — TOML is the likely final format, matching Codex's own config) overlaid with explicit command-line flags. |
|
deadscan
Package deadscan is the retrospective half of CLA-402: a read-only scan over the daemon's iteration logs that reports, per day, per phase and per harness, how many sessions ran and how many of those died producing nothing.
|
Package deadscan is the retrospective half of CLA-402: a read-only scan over the daemon's iteration logs that reports, per day, per phase and per harness, how many sessions ran and how many of those died producing nothing. |
|
delivery
Package delivery checks, against local git, the delivery claims a harness session makes to the control plane.
|
Package delivery checks, against local git, the delivery claims a harness session makes to the control plane. |
|
fleet
Package fleet reports a daemon's activity to the plane: presence (one upserted row per instance, refreshed on every backlog poll) and iteration history (one append-only row per drain, posted at the boundary the loop just crossed).
|
Package fleet reports a daemon's activity to the plane: presence (one upserted row per instance, refreshed on every backlog poll) and iteration history (one append-only row per drain, posted at the boundary the loop just crossed). |
|
harness
Package harness abstracts a coding-agent CLI (Claude Code, Codex, ...) behind the small contract the loop needs.
|
Package harness abstracts a coding-agent CLI (Claude Code, Codex, ...) behind the small contract the loop needs. |
|
loop
Session-initiated handoff (CLA-352): a session ends its FINAL message with a marker line followed by the prompt for its successor, and the driver respawns a fresh session on that prompt — same task, same run-continuity rules as a phase resume — instead of moving to the next backlog iteration.
|
Session-initiated handoff (CLA-352): a session ends its FINAL message with a marker line followed by the prompt for its successor, and the driver respawns a fresh session on that prompt — same task, same run-continuity rules as a phase resume — instead of moving to the next backlog iteration. |
|
plane
Package plane is the driver's small WRITE client for the clankerbar control plane.
|
Package plane is the driver's small WRITE client for the clankerbar control plane. |
|
release
Package release decides what a `staging -> main` promotion should publish: the next semver tag its conventional commits imply, and whether the required `ci` check on the merge commit actually passed.
|
Package release decides what a `staging -> main` promotion should publish: the next semver tag its conventional commits imply, and whether the required `ci` check on the merge commit actually passed. |
|
salvage
Package salvage rescues the work a harness session leaves uncommitted when it is killed mid-task.
|
Package salvage rescues the work a harness session leaves uncommitted when it is killed mid-task. |
|
secureurl
Package secureurl holds one rule, in one place: where a bearer token may be sent.
|
Package secureurl holds one rule, in one place: where a bearer token may be sent. |
|
statedir
Package statedir owns the one directory the DAEMON writes on its own account: the loop's control markers (STOP/HALT and the CLA-461 restart/reload family) and its per-iteration transcripts.
|
Package statedir owns the one directory the DAEMON writes on its own account: the loop's control markers (STOP/HALT and the CLA-461 restart/reload family) and its per-iteration transcripts. |
|
teststate
Package teststate keeps `go test` out of the operator's real loop state dir.
|
Package teststate keeps `go test` out of the operator's real loop state dir. |
|
version
Package version holds the binary's own version string in a form any internal package can read without an import cycle.
|
Package version holds the binary's own version string in a form any internal package can read without an import cycle. |