@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.
@@ -0,0 +1,62 @@
1
+ {
2
+ "$comment": [
3
+ "The DEFAULT leads config, shipped with the package. It is a TEMPLATE, and it ships empty on",
4
+ "purpose: crux reads the first of CRUX_LEADS_CONFIG, ~/.config/crux/leads.json, and this file, so",
5
+ "the moment you have one of your own this one stops being read. `crux discover` writes",
6
+ "~/.config/crux/leads.json for you. Every key here also has a working default in code — a config",
7
+ "pre-filled with somebody else's project names is not a default, it is a mess you clean up first.",
8
+ "",
9
+ "map — an explicit Solo-project-name -> crux-slug mapping, read by `crux leads` and `crux swarm`.",
10
+ " You need an entry only where the two names are NOT equal; a Solo project whose name already",
11
+ " IS a crux slug needs none, which is why this can be empty and still reconcile a whole fleet.",
12
+ "",
13
+ " It is deliberately NOT string munging. In a fleet where `acme-search-index` is the slug",
14
+ " `acme` and `status-page` is the slug `beacon`, no transformation derives either one. So an",
15
+ " unmapped Solo project is reported as UNMAPPED and exits non-zero rather than being silently",
16
+ " skipped — a project that quietly falls out of the reconciliation is the exact failure mode",
17
+ " this command exists to catch.",
18
+ "",
19
+ " \"map\": { \"acme-search-index\": \"acme\", \"status-page\": \"beacon\" }",
20
+ "",
21
+ "leadless — projects configured to expect no lead. Reported as expected, never as an error.",
22
+ "leadProcess — override the Solo process name for a slug (the default is `lead-<slug>`)."
23
+ ],
24
+
25
+ "map": {},
26
+
27
+ "leadless": {
28
+ "personal": "Human-only project. Items are read on the dashboard, not routed to an agent lead."
29
+ },
30
+
31
+ "leadProcess": {},
32
+
33
+ "$swarm": [
34
+ "`crux swarm up` reads this. It is the whole configuration surface for spinning up a swarm.",
35
+ "",
36
+ "backend — solo | exec. ABSENT ON PURPOSE, and that absence is load-bearing: with no backend set,",
37
+ " crux looks at your PATH and picks one (solo, then tmux via exec), and `crux discover`",
38
+ " writes its choice into your config so you can see and change it. A backend pinned here",
39
+ " would hand every reader who does not run that orchestrator a first `crux swarm up` that",
40
+ " dies on `could not run …` — and a default that is wrong for most of its readers is not",
41
+ " a default, it is a trap. Set it here to PIN a backend; --backend and",
42
+ " CRUX_SWARM_BACKEND override it for one run.",
43
+ "agent — the command an `exec` lead runs. Solo names its own agent (see solo.agentToolId).",
44
+ "solo — { agentToolId: 3 } the Solo agent tool to spawn (3 = claude).",
45
+ "exec — { command, stop } templates. Placeholders: {slug} {name} {repo} {brief_path} {command}.",
46
+ " Set `stop` whenever `command` forks (tmux, nohup) or crux is left holding a dead pid.",
47
+ " Leave both out and crux derives the tmux pair — but only if tmux is really installed.",
48
+ "projects — per-slug { repo, brief }. `repo` is required for any project the backend cannot",
49
+ " locate itself; Solo already records a path per project, so a Solo user needs none of",
50
+ " this. `brief` is extra context pasted into the generated standing-lead brief.",
51
+ "",
52
+ "The projects map below is EMPTY on purpose. `crux discover` writes the repo for each project you",
53
+ "register, into your own config — baking one machine's paths into a shipped file is exactly what",
54
+ "`crux swarm` exists to stop doing."
55
+ ],
56
+
57
+ "swarm": {
58
+ "solo": { "agentToolId": 3 },
59
+ "exec": {},
60
+ "projects": {}
61
+ }
62
+ }
@@ -0,0 +1,617 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The orchestration adapters — "bring your own orchestration, but ship our own opinions."
5
+ *
6
+ * ## The interface
7
+ *
8
+ * A backend is three methods. That is the whole contract, and keeping it this small is what makes
9
+ * the North Star true rather than a slogan:
10
+ *
11
+ * start(lead) → {ref, detail} start one standing lead. `ref` is an opaque handle, meaningful
12
+ * only to this backend, that crux records and hands back to stop().
13
+ * stop(entry) → 'stopped' | 'already-gone' | 'unknown'
14
+ * list() → [{slug, ref, running}] what this backend can see running, right now.
15
+ *
16
+ * Plus one declaration, `addresses`, which is not a method because it does not vary per call:
17
+ *
18
+ * 'handle' — this backend can stop a lead from its ref alone, forever (Solo's process id; a tmux
19
+ * session name). The recorded process being gone does not mean the lead is gone.
20
+ * 'pid' — the ref IS an OS pid, and if that pid dies the backend has no other way to name the
21
+ * lead. `planDown` refuses rather than pretend, because a lead that outlived the only
22
+ * handle crux has on it must be reported, not quietly forgotten.
23
+ *
24
+ * ## What is deliberately NOT in the interface
25
+ *
26
+ * **Coverage.** A backend is never asked "is <slug> covered?", because a backend cannot be trusted
27
+ * to answer it — Solo's own status lies, and tmux has no idea what a crux slug is. Coverage is read
28
+ * off the OS process table, where the truth actually lives {@see ../lib/leads}: something, somewhere,
29
+ * is running `crux tail --project <slug>`. That is why `exec` gets working reconciliation for free.
30
+ *
31
+ * ## herdr
32
+ *
33
+ * The third backend {@see herdrBackend}, written against herdr 0.7.3 as actually installed and run —
34
+ * not against its website, which documents `workspace create` / `tab create` / `pane split` / `pane
35
+ * run` and shows neither of the two verbs an adapter most needs (how do you LIST agents, and how do
36
+ * you KILL one). The real surface has both, plus a better start verb than the docs suggest, and the
37
+ * adapter uses those. `herdr --help` and `herdr <verb> --help` are the source of truth; the day this
38
+ * stops matching, it is because herdr moved, and the fix is to re-read them.
39
+ */
40
+
41
+ const { execFileSync, spawn: spawnChild } = require('child_process');
42
+ const { DEFAULT_AGENT, agentCommand, execVars, leadName, renderTemplate } = require('./swarm');
43
+
44
+ /** Solo's own agent-tool id for `claude --dangerously-skip-permissions`. */
45
+ const DEFAULT_AGENT_TOOL_ID = 3;
46
+
47
+ /** `solo … --json`, tolerantly: null on any failure. Mirrors lib/spawn's soloTry. */
48
+ function soloTry(argv) {
49
+ try {
50
+ const raw = execFileSync(process.env.CRUX_SOLO_BIN || 'solo', [...argv, '--json'], {
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ });
54
+ const doc = JSON.parse(raw);
55
+ return doc.ok ? doc.data : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** `solo … --json` or throw. Starting a lead is load-bearing: if Solo cannot, we must not pretend. */
62
+ function solo(argv) {
63
+ let raw;
64
+ try {
65
+ raw = execFileSync(process.env.CRUX_SOLO_BIN || 'solo', [...argv, '--json'], {
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ });
69
+ } catch (e) {
70
+ throw new Error(`could not run \`solo ${argv.join(' ')}\`: ${e.message.split('\n')[0]}`);
71
+ }
72
+ let doc;
73
+ try {
74
+ doc = JSON.parse(raw);
75
+ } catch {
76
+ throw new Error(`\`solo ${argv.join(' ')}\` did not return JSON`);
77
+ }
78
+ if (!doc.ok) throw new Error(`\`solo ${argv.join(' ')}\` failed: ${doc.error || 'unknown error'}`);
79
+ return doc.data;
80
+ }
81
+
82
+ /** Every Solo process, following pagination — an unpaginated list looks complete and is not. */
83
+ function soloProcesses() {
84
+ const all = [];
85
+ for (let offset = 0; ; ) {
86
+ const page = soloTry(['processes', 'list', '--limit', '200', '--offset', String(offset)]);
87
+ if (page === null) return all;
88
+ all.push(...(page.processes || []));
89
+ if (!page.hasMore || (page.processes || []).length === 0) return all;
90
+ offset = page.nextOffset ?? offset + page.processes.length;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The opinionated default: Solo.
96
+ *
97
+ * The one real difference from `crux leads --fix` is that this SPAWNS rather than starts. `--fix`
98
+ * can only start a `kind: command` row a human already declared in `solo.yml`, which is why it
99
+ * cannot build a swarm from nothing. This spawns a `kind: agent` process directly — the same call
100
+ * `crux spawn` makes for a worker — so no declaration has to exist first. And when the Solo project
101
+ * itself does not exist, it is created from the configured repo path, which is the last thing
102
+ * standing between a stranger and a running swarm.
103
+ */
104
+ function soloBackend({ agentToolId = DEFAULT_AGENT_TOOL_ID } = {}) {
105
+ return {
106
+ name: 'solo',
107
+ addresses: 'handle',
108
+
109
+ /** Solo runs the agent in its project's directory, so the project IS the cwd. */
110
+ ensureProject(lead) {
111
+ if (lead.soloProject) return { project: lead.soloProject, created: false };
112
+
113
+ const created = solo(['projects', 'create', lead.slug, lead.repo]);
114
+ const project = created.project ?? created;
115
+ return { project: { id: project.id, name: project.name ?? lead.slug }, created: true };
116
+ },
117
+
118
+ start(lead) {
119
+ const { project, created } = this.ensureProject(lead);
120
+ const name = leadName(lead.slug);
121
+
122
+ // Each flag rides in as its own --arg: Solo appends them after the agent tool's own command
123
+ // (`claude --dangerously-skip-permissions`), exactly as lib/spawn.js does for a worker.
124
+ const data = solo([
125
+ 'processes',
126
+ 'spawn',
127
+ '--project-id',
128
+ String(project.id),
129
+ '--kind',
130
+ 'agent',
131
+ '--agent-tool-id',
132
+ String(agentToolId),
133
+ '--name',
134
+ name,
135
+ '--arg',
136
+ '-n',
137
+ '--arg',
138
+ name,
139
+ '--arg',
140
+ lead.prompt,
141
+ ]);
142
+
143
+ const ref = data?.process?.id ?? data?.process ?? null;
144
+ if (ref === null) {
145
+ throw new Error(`Solo spawned the lead for "${lead.slug}" but reported no process id`);
146
+ }
147
+
148
+ return {
149
+ ref: String(ref),
150
+ detail:
151
+ `Solo process ${ref} in project ${project.id} (${project.name})` +
152
+ `${created ? ' — project created' : ''}`,
153
+ };
154
+ },
155
+
156
+ stop(entry) {
157
+ const data = soloTry(['processes', 'stop', String(entry.ref)]);
158
+ if (data === null) return 'unknown';
159
+ return data.changed === true ? 'stopped' : 'already-gone';
160
+ },
161
+
162
+ list() {
163
+ return soloProcesses()
164
+ .filter((p) => typeof p.name === 'string' && p.name.startsWith('lead-'))
165
+ .map((p) => ({
166
+ slug: p.name.slice('lead-'.length),
167
+ ref: String(p.id),
168
+ running: p.status === 'running',
169
+ }));
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The escape hatch that makes "bring your own orchestration" true: run an arbitrary command.
176
+ *
177
+ * The user supplies a template and crux fills in the blanks:
178
+ *
179
+ * {"backend": "exec", "exec": {"command": "tmux new-session -d -s {name} '{command}'",
180
+ * "stop": "tmux kill-session -t {name}"}}
181
+ *
182
+ * If a user cannot run their swarm on their own orchestrator with this, the North Star is a lie —
183
+ * so the placeholders are documented, an unknown one is a hard failure {@see renderTemplate}, and
184
+ * nothing about crux's model of a lead is assumed beyond "a command starts it".
185
+ *
186
+ * `stop` is optional but strongly wanted, and the reason is the tmux example itself: `tmux
187
+ * new-session -d` FORKS — the process crux spawned exits immediately while the lead lives on inside
188
+ * the tmux server. The pid crux recorded is then a dead handle to a live lead. With a `stop`
189
+ * template crux can still address it by name; without one it cannot, and `planDown` says so out loud
190
+ * rather than reporting a lead it did not stop as stopped.
191
+ */
192
+ function execBackend({ command, stop = null, state = {} } = {}) {
193
+ if (typeof command !== 'string' || command.trim() === '') {
194
+ throw new Error(
195
+ 'the exec backend needs a command template — set `swarm.exec.command`, e.g. ' +
196
+ `"tmux new-session -d -s {name} '{command}'"`,
197
+ );
198
+ }
199
+
200
+ const alive = (pid) => {
201
+ const n = Number(pid);
202
+ if (!Number.isInteger(n) || n <= 0) return false;
203
+ try {
204
+ process.kill(n, 0);
205
+ return true;
206
+ } catch (e) {
207
+ return e.code === 'EPERM'; // Alive, just not ours to signal.
208
+ }
209
+ };
210
+
211
+ return {
212
+ name: 'exec',
213
+ // With a stop template the lead is addressable by name forever; without one, the pid is the
214
+ // only handle there is — and a pid is mortal in a way a name is not.
215
+ addresses: stop ? 'handle' : 'pid',
216
+
217
+ start(lead) {
218
+ const line = renderTemplate(command, execVars(lead));
219
+
220
+ // Detached, in its own process group, so it survives crux exiting — and so that a pid-based
221
+ // stop can signal the whole tree (the agent AND the tail it starts), not just the shell.
222
+ const child = spawnChild('sh', ['-c', line], {
223
+ cwd: lead.repo,
224
+ detached: true,
225
+ stdio: 'ignore',
226
+ });
227
+ child.unref();
228
+
229
+ if (typeof child.pid !== 'number') {
230
+ throw new Error(`could not start the lead for "${lead.slug}": \`${line}\` produced no pid`);
231
+ }
232
+
233
+ return { ref: String(child.pid), detail: `pid ${child.pid} — \`${line}\`` };
234
+ },
235
+
236
+ stop(entry) {
237
+ // The template is the user's own statement of how their orchestrator kills a lead. Prefer it:
238
+ // it addresses the lead by NAME, which still works after the spawned pid has forked away.
239
+ if (stop) {
240
+ const line = renderTemplate(stop, execVars({ ...entry, agent: entry.agent }));
241
+ try {
242
+ execFileSync('sh', ['-c', line], { stdio: ['ignore', 'pipe', 'pipe'] });
243
+ return 'stopped';
244
+ } catch {
245
+ return 'unknown';
246
+ }
247
+ }
248
+
249
+ const pid = Number(entry.ref);
250
+ if (!alive(pid)) return 'already-gone';
251
+
252
+ // Negative pid = the whole process group. The lead is a tree — the agent, and the `crux tail`
253
+ // it spawned — and killing only the shell would orphan the tail, which is the one thing that
254
+ // must not survive: an orphaned tail keeps consuming the stream with nobody to act on it.
255
+ try {
256
+ process.kill(-pid, 'SIGTERM');
257
+ return 'stopped';
258
+ } catch {
259
+ try {
260
+ process.kill(pid, 'SIGTERM');
261
+ return 'stopped';
262
+ } catch {
263
+ return 'unknown';
264
+ }
265
+ }
266
+ },
267
+
268
+ /**
269
+ * exec has no registry of its own — there is nothing to ask. `sh -c` does not remember what it
270
+ * ran, so the only leads this backend can see are the ones crux recorded when it started them.
271
+ * That is an honest answer, and it is why `list()` is used for liveness and display and never
272
+ * for ownership.
273
+ *
274
+ * ## What `running` can honestly mean, which depends entirely on the template
275
+ *
276
+ * With a `stop` template the command FORKS — that is the whole reason `stop` exists — so the pid
277
+ * crux spawned is dead within the second while the lead lives on inside tmux (or systemd, or
278
+ * whatever). `alive(ref)` therefore answers a question nobody asked: it reports on a shell that
279
+ * exited, not on the lead. Believing it says "not running" about a lead that is, and the damage
280
+ * is not cosmetic — it is what `planUp` reads to decide whether the slug is already being
281
+ * brought up. Caught by running the real thing twice:
282
+ *
283
+ * $ crux swarm up --project mercury-billing # tmux session created, agent booting
284
+ * $ crux swarm up --project mercury-billing # ...ten seconds later
285
+ * START mercury-billing started pid 56908 — `tmux new-session -d -s lead-mercury-billing …`
286
+ * 1 started
287
+ *
288
+ * Nothing was started. tmux refused the duplicate session name, crux never looked at the exit
289
+ * status, and reported success — the precise lie this fleet exists to destroy. And tmux is the
290
+ * only reason it was merely a lie: a template that does NOT enforce unique names (`nohup … &`)
291
+ * would have started a second real lead, two tails on one stream, racing the cursor.
292
+ *
293
+ * So when the lead is addressable by name, `running` means what it means for herdr, and for the
294
+ * same reason: crux started this lead and has no way to watch it die, so it says so. The states
295
+ * that follow are the honest ones — the process table still decides coverage, so a lead that is
296
+ * really up reads as `covered`, and one that died before it ever tailed goes `stalled` after the
297
+ * grace window ({@see ../lib/swarm.planUp}) with `crux swarm down --project <slug>` as the
298
+ * remedy, which works precisely because the name still names it. Loud, and recoverable.
299
+ *
300
+ * Without a `stop` template the pid IS the only handle there is (`addresses: 'pid'`), and then it
301
+ * is the truest thing available — so it is used.
302
+ */
303
+ list() {
304
+ return Object.entries(state)
305
+ .filter(([, entry]) => entry.backend === 'exec')
306
+ .map(([slug, entry]) => ({
307
+ slug,
308
+ ref: String(entry.ref),
309
+ running: stop ? true : alive(entry.ref),
310
+ }));
311
+ },
312
+ };
313
+ }
314
+
315
+ /** Sleep without yielding, so `ensureServer` can poll inside a synchronous adapter method. */
316
+ function sleepSync(ms) {
317
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
318
+ }
319
+
320
+ /** `herdr …`, session-scoped. Every socket verb accepts `--session`, so it prefixes all of them. */
321
+ function herdrArgv(session, argv) {
322
+ return session ? ['--session', session, ...argv] : argv;
323
+ }
324
+
325
+ /** herdr answers an error as a JSON envelope on stdout AND exits 1, so a throw still carries it. */
326
+ function herdrErrorFrom(raw) {
327
+ try {
328
+ const doc = JSON.parse(raw);
329
+ return doc?.error ? `${doc.error.message} (${doc.error.code})` : null;
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * `herdr <verb>` → its `result`, or null on any failure.
337
+ *
338
+ * The most common failure is the interesting one: with no server running, the socket file does not
339
+ * exist and herdr exits 1 with a raw `Os { code: 2, kind: NotFound }` on stderr — not JSON. That is
340
+ * not a crux bug to be surfaced, it is simply "there are no panes", so it collapses to null here and
341
+ * every caller reads it as "nothing is running". Mirrors soloTry.
342
+ */
343
+ function herdrTry(session, argv) {
344
+ try {
345
+ const raw = execFileSync(process.env.CRUX_HERDR_BIN || 'herdr', herdrArgv(session, argv), {
346
+ encoding: 'utf8',
347
+ stdio: ['ignore', 'pipe', 'pipe'],
348
+ });
349
+ const doc = JSON.parse(raw);
350
+ return doc.error ? null : (doc.result ?? null);
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+
356
+ /** `herdr <verb>` or throw. Starting a lead is load-bearing: if herdr cannot, we must not pretend. */
357
+ function herdrRun(session, argv) {
358
+ let raw;
359
+ try {
360
+ raw = execFileSync(process.env.CRUX_HERDR_BIN || 'herdr', herdrArgv(session, argv), {
361
+ encoding: 'utf8',
362
+ stdio: ['ignore', 'pipe', 'pipe'],
363
+ });
364
+ } catch (e) {
365
+ const detail =
366
+ herdrErrorFrom(e.stdout) || String(e.stderr || e.message).split('\n')[0] || 'unknown error';
367
+ throw new Error(`could not run \`herdr ${argv.join(' ')}\`: ${detail}`);
368
+ }
369
+
370
+ let doc;
371
+ try {
372
+ doc = JSON.parse(raw);
373
+ } catch {
374
+ throw new Error(`\`herdr ${argv.join(' ')}\` did not return JSON`);
375
+ }
376
+ if (doc.error) {
377
+ throw new Error(`\`herdr ${argv.join(' ')}\` failed: ${doc.error.message} (${doc.error.code})`);
378
+ }
379
+ return doc.result ?? {};
380
+ }
381
+
382
+ /**
383
+ * herdr — a terminal multiplexer built for coding agents (herdr.dev).
384
+ *
385
+ * A standing lead is exactly what herdr exists to host: a long-lived agent pane, in a repo, that
386
+ * survives detach and can be reattached over ssh. The mapping, against the REAL 0.7.3 CLI:
387
+ *
388
+ * start → `herdr agent start <name> --cwd <repo> -- <argv…>` (undocumented on the site, which
389
+ * shows the workspace/tab/pane-split dance instead; this one verb does all of it — it
390
+ * creates the workspace, the tab and the pane, names the agent, and execs argv in <repo>)
391
+ * stop → `herdr pane close <pane_id>` — there is NO `herdr agent stop`/`kill`. That verb does
392
+ * not exist; closing the agent's PANE is how you kill an agent, and it does really kill it
393
+ * (the child process dies with the pane, verified).
394
+ * list → `herdr agent list` → {agents:[{name, pane_id, workspace_id, terminal_id, agent_status}]}
395
+ *
396
+ * ## Why the ref is the NAME, and not the pane id
397
+ *
398
+ * `pane close` only accepts a `pane_id` ("w2:p3") — it rejects a terminal id outright. The lazy
399
+ * adapter therefore records the pane id at start and closes it at stop. That is a bug waiting to
400
+ * happen, and it is the kind that kills the WRONG pane: a pane id is positional and mutable — `herdr
401
+ * pane move` re-homes a pane into another tab or workspace and its id changes with it, so a user who
402
+ * rearranges their herdr layout would have crux close whatever now sits at the id it wrote down.
403
+ *
404
+ * So the ref is the agent's NAME (`lead-<slug>`), which is crux's own, unique by construction (one
405
+ * lead per project), and which herdr itself addresses agents by. `stop()` RESOLVES that name to a
406
+ * pane id at the moment it stops — never holding a positional id across time. It costs one extra
407
+ * call and it is the difference between closing the lead and closing whatever the user just moved
408
+ * into its old slot.
409
+ *
410
+ * herdr enforces name uniqueness at its own layer (a second `agent start lead-alpha` is refused with
411
+ * `agent_name_taken` rather than quietly creating a twin), which makes the name a sound handle AND
412
+ * gives idempotence a second floor beneath crux's state file: even with the state file deleted, herdr
413
+ * will not let `swarm up` start a second lead on a slug it is already hosting.
414
+ *
415
+ * That is `addresses: 'handle'` in the honest sense — the name still names the lead long after the
416
+ * process crux spawned has gone.
417
+ *
418
+ * ## Liveness: the process table, still — and this backend is the one that had a real alternative
419
+ *
420
+ * herdr is the first backend with genuine agent-status awareness (idle / working / blocked), so the
421
+ * tempting move is to let it answer "is this lead healthy?" and skip the process table. It is the
422
+ * wrong move, and the reason is precise rather than dogmatic:
423
+ *
424
+ * A HEALTHY standing lead is parked on `crux tail` — blocked on a socket, burning nothing, doing
425
+ * nothing. herdr reports that as `idle`. A lead STUCK at Claude Code's folder-trust prompt is also
426
+ * doing nothing, and herdr reports that as `idle` too. The two states that matter most — the lead
427
+ * that is working perfectly and the lead that is dead on its feet — are INDISTINGUISHABLE under
428
+ * herdr status. Meanwhile the process table separates them cleanly, because one of them is running
429
+ * `crux tail --project <slug>` and the other never got there.
430
+ *
431
+ * herdr status is more convenient and less truthful, so coverage stays where it lives for every other
432
+ * backend: the OS process table {@see ../lib/leads}. herdr's status rides along as DETAIL — it is
433
+ * genuinely useful for a human reading `swarm status` ("blocked" is a real signal) — and never as the
434
+ * thing crux believes.
435
+ *
436
+ * And herdr's view is unreliable in a second, sharper way, found by restarting a real server rather
437
+ * than by reasoning about one: **herdr restores its panes when its server restarts, keeping the agent
438
+ * NAME, but not the command.** The restored pane comes back running a bare login shell. So an agent
439
+ * can sit in `agent list`, named `lead-crux`, looking for all the world like a lead, with the claude
440
+ * process that made it a lead long dead. herdr's own registry outlives the thing it describes. Any
441
+ * adapter that had trusted `agent list` to mean "running" — the obvious reading, and the one the
442
+ * website's framing invites — would report that zombie as a healthy lead forever.
443
+ *
444
+ * `running` here therefore means only what it can honestly mean: the pane EXISTS. Which is exactly
445
+ * the question the planner is really asking it, and the reason a zombie is safe:
446
+ *
447
+ * - COVERAGE is not asked of this method at all, so a zombie can never read as green. The process
448
+ * table sees no `crux tail --project <slug>`, and `planUp` reports the slug as `stalled` — alive,
449
+ * never tailed, past the grace window — with `crux swarm down --project <slug>` as the remedy,
450
+ * which closes the pane and lets the next `up` start a real lead. Loud, and recoverable.
451
+ * - Reporting the zombie as NOT running would be worse, not better: crux would try to start a
452
+ * second lead, and herdr would refuse it with `agent_name_taken` because the zombie still holds
453
+ * the name. The swarm would wedge instead of self-describing.
454
+ *
455
+ * The same presence rule is what makes the folder-trust trap detectable — a lead frozen at that
456
+ * prompt is alive with no tail, which is precisely `planUp`'s `stalled` refusal, and it would be
457
+ * invisible if `idle` were allowed to mean "not running".
458
+ */
459
+ function herdrBackend({ session = null } = {}) {
460
+ return {
461
+ name: 'herdr',
462
+ addresses: 'handle',
463
+
464
+ /** True when a herdr server is up for this session. The one verb that answers without a socket. */
465
+ serverUp() {
466
+ try {
467
+ const out = execFileSync(
468
+ process.env.CRUX_HERDR_BIN || 'herdr',
469
+ herdrArgv(session, ['status', 'server']),
470
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
471
+ );
472
+ return /^status:\s*running/m.test(out);
473
+ } catch {
474
+ return false;
475
+ }
476
+ },
477
+
478
+ /**
479
+ * A herdr socket verb against no server is not an error worth reporting, it is a server that has
480
+ * not been started — so start one, exactly as the solo backend creates a missing project rather
481
+ * than refusing. Detached, because the swarm has to outlive the `crux swarm up` that made it.
482
+ */
483
+ ensureServer() {
484
+ if (this.serverUp()) return false;
485
+
486
+ const child = spawnChild(
487
+ process.env.CRUX_HERDR_BIN || 'herdr',
488
+ herdrArgv(session, ['server']),
489
+ { detached: true, stdio: 'ignore' },
490
+ );
491
+ child.unref();
492
+
493
+ // The server binds its socket a beat after exec. Poll rather than sleep-and-hope: a lead
494
+ // started against a socket that is not listening yet fails for a reason nobody could diagnose.
495
+ for (let i = 0; i < 50; i++) {
496
+ if (this.serverUp()) return true;
497
+ sleepSync(200);
498
+ }
499
+
500
+ throw new Error(
501
+ 'started `herdr server` but it never began listening on its socket. Run `herdr status` to ' +
502
+ 'see what it is doing.',
503
+ );
504
+ },
505
+
506
+ /**
507
+ * argv, not a shell line — and that is a real gain over `exec`, not a detail. `herdr agent start`
508
+ * execs its argv directly, so there is no shell to quote for: the apostrophe that `execVars`
509
+ * has to REFUSE (it would break out of the single-quoted `{command}` in the tmux template) is
510
+ * simply a character here. Same claude invocation as every other backend, minus the quoting trap.
511
+ */
512
+ argvFor(lead) {
513
+ const agent = String(lead.agent || DEFAULT_AGENT).trim();
514
+ return [...agent.split(/\s+/), '-n', leadName(lead.slug), lead.prompt];
515
+ },
516
+
517
+ start(lead) {
518
+ const created = this.ensureServer();
519
+ const name = leadName(lead.slug);
520
+
521
+ // --no-focus: `swarm up` starting eight leads must not yank the user's herdr focus eight times.
522
+ const data = herdrRun(session, [
523
+ 'agent',
524
+ 'start',
525
+ name,
526
+ '--cwd',
527
+ lead.repo,
528
+ '--no-focus',
529
+ '--',
530
+ ...this.argvFor(lead),
531
+ ]);
532
+
533
+ const agent = data?.agent;
534
+ if (!agent?.name) {
535
+ throw new Error(`herdr started the lead for "${lead.slug}" but reported no agent`);
536
+ }
537
+
538
+ return {
539
+ ref: agent.name,
540
+ detail:
541
+ `herdr agent ${agent.name} in pane ${agent.pane_id} (workspace ${agent.workspace_id})` +
542
+ `${created ? ' — herdr server started' : ''}` +
543
+ `${session ? ` — session ${session}` : ''}`,
544
+ };
545
+ },
546
+
547
+ /**
548
+ * Resolve the name to a pane id NOW, then close that pane — never the pane id crux wrote down at
549
+ * start. See the header: pane ids move, and a stale one closes a stranger's pane. There is no
550
+ * `herdr agent stop`; closing the agent's pane is how herdr kills an agent, and it does kill it.
551
+ */
552
+ stop(entry) {
553
+ const agents = this.agents();
554
+
555
+ if (agents === null) {
556
+ // Could not ask herdr at all. If its server is down, the lead is genuinely gone — herdr
557
+ // reaps every agent process when its server stops, so there is nothing left to kill and
558
+ // `already-gone` is a fact. But if the server is UP and the query still failed, we do not
559
+ // know what happened, and a stop crux did not make must never be reported as one.
560
+ return this.serverUp() ? 'unknown' : 'already-gone';
561
+ }
562
+
563
+ const found = agents.find((a) => a.name === String(entry.ref));
564
+ if (!found) return 'already-gone';
565
+
566
+ const closed = herdrTry(session, ['pane', 'close', found.pane_id]);
567
+ return closed === null ? 'unknown' : 'stopped';
568
+ },
569
+
570
+ /** Every agent herdr can see, or null when there is no server to ask. */
571
+ agents() {
572
+ const data = herdrTry(session, ['agent', 'list']);
573
+ if (data === null) return null;
574
+ return Array.isArray(data.agents) ? data.agents : [];
575
+ },
576
+
577
+ list() {
578
+ return (this.agents() ?? [])
579
+ .filter((a) => typeof a.name === 'string' && a.name.startsWith('lead-'))
580
+ .map((a) => ({
581
+ slug: a.name.slice('lead-'.length),
582
+ ref: a.name,
583
+ // Presence, and deliberately NOT `a.agent_status`. A healthy lead parked on `crux tail` is
584
+ // `idle` — parking IS doing nothing — and so is a lead frozen at a folder-trust prompt, so
585
+ // status cannot separate the two states that matter most. Presence is also all herdr can
586
+ // honestly support: a pane restored across a server restart keeps this name while running
587
+ // a bare shell. Coverage comes from the process table either way, so a zombie surfaces as
588
+ // `stalled` rather than as green. See the header.
589
+ running: true,
590
+ status: a.agent_status ?? 'unknown',
591
+ pane: a.pane_id,
592
+ }));
593
+ },
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Build the backend named in config.
599
+ *
600
+ * An unknown name is refused with the list of what exists.
601
+ */
602
+ function makeBackend(name, { config = {}, state = {} } = {}) {
603
+ if (name === 'solo') return soloBackend(config.solo || {});
604
+ if (name === 'exec') return execBackend({ ...(config.exec || {}), state });
605
+ if (name === 'herdr') return herdrBackend(config.herdr || {});
606
+
607
+ throw new Error(`unknown backend "${name}" (available: solo, exec, herdr)`);
608
+ }
609
+
610
+ module.exports = {
611
+ DEFAULT_AGENT_TOOL_ID,
612
+ execBackend,
613
+ herdrBackend,
614
+ makeBackend,
615
+ soloBackend,
616
+ soloProcesses,
617
+ };