@coulb/crux-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/crux.js +2697 -0
- package/leads.config.json +62 -0
- package/lib/backends.js +617 -0
- package/lib/cancel.js +125 -0
- package/lib/cursor.js +51 -0
- package/lib/discover.js +422 -0
- package/lib/leads.js +292 -0
- package/lib/orchestrator.js +237 -0
- package/lib/spawn.js +436 -0
- package/lib/store.js +119 -0
- package/lib/swarm-state.js +66 -0
- package/lib/swarm.js +628 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Coulbourne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# crux
|
|
2
|
+
|
|
3
|
+
**Agent orchestration for one operator (with a life).**
|
|
4
|
+
|
|
5
|
+
Crux runs a **standing lead** on every project you give it — a durable agent that parks on the
|
|
6
|
+
project's stream, picks up each thread as it arrives, answers what it can, spawns a headless worker
|
|
7
|
+
for the code, and reaches your phone only when the call is actually yours.
|
|
8
|
+
|
|
9
|
+
This is the CLI. It is the interface: every verb below is one an agent (or you) drives crux with.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @coulb/crux-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node 18+. Zero dependencies. The installed command is `crux`.
|
|
18
|
+
|
|
19
|
+
## Config
|
|
20
|
+
|
|
21
|
+
Point the CLI at your Crux API and give it a token. Resolution order:
|
|
22
|
+
|
|
23
|
+
1. env `CRUX_API_URL` / `CRUX_API_TOKEN`
|
|
24
|
+
2. `~/.config/crux/config.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "url": "https://crux.coulb.com", "token": "…" }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The token is your API bearer token — never commit it.
|
|
31
|
+
|
|
32
|
+
## First run — three commands
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd ~/src/acme
|
|
36
|
+
crux discover # this repo is now a crux project
|
|
37
|
+
crux swarm up # a standing lead is now working it
|
|
38
|
+
crux push "the importer keeps timing out" # give it something to do
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That is the whole loop: a project, a lead on it, and work going in. Everything below is detail.
|
|
42
|
+
|
|
43
|
+
## `crux discover` — step zero
|
|
44
|
+
|
|
45
|
+
A repo becomes a crux project. Run this before anything else on this page:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd ~/src/acme
|
|
49
|
+
crux discover # Enter, Enter, Enter
|
|
50
|
+
crux swarm up # a standing lead is now working it
|
|
51
|
+
crux push "…" # give it something to do
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ crux discover
|
|
56
|
+
Found ~/src/acme — a Laravel app.
|
|
57
|
+
|
|
58
|
+
slug [acme]
|
|
59
|
+
name [Acme]
|
|
60
|
+
color (emerald violet sky amber rose pink zinc) [emerald]
|
|
61
|
+
keywords [acme,laravel,php]
|
|
62
|
+
repo [/Users/you/src/acme]
|
|
63
|
+
|
|
64
|
+
✓ acme is a crux project — created.
|
|
65
|
+
repo ~/src/acme
|
|
66
|
+
config ~/.config/crux/leads.json — swarm.projects.acme.repo added
|
|
67
|
+
lead not started
|
|
68
|
+
|
|
69
|
+
Next: crux swarm up --project acme
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**It does not ask what it can read.** The repo path comes from git (a subdirectory still finds the
|
|
73
|
+
root). The name comes from the `origin` remote, then `package.json` / `composer.json`, then the
|
|
74
|
+
directory. The framework comes from the manifests — a lead that knows it is standing in a Laravel app
|
|
75
|
+
writes better briefs, and "laravel" becomes a routing keyword. The **keywords** are what route a
|
|
76
|
+
captured item to this project, so they are the line worth reading before you press Enter.
|
|
77
|
+
|
|
78
|
+
It writes **both** places, and needs both: the project (`POST /api/projects` — so it exists in the
|
|
79
|
+
feed, the app and the web) and `swarm.projects.<slug>.repo` in the leads config, so that `crux swarm
|
|
80
|
+
up` knows which repo the lead runs in. A project with no repo cannot be led. If the config is not
|
|
81
|
+
writable it says so and creates neither.
|
|
82
|
+
|
|
83
|
+
**Idempotent.** Run it twice and you get one project and one config entry. A second run reports
|
|
84
|
+
`unchanged` — and means it; `created` and `updated` name what actually changed. A slug already taken
|
|
85
|
+
by a different repo is refused, and so is a repo that is already another project (one directory with
|
|
86
|
+
two leads is two agents racing one codebase). Every refusal names its fix.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
crux discover ~/src/acme --yes # every default, no questions
|
|
90
|
+
crux discover ~/src/acme --slug orbit # register it under a slug of your choosing
|
|
91
|
+
crux discover ~/src/acme --json # machine output (implies --yes)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
crux push "The acme importer keeps timing out — bump the worker timeout?" # auto-routes
|
|
98
|
+
crux push --project crux --source agent "CLI smoke test from lead agent" # explicit project
|
|
99
|
+
crux activity --project crux --actor lead "Shipped items API + CLI"
|
|
100
|
+
echo '{ "source":"github", "title":"…", "format":"quick" }' | crux decide
|
|
101
|
+
crux feed --project crux
|
|
102
|
+
crux projects
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`push` returns the item id and the project it routed to. When `--project` is omitted the API
|
|
106
|
+
routes by keyword (or Haiku, if `ANTHROPIC_API_KEY` is set on the server).
|
|
107
|
+
|
|
108
|
+
## Draining the unrouted queue
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
crux next --claim --actor god-agent || exit 0 # exits 3 when the queue is empty
|
|
112
|
+
crux route <id> --project <slug> [--note "why"] # reassign; re-enters the routed flow
|
|
113
|
+
crux done <id> [--note "why"] [--drop] # close it (--drop = dropped, not done)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`--claim` takes an exclusive lease first, so two overlapping pollers never both act on the
|
|
117
|
+
same item. `--actor` is recorded on the claim and on every activity the verbs log.
|
|
118
|
+
|
|
119
|
+
## `--title` and `--status` — the row a thread shows up as
|
|
120
|
+
|
|
121
|
+
A thread renders as a **row**: a short **title** on the main line, and under it, smaller and grey, a
|
|
122
|
+
**status line** — one phrase saying what is happening to it *right now*.
|
|
123
|
+
|
|
124
|
+
**The lead sets both. Nothing generates them.** There is no model on the write path deriving a title
|
|
125
|
+
from the captured text, and there is not going to be one: the lead is already an LLM, it has already
|
|
126
|
+
read the thread, and it already knows what it is doing to it. Re-deriving that server-side would pay
|
|
127
|
+
twice for worse information — and a *derived* status line goes stale the instant the lead does the
|
|
128
|
+
next thing, which a set one cannot, because the lead sets it **as it works**.
|
|
129
|
+
|
|
130
|
+
So there is no "update the row" call. `--status` rides the request the lead was already making:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
crux accept 214 --title "auth timeout" --status "reading the failing test" --actor lead-orbit
|
|
134
|
+
crux activity --item 214 --project orbit --type note "found it — the retry has no ceiling" \
|
|
135
|
+
--status "patching the retry loop"
|
|
136
|
+
crux spawn 214 --project-id 7 --title "auth timeout" --status "worker on it" --brief "…"
|
|
137
|
+
crux need --item 214 --kind decide --status "blocked on the retry policy" --option … --option …
|
|
138
|
+
crux done 214 --note "shipped in #91" --status "merged, closed"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| flag | on | is |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `--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. |
|
|
144
|
+
| `--status "<phrase>"` | `accept`, `activity`, `need`, `spawn`, `done` | what is happening **now**. Carried on whatever you were already sending. |
|
|
145
|
+
|
|
146
|
+
Two rules worth knowing:
|
|
147
|
+
|
|
148
|
+
- **`--status` on `activity` needs `--item`.** A status line belongs to a *thread*, and an activity
|
|
149
|
+
without `--item` is filed against the project as a whole — so there is nothing to set it on. That is
|
|
150
|
+
refused (422) rather than silently dropped, because it is exactly the shape of a lead that forgot
|
|
151
|
+
`--item`, and a quietly discarded status line would leave it thinking the row had updated.
|
|
152
|
+
- **`crux done` always rewrites the status line.** A closed thread must not sit there still announcing
|
|
153
|
+
work that stopped hours ago — nothing will ever correct it, since a closed thread gets no more
|
|
154
|
+
writes. Pass a **resting** phrase (`--status "merged, closed"`); omit it and the close writes `done`
|
|
155
|
+
(or `dropped`) itself. Note this `--status` is the status *line*, not the disposition — `--drop` is
|
|
156
|
+
what makes it dropped.
|
|
157
|
+
|
|
158
|
+
**Untitled threads render exactly as they always have.** Every thread that predates this feature has
|
|
159
|
+
no title, and no thread has one before a lead accepts it — those rows go on showing the captured text,
|
|
160
|
+
and a thread with no status line shows no second line at all (never a blank grey strip). A lead that
|
|
161
|
+
never passes `--title` is not broken, it is just leaving its rows unnamed.
|
|
162
|
+
|
|
163
|
+
## `crux spawn` — a worker that reports and dies
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
crux spawn <id> --project-id <n> --brief "<the task>" # headless: reports, then exits
|
|
167
|
+
crux spawn <id> --project-id <n> --live # interactive + Remote Control
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Headless is the default. The worker runs under `claude -p`, so **its final message IS its report** —
|
|
171
|
+
`crux supervise` (started for you, detached) waits for it to exit and posts that message to the
|
|
172
|
+
thread. The agent does not choose to report and cannot forget to.
|
|
173
|
+
|
|
174
|
+
**A run ends in exactly one of three ways** — full contract in [the worker contract](https://crux.coulb.com/docs/worker-contract):
|
|
175
|
+
|
|
176
|
+
| Ending | Thread gets | Pushes to you? |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| **reported** | the agent's final message, verbatim | no |
|
|
179
|
+
| **died** | **Worker failed — …** | **yes** |
|
|
180
|
+
| **cancelled by lead** | **Worker cancelled by … Not a crash.** | no |
|
|
181
|
+
|
|
182
|
+
**Silence is the bug.** A worker whose process ends having printed nothing gets a loud failure note
|
|
183
|
+
and a phone push — otherwise the thread waits on a dead agent forever.
|
|
184
|
+
|
|
185
|
+
## `crux cancel` — stop a worker on purpose
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
crux cancel <id> --note "the approach changed" --actor lead-crux
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Use this — **never `solo processes stop`** — when you need to stop a worker mid-flight (the brief
|
|
192
|
+
turned out to be wrong, the approach changed). The supervisor cannot see intent, only a process that
|
|
193
|
+
ended having printed nothing, *which is exactly what a crash looks like*. Killing a worker by hand
|
|
194
|
+
therefore posts "Worker failed … it crashed, was killed, or never started" and pushes it to your
|
|
195
|
+
phone, for a run in which nothing failed.
|
|
196
|
+
|
|
197
|
+
`crux cancel` writes a **tombstone** — keyed by item id **and** Solo process id — *before* it stops
|
|
198
|
+
the process, so the supervisor reports a cancellation rather than a death, silently. The thread stays
|
|
199
|
+
**open**: re-brief and `crux spawn` again. (`crux done` is the other thing — the thread closes and
|
|
200
|
+
its worker dies with it.)
|
|
201
|
+
|
|
202
|
+
A worker killed with no tombstone is still reported as a crash. That is deliberate: an unclaimed
|
|
203
|
+
silence is the one thing the supervisor exists to catch.
|
|
204
|
+
|
|
205
|
+
## `crux tail` — push-style events
|
|
206
|
+
|
|
207
|
+
A long-running watcher. It prints **one flushed single-line JSON record per new event**,
|
|
208
|
+
oldest first, and **nothing at all** otherwise — silence always means "no new input". Arm it
|
|
209
|
+
under Claude Code's Monitor and an agent is woken by real Crux input instead of burning
|
|
210
|
+
tokens on an agentic poll.
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
crux tail --unrouted # triage: everything the router couldn't place
|
|
214
|
+
crux tail --project crux # a project lead: its own feed
|
|
215
|
+
crux tail --topics items,activities --project crux --interval 30
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{"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"}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The record is a **pointer, not a payload** — fetch details with `crux feed --json` or the API.
|
|
223
|
+
Items carry `status` and `source`; activities carry `actor` and `type`, so a consumer can skip
|
|
224
|
+
the events it caused itself rather than wake on its own echo.
|
|
225
|
+
|
|
226
|
+
- **Durable cursor.** Position is saved to `~/.config/crux/tail-cursor.json`, keyed by
|
|
227
|
+
(server, topics, project, status), so a restart resumes exactly where it left off and two
|
|
228
|
+
tails never fight. Every read-modify-write of that shared file is serialised under a lock, so
|
|
229
|
+
concurrent tails cannot lose each other's advances. Written after the lines are flushed, so
|
|
230
|
+
delivery is **at-least-once** — a consumer that acts on events must be idempotent.
|
|
231
|
+
- **First run starts from now.** No history is replayed. Pass `--since <timestamp>` to start
|
|
232
|
+
from an instant instead.
|
|
233
|
+
- **Errors never reach stdout.** Transient API failures are retried with backoff and logged to
|
|
234
|
+
stderr; a 4xx (bad cursor, revoked token) exits rather than spinning.
|
|
235
|
+
- **`--interval` defaults to 60s.** See the hibernation note in `STATUS-cli.md`.
|
|
236
|
+
- **Every poll announces the listener.** `--actor <name>` (default `<user>@<host>`) and the poll
|
|
237
|
+
interval ride along as headers, and the dashboard turns them into a per-project status dot.
|
|
238
|
+
The name is **self-asserted and advisory, not a credential** — it is a health signal, nothing
|
|
239
|
+
is authorised on it. Set it to the lead's name in `solo.yml`: `--actor lead-mercury`.
|
|
240
|
+
|
|
241
|
+
## `crux leads` — is the fleet actually alive?
|
|
242
|
+
|
|
243
|
+
A dead lead and an idle one both produce silence. This tells them apart: it walks every Solo
|
|
244
|
+
project and reports whether a live lead is tailing its crux stream.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
crux leads # report; exits 1 if anything is uncovered
|
|
248
|
+
crux leads --json # machine-readable, for a health check
|
|
249
|
+
crux leads --fix # start the solo.yml command for each uncovered project
|
|
250
|
+
crux leads --fix --dry-run
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Coverage is read off the running `crux tail --project <slug>` command line, found by walking the
|
|
254
|
+
process tree beneath each Solo process — **never** off a process name. Names lie: the stopped
|
|
255
|
+
`kind: agent` rows are named exactly like the new `kind: command` ones.
|
|
256
|
+
|
|
257
|
+
It reports three failure classes, not just the happy path:
|
|
258
|
+
|
|
259
|
+
| state | meaning |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `uncovered` | a Solo project with no lead tailing it |
|
|
262
|
+
| `orphan` | a lead tailing a slug that has no Solo project |
|
|
263
|
+
| `duplicate` | two leads on one slug — the stream is double-processed |
|
|
264
|
+
| `unmapped` | a Solo project missing from the name→slug map, never silently skipped |
|
|
265
|
+
| `unmanaged` | a crux slug with no Solo project and no lead (registry drift; a warning) |
|
|
266
|
+
| `leadless` | configured to expect no lead, e.g. `personal`. Expected, not an error. |
|
|
267
|
+
|
|
268
|
+
`--fix` only ever starts leads for `uncovered` projects, and **refuses** to start a second tail on
|
|
269
|
+
a slug that is already covered.
|
|
270
|
+
|
|
271
|
+
The Solo-name→crux-slug mapping is explicit config, not string munging (`acme-search-index` is
|
|
272
|
+
`acme`; `status-page` is `beacon` — no transformation derives either one, which is why the map is
|
|
273
|
+
explicit and an unmapped project is reported rather than guessed at). It lives in
|
|
274
|
+
`~/.config/crux/leads.json`, or wherever `CRUX_LEADS_CONFIG` points.
|
|
275
|
+
|
|
276
|
+
## `crux swarm` — go from "I have crux projects" to "I have leads working them"
|
|
277
|
+
|
|
278
|
+
> **The full guide is [Running a swarm](https://crux.coulb.com/docs/swarm)** — what a swarm is, getting one up, the
|
|
279
|
+
> backends, the config, and the traps. What follows is the CLI-level summary.
|
|
280
|
+
|
|
281
|
+
`crux leads` tells you whether a fleet is alive. `crux swarm` **creates one**, on whatever
|
|
282
|
+
orchestrator you already run.
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
crux swarm up --dry-run # print the plan; start nothing
|
|
286
|
+
crux swarm up # start a lead for every project that has none
|
|
287
|
+
crux swarm status # what's covered, what isn't (exits 1 if anything is uncovered)
|
|
288
|
+
crux swarm down # stop the leads crux started — and only those
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A **lead** is a durable agent that parks on `crux tail`, acks each thread the moment it arrives,
|
|
292
|
+
answers what it can as chat, spawns workers for real work, escalates with `crux need`, and never
|
|
293
|
+
exits. `swarm up` writes the brief that says all of that (`~/.config/crux/leads/<slug>.md` — read
|
|
294
|
+
it, edit it, it's yours) and starts one lead per uncovered project.
|
|
295
|
+
|
|
296
|
+
That brief is the actual product. It used to be folklore: hand-written files in a private
|
|
297
|
+
directory, encoding the whole operating contract, that nobody but their author could reproduce.
|
|
298
|
+
Now crux generates it, from your project registry and your config — no private brief directory, no
|
|
299
|
+
baked-in paths, nothing of anyone else's machine in it.
|
|
300
|
+
|
|
301
|
+
### Backends — bring your own orchestration
|
|
302
|
+
|
|
303
|
+
| backend | what it does |
|
|
304
|
+
|---|---|
|
|
305
|
+
| `solo` | **the default.** Spawns a [Solo](https://soloterm.com) 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. |
|
|
306
|
+
| `exec` | **the escape hatch.** Runs any command you give it. This is what makes "bring your own orchestration" true rather than a slogan. |
|
|
307
|
+
|
|
308
|
+
**Solo is the one we run.** [Solo](https://soloterm.com) is a terminal workspace for the processes
|
|
309
|
+
and agents you already keep running, and on it the entire swarm config is:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
"swarm": { "backend": "solo" }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
No repo paths, no declarations. Solo already records a path per project, so `crux swarm up` works
|
|
316
|
+
with nothing configured at all — and when the Solo project doesn't exist yet, the backend creates it
|
|
317
|
+
from your repo path rather than refusing. Each lead lands as a named agent process (`lead-acme`) you
|
|
318
|
+
can open, read, and talk to.
|
|
319
|
+
|
|
320
|
+
`exec` takes a command template and fills in the blanks:
|
|
321
|
+
|
|
322
|
+
```json
|
|
323
|
+
"swarm": {
|
|
324
|
+
"backend": "exec",
|
|
325
|
+
"exec": {
|
|
326
|
+
"command": "tmux new-session -d -s {name} '{command}'",
|
|
327
|
+
"stop": "tmux kill-session -t {name}"
|
|
328
|
+
},
|
|
329
|
+
"projects": { "crux": { "repo": "~/src/crux", "brief": "extra context for this lead" } }
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Placeholders: `{slug}` `{name}` `{repo}` `{brief_path}` `{command}`. An unknown one is **refused**,
|
|
334
|
+
never expanded to nothing — a swarm that silently half-starts is worse than one that won't start.
|
|
335
|
+
|
|
336
|
+
**Set `stop` whenever your command forks** (`tmux new-session -d`, `nohup … &`). Those exit
|
|
337
|
+
immediately, so the pid crux recorded is a dead handle to a live lead. With `stop`, crux addresses
|
|
338
|
+
the lead by name and can still kill it; without it, `crux swarm down` tells you it could not, rather
|
|
339
|
+
than reporting a lead it never stopped as stopped.
|
|
340
|
+
|
|
341
|
+
### It is idempotent, and that is the whole design
|
|
342
|
+
|
|
343
|
+
Run `swarm up` twice and the second run starts nothing. Two tails on one stream race the shared
|
|
344
|
+
cursor file and deliver every item **twice**, so "start a lead" has to mean "make sure exactly one
|
|
345
|
+
lead is running" — a reconciliation, not a spawn.
|
|
346
|
+
|
|
347
|
+
Coverage is read off the **OS process table** (who is really running `crux tail --project <slug>`),
|
|
348
|
+
never off a backend's opinion — Solo's own status lies, and tmux has no idea what a crux slug is.
|
|
349
|
+
That's why `exec` gets working reconciliation for free. Plus one thing the process table can't see:
|
|
350
|
+
a lead crux started whose tail hasn't come up *yet*. Without that, a second `up` inside the boot
|
|
351
|
+
window would double-tail the stream.
|
|
352
|
+
|
|
353
|
+
An agent multiplexer *could* have answered instead — the good ones track real agent status (idle /
|
|
354
|
+
working / blocked) — and one still doesn't get asked, for a precise reason. A healthy lead parked on
|
|
355
|
+
`crux tail` is doing nothing, so it gets called `idle`; a lead frozen at Claude Code's folder-trust
|
|
356
|
+
prompt is *also* doing nothing, so that gets called `idle` too. The two states that matter most are
|
|
357
|
+
indistinguishable to it, and the process table separates them cleanly. A backend's own status is
|
|
358
|
+
shown as detail; it is never what crux believes.
|
|
359
|
+
|
|
360
|
+
| state | meaning |
|
|
361
|
+
|---|---|
|
|
362
|
+
| `covered` | something is tailing this slug. Nothing to do. |
|
|
363
|
+
| `starting` | crux started this lead; its process is alive but its tail hasn't appeared yet. |
|
|
364
|
+
| `tail-down` | it's alive and has tailed before — it's between tail restarts. Not a new lead. |
|
|
365
|
+
| `stalled` | **alive, but it has NEVER tailed.** A dead swarm that looks alive. See below. |
|
|
366
|
+
| `leadless` | configured to expect no lead. Expected, not an error. |
|
|
367
|
+
| refusals | no repo path; a duplicate tail; a repo that disagrees with the backend's. |
|
|
368
|
+
|
|
369
|
+
Nothing is ever silently skipped. Every project crux won't bring up is named, with the reason.
|
|
370
|
+
|
|
371
|
+
### The one thing that will bite you first
|
|
372
|
+
|
|
373
|
+
Claude Code asks you to trust a directory it has never seen — and
|
|
374
|
+
`--dangerously-skip-permissions` **does not skip that prompt**. A lead started in an untrusted repo
|
|
375
|
+
sits at it forever: the agent is alive, the swarm is dead, and the only symptom is silence.
|
|
376
|
+
|
|
377
|
+
So crux watches for it. A lead that has been up for three minutes and has never started its tail is
|
|
378
|
+
reported `stalled`, with the cause, and `swarm up` refuses to start a second one on top of it
|
|
379
|
+
(it would stall in exactly the same place). To fix: run `claude` once in the repo and accept the
|
|
380
|
+
folder, then `crux swarm down --project <slug>` and `up` again.
|
|
381
|
+
|
|
382
|
+
### Config
|
|
383
|
+
|
|
384
|
+
Everything lives under `swarm` in the leads config (`~/.config/crux/leads.json`, or
|
|
385
|
+
`CRUX_LEADS_CONFIG`, or the template that ships with the package):
|
|
386
|
+
|
|
387
|
+
| key | |
|
|
388
|
+
|---|---|
|
|
389
|
+
| `backend` | `solo` (default) or `exec`. Override per-run with `--backend`, or `CRUX_SWARM_BACKEND`. |
|
|
390
|
+
| `agent` | the command an `exec` lead runs. Default `claude --dangerously-skip-permissions`. |
|
|
391
|
+
| `solo.agentToolId` | the Solo agent tool to spawn. Default `3` (claude). |
|
|
392
|
+
| `exec.command` / `exec.stop` | the templates above. |
|
|
393
|
+
| `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. |
|
|
394
|
+
| `projects.<slug>.brief` | extra context pasted into that lead's generated brief. |
|
|
395
|
+
|
|
396
|
+
`crux swarm down` stops **only** what `crux swarm up` started (it's tracked in
|
|
397
|
+
`~/.config/crux/swarm.json`). A lead you started by hand is reported and left running — crux will
|
|
398
|
+
not kill a process it did not start and cannot restart.
|
|
399
|
+
|
|
400
|
+
## Tests
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
npm test # node:test — the reconciliation classifiers, the cursor lock, and `crux swarm`
|
|
404
|
+
# end-to-end against a stub Solo and a stub API
|
|
405
|
+
```
|