Agent orchestration for one operator (with a life).
Crux runs a standing lead on every project you give it — a durable agent that parks on the project's stream, picks up each thread as it arrives, answers what it can, spawns a headless worker for the code, and reaches your phone only when the call is actually yours.
This is the CLI. It is the interface: every verb below is one an agent (or you) drives crux with.
npm install -g @coulb/crux-cli
crux installNode 18+. Zero dependencies. The installed command is crux.
npm install -g @coulb/crux-cli
crux install # the server, and your API token
cd ~/src/acme
crux discover # this repo is now a crux project
crux swarm up # a standing lead is now working it
crux push "the importer keeps timing out" # give it something to doThat is the whole loop: a token, a project, a lead on it, and work going in. Everything below is detail.
Every other verb needs a server and a token. This is where you get them.
$ crux install
crux install — writes ~/.config/crux/config.json, which is what every other crux command reads.
Server URL [https://crux.coulb.com]
Your API token — the one thing crux cannot work out for itself.
Don't have one? Sign up at https://crux.coulb.com/register
The token is shown ONCE, on the page you land on right after you
register, under "Your API token — shown once". Copy it there.
It is not your password, and there is no second chance to read it.
API token (not echoed):
Checking https://crux.coulb.com… ✓ the token works (0 projects).
✓ ~/.config/crux/config.json — created.
url https://crux.coulb.com
token verified, and never printed
mode 0600 — this file is a bearer token to your whole feed
Next: crux discover # turn a repo into a crux projectThe URL is defaulted, not asked. https://crux.coulb.com is the answer for everyone who is not
self-hosting, so Enter accepts it. The token is asked, because it is the one thing only you have —
and the prompt says where to go and get one, because the person who most needs this command is the
person who does not have one yet.
Where the token comes from. Sign up at https://crux.coulb.com/register. It is shown once, on the page you land on immediately after registering. It is stored hashed, so that render is the only time it will ever exist on a screen — copy it then. It is not your password.
Lost it? There is no CLI verb to reissue one yet (issue #3) — though POST /api/login (email + password) does mint a fresh
token today, which is the door that verb will come through.
Three rules it will not bend:
- It never writes a config that does not work. The token is checked against the server
(
GET /api/projects) before the file is written. A 401 fails the command and writes nothing — a config that is saved and then does not work is worse than none, because you would believe you were set up. - It never clobbers silently. An existing config is shown (the url — never the token) and you
are asked.
--forcemeans it. Under--yesthere is nobody to ask, so it refuses: the token in that file was shown once and cannot be shown again. - The file is
0600, and the token is never echoed — not to your terminal, not into your shell history, not into--json, not into an error message.
crux install --url http://localhost:8000 # a self-hoster; a bare host means https
CRUX_API_TOKEN=crux_… crux install --yes # a script, or a machine being provisioned
crux install --force # replace the config that is already there
crux init # the same command, under the name you may reach forcrux install writes it for you. What it writes:
- env
CRUX_API_URL/CRUX_API_TOKEN— these win over the file ~/.config/crux/config.json(or whereverCRUX_CONFIGpoints):
{ "url": "https://crux.coulb.com", "token": "…" }The token is your API bearer token — never commit it. CRUX_CONFIG is how a second account or a
staging server gets a config of its own without going near the one your leads are holding open.
Every request says which version it is (X-Crux-Version), and the server answers with what it
thinks about that. Two things can come back:
- a warning, printed once per run, on stderr. You are behind, and it names the command that
fixes it. It is on stderr because
crux tail's stdout is one JSON line per event and nothing else — every standing lead parses that stream, so a warning on it would break all of them at once. - a refusal (HTTP 426), which stops the command and says why. This is a correctness floor,
not a "please upgrade": it is only ever raised past a version that reports your own work
incorrectly. Today's floor is
0.3.0, the release where the supervisor stopped announcing a crashed worker as a clean exit.
Either way:
npm i -g @coulb/crux-cli@latest # or `git pull`, in a repo checkout
A CLI old enough that it sends no version at all is warned, never refused — a rule written after you installed must not lock you out with no way to find out why. Those clients cannot read the warning either, so the server opens a thread in your crux inbox instead, and closes it by itself the next time it sees you on a good version.
A repo becomes a crux project. Run crux install first (it is the only thing that comes before
this), then this before anything else on the page:
cd ~/src/acme
crux discover # Enter, Enter, Enter
crux swarm up # a standing lead is now working it
crux push "…" # give it something to do$ crux discover
Found ~/src/acme — a Laravel app.
slug [acme]
name [Acme]
color (emerald violet sky amber rose pink zinc) [emerald]
keywords [acme,laravel,php]
repo [/Users/you/src/acme]
✓ acme is a crux project — created.
repo ~/src/acme
config ~/.config/crux/leads.json — swarm.projects.acme.repo added
lead not started
Next: crux swarm up --project acmeIt does not ask what it can read. The repo path comes from git (a subdirectory still finds the
root). The name comes from the origin remote, then package.json / composer.json, then the
directory. The framework comes from the manifests — a lead that knows it is standing in a Laravel app
writes better briefs, and "laravel" becomes a routing keyword. The keywords are what route a
captured item to this project, so they are the line worth reading before you press Enter.
It writes both places, and needs both: the project (POST /api/projects — so it exists in the
feed, the app and the web) and swarm.projects.<slug>.repo in the leads config, so that crux swarm up knows which repo the lead runs in. A project with no repo cannot be led. If the config is not
writable it says so and creates neither.
Idempotent. Run it twice and you get one project and one config entry. A second run reports
unchanged — and means it; created and updated name what actually changed. A slug already taken
by a different repo is refused, and so is a repo that is already another project (one directory with
two leads is two agents racing one codebase). Every refusal names its fix.
crux discover ~/src/acme --yes # every default, no questions
crux discover ~/src/acme --slug orbit # register it under a slug of your choosing
crux discover ~/src/acme --json # machine output (implies --yes)crux push "The acme importer keeps timing out — bump the worker timeout?" # auto-routes
crux push --project crux --source agent "CLI smoke test from lead agent" # explicit project
crux activity --project crux --actor lead "Shipped items API + CLI"
echo '{ "source":"github", "title":"…", "format":"quick" }' | crux decide
crux feed --project crux
crux projectspush returns the item id and the project it routed to. When --project is omitted the API
routes by keyword (or Haiku, if ANTHROPIC_API_KEY is set on the server).
crux next --claim --actor god-agent || exit 0 # exits 3 when the queue is empty
crux route <id> --project <slug> [--note "why"] # reassign; re-enters the routed flow
crux done <id> [--note "why"] [--drop] # close it (--drop = dropped, not done)
crux bury <id> # …and reap the agent of one closed from the web--claim takes an exclusive lease first, so two overlapping pollers never both act on the
same item. --actor is recorded on the claim and on every activity the verbs log.
A thread renders as a row: a short title on the main line, and under it, smaller and grey, a status line — one phrase saying what is happening to it right now.
The lead sets both. Nothing generates them. There is no model on the write path deriving a title from the captured text, and there is not going to be one: the lead is already an LLM, it has already read the thread, and it already knows what it is doing to it. Re-deriving that server-side would pay twice for worse information — and a derived status line goes stale the instant the lead does the next thing, which a set one cannot, because the lead sets it as it works.
So there is no "update the row" call. --status rides the request the lead was already making:
crux accept 214 --title "auth timeout" --status "reading the failing test" --actor lead-orbit
crux activity --item 214 --project orbit --type note "found it — the retry has no ceiling" \
--status "patching the retry loop"
crux spawn 214 --project-id 7 --title "auth timeout" --status "worker on it" --brief "…"
crux need --item 214 --kind decide --status "blocked on the retry policy" --option … --option …
crux done 214 --note "shipped in #91" --status "merged, closed"| flag | on | is |
|---|---|---|
--title "<2-3 words>" |
accept, spawn |
the thread's name — the row's main line. Set it once, when you pick the thread up. It is a name, not a summary; the row clips it to one line. |
--status "<phrase>" |
accept, activity, need, spawn, done |
what is happening now. Carried on whatever you were already sending. |
Two rules worth knowing:
--statusonactivityneeds--item. A status line belongs to a thread, and an activity without--itemis filed against the project as a whole — so there is nothing to set it on. That is refused (422) rather than silently dropped, because it is exactly the shape of a lead that forgot--item, and a quietly discarded status line would leave it thinking the row had updated.crux donealways rewrites the status line. A closed thread must not sit there still announcing work that stopped hours ago — nothing will ever correct it, since a closed thread gets no more writes. Pass a resting phrase (--status "merged, closed"); omit it and the close writesdone(ordropped) itself. Note this--statusis the status line, not the disposition —--dropis what makes it dropped.
Untitled threads render exactly as they always have. Every thread that predates this feature has
no title, and no thread has one before a lead accepts it — those rows go on showing the captured text,
and a thread with no status line shows no second line at all (never a blank grey strip). A lead that
never passes --title is not broken, it is just leaving its rows unnamed.
crux gate red --stage check-tokens --reason "widget palette is missing stateColor.acknowledged"
crux gate green --ref "$(git rev-parse --abbrev-ref HEAD)"A pre-deploy gate that fails on a laptop, in a terminal nobody is sitting at, is a guard worth whatever it costs to notice — which is nothing. This project's own gate was red for nine days and nobody knew; it was found by going to cut a build and discovering the door had been locked since July. Put this in the gate's exit trap and the refusal lands in your inbox instead.
- A red report opens ONE thread per breakage, naming the check that refused and what it said, and updates that same thread when the gate later starts refusing somewhere else. It does not open a thread per run — a broken gate is re-run on every deploy attempt, and a thread each time is a cascade, not a signal.
- A red report never pushes. A refusal is not an emergency; it is the system working.
- A green report closes the thread by itself. Nothing to tick off.
The green timestamp is the real point. "Did it fail?" is answerable by the gate. "Has anything
proved lately that a deploy would be allowed?" is not — because the failure mode is that nobody
runs it, and a gate nobody runs is exactly as silent as a gate that passes. The server's daily
crux:reconcile-gates sweep reads that timestamp, and that one pushes: once per breakage, on a
threshold your account sets (PATCH /api/account/settings, gate.stale_days, default 3 days).
Best-effort by design: report it from a trap with || true, so a laptop with no network turns a
red gate into a red gate, never into a crash.
crux spawn <id> --project-id <n> --brief "<the task>" # headless: reports, then exits
crux spawn <id> --project-id <n> --live # interactive + Remote ControlHeadless is the default. The worker runs under claude -p, so its final message IS its report —
crux supervise (started for you, detached) waits for it to exit and posts that message to the
thread. The agent does not choose to report and cannot forget to.
The wrapper also tells it how to write that report, because the report is published rather than filed: markdown, phone-sized, and never a PR, issue or thread named without its URL. Leads get the same rule from their generated brief (How you write).
A thread is the one thing you do not have to write the URL for. Write #123 and the web and the
app both turn it into a link to that thread — but only when thread 123 is real and belongs to the
account reading it, so an id that is nothing (or somebody else's) stays plain text. Two things stay
plain deliberately: an id inside backticks or a code fence is code, and PR #35 / issue #35 is
GitHub's number, not ours — the word in front of it is how the two are told apart. Everything else
still needs its full URL.
A run ends in exactly one of these ways — full contract in the worker contract:
| Ending | Thread gets | Pushes to you? |
|---|---|---|
| reported | the agent's final message, verbatim | no |
| died | Worker failed — … | yes |
| cancelled by lead | Worker cancelled by … Not a crash. | no |
| report unreadable | Worker finished, but its report could not be read back… Not a crash. | no |
Silence is the bug. A worker whose process ends having printed nothing and left no trace on its thread gets a loud failure note and a phone push — otherwise the thread waits on a dead agent forever.
But empty stdout alone is not death. A healthy headless worker often does its whole job by posting
to the thread (crux activity … --type note) and then exits, so its final stdout is legitimately
empty. Before filing an obituary, crux supervise asks the thread whether the worker posted anything
during this run (any activity created at/after the moment it was launched). If it did, the empty
buffer is a clean exit, not a crash — no failure note, no push. Only a worker that both printed
nothing and said nothing to its thread is announced as failed. (The launch moment is the floor, so a
previous, cancelled worker's notes on a reused thread cannot vouch for a new one's real crash.)
And a report crux could not read is not death either. "Nothing parsed" and "nothing was printed"
are different facts, and conflating them announced three finished workers as corpses in one night.
So crux supervise asks the buffer whether a result object went out at all — an unescaped
"type":"result" is proof the run got that far — and if one did, it recovers what it can and says
the report is partial. It never calls that a crash and never pushes it. A buffer with no result
object in it is still a corpse, still loud, still pushed.
One live worker per thread. A per-thread worktree is not a lock, and two agents in one worktree
overwrite each other's edits silently — which is what happened the night a false spawn failure got
retried into a double. So crux spawn asks Solo whether that thread already has a running worker,
and refuses before it creates anything if it does, naming the process. Respawn is untouched: a worker
that has exited does not block the next one. To replace a live worker, crux cancel it first.
A spawn that looks like it failed is checked, not believed. solo processes spawn has reported
failure over an agent that was already up and working. crux now asks Solo whether the process exists
before declaring failure, and adopts it if it does — saying so on both the warning and the success
line, because a recovery that reads like an ordinary spawn is how the next person is misled. Only once
Solo has definitively answered that nothing started does it retry, once. If Solo cannot be reached, the
failure is reported as a failure with the child's own stderr and exit code attached, and nothing is
retried: a retry fired on an unanswered question is the move that made the double.
crux cancel <id> --note "the approach changed" --actor lead-cruxUse this — never solo processes stop — when you need to stop a worker mid-flight (the brief
turned out to be wrong, the approach changed). The supervisor cannot see intent, only a process that
ended having printed nothing, which is exactly what a crash looks like. Killing a worker by hand
therefore posts "Worker failed … it crashed, was killed, or never started" and pushes it to your
phone, for a run in which nothing failed.
crux cancel writes a tombstone — keyed by item id and Solo process id — before it stops
the process, so the supervisor reports a cancellation rather than a death, silently. The thread stays
open: re-brief and crux spawn again. (crux done is the other thing — the thread closes and
its worker dies with it.)
A worker killed with no tombstone is still reported as a crash. That is deliberate: an unclaimed silence is the one thing the supervisor exists to catch.
crux bury <id> --actor lead-crux
crux bury <id> --dry-runcrux done closes a thread and stops its agent, because a thread's death is its worker's. A
close made from the web or the phone can only do the first half: the server has no route to the
box the agent is running on. So the thread goes done, the board says finished, and the worker is
still there.
For a headless worker that is harmless — it exited on its own; that exit is what posted its report.
It is a --live worker that this exists for. A live agent never exits by itself, and crux spawn
returns early for --live rather than leaving a supervisor behind, so nothing has ever watched
one. Its only bound was crux done, and a web close is a crux done with that bound missing.
crux bury is the missing half, made reachable for a thread somebody else closed: it stops the Solo
process the thread recorded, takes the worker's desk away, and removes its worktree if that is clean
and merged. It runs on the lead box, which is the only place solo answers. No server-side reap,
no relay, no daemon — the close is already on the tail the lead is already parked on, and the lead
brief crux ships tells it to run this when one lands that it did not make itself.
Three things it will not do:
- It will not act on a stale record. It fetches the thread's current status and refuses an
open one, non-zero. A thread closed and then reopened is meant to have a live worker on it, and
killing that would be this bug pointed the other way. To stop a worker on a thread that stays open,
that is
crux cancel. - It will not make noise on a replay. Tail delivery is at-least-once, so the same close arrives again — and stopping a process that has already exited is a no-op that writes nothing to the thread. Running it twice is free.
- It will not fail quietly. A Solo it cannot reach gets a note on the thread naming the process and the command to run by hand, plus a non-zero exit, and the worktree is left alone. An agent outliving its thread with nobody knowing is the whole bug; a reaper that reports its own failure into a lead's scrollback would rebuild it one layer up.
A long-running watcher. It prints one flushed single-line JSON record per new event, oldest first, and nothing at all otherwise — silence always means "no new input". Arm it under Claude Code's Monitor and an agent is woken by real Crux input instead of burning tokens on an agentic poll.
crux tail --unrouted # triage: everything the router couldn't place
crux tail --project crux # a project lead: its own feed
crux tail --topics items,activities --project crux --interval 30{"topic":"item.created","id":8,"project":null,"summary":"the faucet is dripping again","ts":"2026-07-09T01:31:05+00:00","cursor":9,"status":"unmatched","source":"cli"}The record is a pointer, not a payload — fetch details with crux feed --json or the API.
Items carry status and source; activities carry actor and type, so a consumer can skip
the events it caused itself rather than wake on its own echo.
- Durable cursor, one per consumer. Position is saved to
~/.config/crux/tail-cursor.json, keyed by (server, topics, project, status,--actor), so a restart resumes exactly where this consumer left off. The actor is part of the key because the cursor only advances over records already printed: two tails sharing one entry would each advance it past records the other printed, and neither would see a gap. Every read-modify-write of that shared file is serialised under a lock, so concurrent tails cannot lose each other's advances. Written after the lines are flushed, so delivery is at-least-once — a consumer that acts on events must be idempotent. - Changing
--actoris not silent. A new actor name is a new key, so it would resume from now and skip everything since. When a stream already has a position under another actor, the tail says so on stderr and prints the exact--sincethat would inherit it. (Entries written before cursors were keyed by actor are inherited automatically, once, and left in place.) - First run starts from now. No history is replayed. Pass
--since <timestamp>to start from an instant instead. With--claim, a first run starts from its own claim rather than from now, so nothing recorded after the claim can be missed — see the fence notes below. - Errors never reach stdout. Transient API failures are retried with backoff and logged to stderr; a 4xx (bad cursor, revoked token) exits rather than spinning.
--intervaldefaults to 60s. See the hibernation note inSTATUS-cli.md.- Every poll announces the listener.
--actor <name>(default<user>@<host>) and the poll interval ride along as headers, and the dashboard turns them into a per-project status dot. The name is self-asserted and advisory, not a credential — it is a health signal, nothing is authorised on it. Set it to the lead's name insolo.yml:--actor lead-mercury.
Two leads on one project handle every item twice. --claim makes a starting lead retire any
other lead on that stream, so a second start is a clean handover instead of a silent double:
crux tail --project mercury --claim --actor lead-mercury --topics items,activities,decisionsOn startup — before a single event is printed — the tail posts a claim naming this process
(host:pid:starttime). Every tail on that stream is delivered the claim as one more record:
{"topic":"lead.claimed","instance":"always-on:4412:1753900000000","project":"mercury","actor":"lead-mercury","cursor":9812}A claimed tail that sees a claim newer than its own, from a different process prints that line and exits 75. Last writer wins: whoever started most recently owns the stream. That is what lets a lead move from a laptop to an always-on box without anyone racing to kill the old one first.
Exit 75 is the fence, and your supervisor has to know it.
75 means another lead owns this stream now — not a crash. A supervisor that restarts on it gets ping-pong: the retired lead comes back, claims, retires the lead that just retired it, and the two of them trade the project forever, double-handling it on every pass. That is worse than the problem the fence solves.
- systemd:
RestartPreventExitStatus=75in the unit.- Solo (or any supervisor with no exit-code rule): the lead stops its own supervised process on the way out —
solo processes stop $SOLO_PROCESS_ID. The briefcrux swarm upgenerates says so.
Notes worth knowing before you rely on it:
--claimis opt-in, and needs--projector--unrouted. A debug tail must not be able to shut a lead down, and a claim with no stream named would retire every lead at once.- A failed claim is fatal. If the claim cannot be posted the tail exits rather than reading items — a lead that has fenced nothing is just a second lead, which is the bug.
- The retiring lead does not advance its cursor past the takeover, so the incoming lead replays that batch rather than the two of them dropping it between them.
- Only a claim recorded after your own retires you. A lead resuming on a durable cursor is routinely handed the outgoing lead's claim; acting on that would leave the project with no lead.
- A claiming lead with no saved cursor starts at its own claim, not at "now". "Now" would be decided by the first poll, and anything recorded in the milliseconds between the two — including a rival's claim — would be pinned past and never delivered, leaving both leads up. Starting at the claim replays that window instead, which is the at-least-once delivery this tail already has.
- It is not authentication. Anyone holding your API token can claim your streams. It answers "is this claim mine?", and nothing else.
A dead lead and an idle one both produce silence. This tells them apart: it walks every Solo project and reports whether a live lead is tailing its crux stream.
crux leads # report; exits 1 if anything is uncovered
crux leads --json # machine-readable, for a health check
crux leads --fix # start the solo.yml command for each uncovered project
crux leads --fix --dry-runCoverage is read off the running crux tail --project <slug> command line, found by walking the
process tree beneath each Solo process — never off a process name. Names lie: the stopped
kind: agent rows are named exactly like the new kind: command ones.
It reports three failure classes, not just the happy path:
| state | meaning |
|---|---|
uncovered |
a Solo project with no lead tailing it |
orphan |
a lead tailing a slug that has no Solo project |
duplicate |
two leads on one slug — the stream is double-processed |
unmapped |
a Solo project missing from the name→slug map, never silently skipped |
unmanaged |
a crux slug with no Solo project and no lead (registry drift; a warning) |
leadless |
configured to expect no lead, e.g. personal. Expected, not an error. |
--fix only ever starts leads for uncovered projects, and refuses to start a second tail on
a slug that is already covered.
The Solo-name→crux-slug mapping is explicit config, not string munging (acme-search-index is
acme; status-page is beacon — no transformation derives either one, which is why the map is
explicit and an unmapped project is reported rather than guessed at). It lives in
~/.config/crux/leads.json, or wherever CRUX_LEADS_CONFIG points.
The full guide is Running a swarm — what a swarm is, getting one up, the backends, the config, and the traps. What follows is the CLI-level summary.
crux leads tells you whether a fleet is alive. crux swarm creates one, on whatever
orchestrator you already run.
crux swarm up --dry-run # print the plan; start nothing
crux swarm up # start a lead for every project that has none
crux swarm status # what's covered, what isn't (exits 1 if anything is uncovered)
crux swarm down # stop the leads crux started — and only thoseA lead is a durable agent that parks on crux tail, acks each thread the moment it arrives,
answers what it can as chat, spawns workers for real work, escalates with crux need, and never
exits. swarm up writes the brief that says all of that (~/.config/crux/leads/<slug>.md — read
it, edit it, it's yours) and starts one lead per uncovered project.
That brief is the actual product. It used to be folklore: hand-written files in a private directory, encoding the whole operating contract, that nobody but their author could reproduce. Now crux generates it, from your project registry and your config — no private brief directory, no baked-in paths, nothing of anyone else's machine in it.
| backend | what it does |
|---|---|
solo |
the default. Spawns a Solo agent per lead, creating the Solo project from your repo path if it doesn't exist. No declaration needed — this is what crux leads --fix cannot do. |
exec |
the escape hatch. Runs any command you give it. This is what makes "bring your own orchestration" true rather than a slogan. |
Solo is the one we run. Solo is a terminal workspace for the processes and agents you already keep running, and on it the entire swarm config is:
"swarm": { "backend": "solo" }No repo paths, no declarations. Solo already records a path per project, so crux swarm up works
with nothing configured at all — and when the Solo project doesn't exist yet, the backend creates it
from your repo path rather than refusing. Each lead lands as a named agent process (lead-acme) you
can open, read, and talk to.
exec takes a command template and fills in the blanks:
"swarm": {
"backend": "exec",
"exec": {
"command": "tmux new-session -d -s {name} '{command}'",
"stop": "tmux kill-session -t {name}"
},
"projects": { "crux": { "repo": "~/src/crux", "brief": "extra context for this lead" } }
}Placeholders: {slug} {name} {repo} {brief_path} {command}. An unknown one is refused,
never expanded to nothing — a swarm that silently half-starts is worse than one that won't start.
Set stop whenever your command forks (tmux new-session -d, nohup … &). Those exit
immediately, so the pid crux recorded is a dead handle to a live lead. With stop, crux addresses
the lead by name and can still kill it; without it, crux swarm down tells you it could not, rather
than reporting a lead it never stopped as stopped.
Run swarm up twice and the second run starts nothing. Two tails on one stream race the shared
cursor file and deliver every item twice, so "start a lead" has to mean "make sure exactly one
lead is running" — a reconciliation, not a spawn.
Coverage is read off the OS process table (who is really running crux tail --project <slug>),
never off a backend's opinion — Solo's own status lies, and tmux has no idea what a crux slug is.
That's why exec gets working reconciliation for free. Plus one thing the process table can't see:
a lead crux started whose tail hasn't come up yet. Without that, a second up inside the boot
window would double-tail the stream.
An agent multiplexer could have answered instead — the good ones track real agent status (idle /
working / blocked) — and one still doesn't get asked, for a precise reason. A healthy lead parked on
crux tail is doing nothing, so it gets called idle; a lead frozen at Claude Code's folder-trust
prompt is also doing nothing, so that gets called idle too. The two states that matter most are
indistinguishable to it, and the process table separates them cleanly. A backend's own status is
shown as detail; it is never what crux believes.
| state | meaning |
|---|---|
covered |
something is tailing this slug. Nothing to do. |
starting |
crux started this lead; its process is alive but its tail hasn't appeared yet. |
tail-down |
it's alive and has tailed before — it's between tail restarts. Not a new lead. |
stalled |
alive, but it has NEVER tailed. A dead swarm that looks alive. See below. |
leadless |
configured to expect no lead. Expected, not an error. |
| refusals | no repo path; a duplicate tail; a repo that disagrees with the backend's. |
Nothing is ever silently skipped. Every project crux won't bring up is named, with the reason.
Claude Code asks you to trust a directory it has never seen — and
--dangerously-skip-permissions does not skip that prompt. A lead started in an untrusted repo
sits at it forever: the agent is alive, the swarm is dead, and the only symptom is silence.
So crux watches for it. A lead that has been up for three minutes and has never started its tail is
reported stalled, with the cause, and swarm up refuses to start a second one on top of it
(it would stall in exactly the same place). To fix: run claude once in the repo and accept the
folder, then crux swarm down --project <slug> and up again.
Everything lives under swarm in the leads config (~/.config/crux/leads.json, or
CRUX_LEADS_CONFIG, or the template that ships with the package):
| key | |
|---|---|
backend |
solo (default) or exec. Override per-run with --backend, or CRUX_SWARM_BACKEND. |
agent |
the command an exec lead runs. Default claude --dangerously-skip-permissions. |
solo.agentToolId |
the Solo agent tool to spawn. Default 3 (claude). |
exec.command / exec.stop |
the templates above. |
projects.<slug>.repo |
where that lead works. Required for any project your backend can't locate itself — Solo already records a path per project, so a Solo user needs none of this. |
projects.<slug>.brief |
extra context pasted into that lead's generated brief. |
crux swarm down stops only what crux swarm up started (it's tracked in
~/.config/crux/swarm.json). A lead you started by hand is reported and left running — crux will
not kill a process it did not start and cannot restart.
A lead is a Claude agent. So when Claude is the thing that died — a /login that expired, a
lapsed subscription, a rate limit — every lead on the box is unconscious at the same instant, and
not one of them can notice it. The probe is therefore plain Node in this CLI, run by crux swarm up, crux spawn and crux swarm recover from outside any agent. Nothing has to be alive for it
to work.
Off by default. A machine that has not opted in behaves exactly as it always has: no probe process, no extra call, byte-identical output.
"swarm": { "failover": { "enabled": true, "agentToolId": 4 } }| key | |
|---|---|
failover.enabled |
absent means off. Only literal true switches it on. |
failover.agentToolId |
the Solo agent tool to fail over to (solo agents list). Use failover.agent on exec/herdr. Setting the one your backend can't read is refused, not ignored. |
failover.toolType |
claude or codex. Only needed where crux can't work it out — the flags an agent takes are a property of the tool, and handing one the other's starts a lead that dies on its own argv. |
failover.probeSeconds |
how long a probe may take. Default 25. |
failover.cacheSeconds |
how long one verdict is shared, so ten callers run one probe. Default 60. |
The primary is not set here. It's whatever your fleet already runs (solo.agentToolId, or
agent) — a second place to write one value is a day the two disagree, and that day a lead comes
up on a model nobody chose.
Fail closed. Only a probe that fails twice moves anything. A probe that times out, can't be spawned, is misconfigured, or disagrees with itself changes nothing and says so — a false "down" restarts a healthy fleet and drops what every lead was holding.
crux swarm recover --dry-run # who would move back to the primary, and why the others would not
crux swarm recover # move ONE idle lead back. Run it on a timer.recover moves one lead per pass, only once the primary answers again, and only while that lead
holds no open thread — read from the API, because a lead crux can't prove is idle is not idle.
Workers are never migrated: each finishes on the tool it started on.
npm test # the whole suite
npm test -- test/tail-takeover.test.js # one filenode:test — the reconciliation classifiers, the cursor lock, and crux swarm end-to-end against
a stub Solo and a stub API.
It runs through scripts/run-tests.js, which prints the usual TAP and then a verdict as the
last line, on stderr as well as stdout:
crux cli tests: PASSED — 376 passed / 376 tests, none cancelled or timed out.
That line exists because node --test scores a timed-out test as cancelled, not as failed, so a
run where a test hung still prints # fail 0 — which is how a flake once got read as green and
merged. The honest count is # pass against # tests; the verdict does that arithmetic and says
NOT GREEN, names the test, and exits non-zero when they disagree. Never quote # fail.