@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/lib/swarm.js ADDED
@@ -0,0 +1,628 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `crux swarm` — take a user from "I have crux projects" to "I have standing leads working my
5
+ * threads", on whatever orchestrator they already run.
6
+ *
7
+ * Everything in this file is PURE: no processes, no network, no clock, no filesystem. It decides
8
+ * what *should* happen; `lib/backends.js` is the only thing allowed to make it happen, and crux.js
9
+ * does the I/O that feeds this. That split is what makes the refusals testable, and the refusals
10
+ * are the product.
11
+ *
12
+ * ## Why a swarm is not just `crux leads --fix`
13
+ *
14
+ * `--fix` can only *start* a lead that a human already hand-declared as a `kind: command` row in
15
+ * Solo's `solo.yml` — read `planFix()`: every state that is not `uncovered` is an explicit refusal,
16
+ * and a project with no declaration is refused too. That is correct for a fleet that already exists
17
+ * and wrong for a member of the public, who has no declarations, no `~/dispatch/leads/*.md`, and
18
+ * possibly no Solo. `swarm up` builds the missing three things itself: it GENERATES the standing-lead
19
+ * brief (below), SPAWNS the lead process directly rather than starting a declared one, and does both
20
+ * through an adapter so the orchestrator is the user's choice.
21
+ *
22
+ * ## Coverage is an OS fact, not a backend's opinion
23
+ *
24
+ * A lead's identity is not its process name — the name is a label anyone can reuse, and Solo's own
25
+ * status lies (see the header of `lib/leads.js`). What actually matters is whether *something* is
26
+ * running `crux tail --project <slug>`, because that is the thing that consumes the stream. So
27
+ * coverage is read off the OS process table for every backend, and the adapter is never asked. This
28
+ * is why the `exec` backend gets working reconciliation for free: crux does not need to understand
29
+ * tmux to see the tail tmux started.
30
+ *
31
+ * The one thing the process table cannot tell us is the few seconds between "the agent process is
32
+ * up" and "the agent has got as far as running its tail". A second `swarm up` inside that window
33
+ * would see no tail, conclude `uncovered`, and start a SECOND lead — two tails on one stream, racing
34
+ * the shared cursor file and double-delivering every item. So a lead crux started is also remembered
35
+ * ({@see ../lib/swarm-state}), and a remembered lead whose process is still alive counts as covered
36
+ * (`starting`) even before its tail appears. Idempotence needs both halves: the process table alone
37
+ * misses the boot window, and the state file alone misses every lead crux did not start.
38
+ */
39
+
40
+ /** Placeholders an `exec` command template may use. Anything else is a typo, and typos are refused. */
41
+ const EXEC_PLACEHOLDERS = ['slug', 'name', 'repo', 'brief_path', 'command'];
42
+
43
+ /**
44
+ * How long a lead may be "up but not yet tailing" before crux calls it STUCK rather than starting.
45
+ *
46
+ * There is a real gap between the agent process existing and the agent having got as far as running
47
+ * `crux tail`, and during it the slug looks uncovered — {@see planUp} treats that as `starting` so a
48
+ * second `swarm up` cannot double-tail the stream. But "starting" must be able to EXPIRE, because a
49
+ * lead that never tails is exactly as dead as one that never started, and a state that is green
50
+ * forever is the silence this whole fleet exists to destroy.
51
+ *
52
+ * The failure this caught, live, on the first real run: a lead whose repo is a directory Claude Code
53
+ * has not seen before sits at the folder-trust prompt indefinitely. `--dangerously-skip-permissions`
54
+ * does not bypass it. `swarm up` said "1 started", the process was alive, and the swarm was dead —
55
+ * the precise outcome a stranger would hit on their first run and blame crux for. Generous, because
56
+ * a slow boot is not a fault: a real lead reaches its tail in well under a minute.
57
+ */
58
+ const STALL_AFTER_MS = 3 * 60 * 1000;
59
+
60
+ /** The agent crux runs for a lead when the config does not say otherwise. */
61
+ const DEFAULT_AGENT = 'claude --dangerously-skip-permissions';
62
+
63
+ /** The lead process name for a slug. Solo names the process this; tmux names the session this. */
64
+ function leadName(slug) {
65
+ return `lead-${slug}`;
66
+ }
67
+
68
+ /** The actor a lead announces itself as — its presence dot on /projects, and its byline on replies. */
69
+ function leadActor(slug) {
70
+ return `lead-${slug}`;
71
+ }
72
+
73
+ /**
74
+ * The prompt the lead agent is started with — deliberately tiny, because the CONTRACT lives in the
75
+ * generated brief on disk {@see renderLeadBrief} where a human can read it, diff it, and edit it.
76
+ *
77
+ * It contains no single quote, and that is load-bearing rather than stylistic: an `exec` template
78
+ * embeds `{command}` inside a single-quoted shell word in the documented tmux example
79
+ * (`tmux new-session -d -s {name} '{command}'`). One apostrophe here would end that word early and
80
+ * hand the user a broken shell line. {@see execCommand}, which refuses rather than emit one.
81
+ */
82
+ function leadPrompt(briefPath) {
83
+ return (
84
+ `Read ${briefPath} and follow it exactly. ` +
85
+ 'You are the standing project lead — park on crux tail, handle items, never exit.'
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The full shell command that starts one lead: the user's agent, named, handed the prompt.
91
+ *
92
+ * This is what `{command}` expands to in an `exec` template, and what the `solo` backend passes as
93
+ * `--arg`s. Both backends run the SAME command, which is the point — swapping the orchestrator must
94
+ * not quietly swap what gets run.
95
+ */
96
+ function agentCommand({ agent = DEFAULT_AGENT, slug, briefPath }) {
97
+ return `${agent} -n ${leadName(slug)} "${leadPrompt(briefPath)}"`;
98
+ }
99
+
100
+ /**
101
+ * Expand an `exec` template.
102
+ *
103
+ * An unknown placeholder is a hard failure, never an empty string. `{brief}` instead of
104
+ * `{brief_path}` would otherwise start a lead with a truncated command line and no explanation —
105
+ * and a swarm that silently half-starts is precisely the ambiguity this whole feature exists to
106
+ * remove. Refuse rather than guess.
107
+ */
108
+ function renderTemplate(template, vars) {
109
+ const unknown = [];
110
+ const out = template.replace(/\{(\w+)\}/g, (match, key) => {
111
+ if (!Object.hasOwn(vars, key)) {
112
+ unknown.push(key);
113
+ return match;
114
+ }
115
+ return vars[key];
116
+ });
117
+
118
+ if (unknown.length) {
119
+ throw new Error(
120
+ `unknown placeholder${unknown.length > 1 ? 's' : ''} ${unknown.map((u) => `{${u}}`).join(', ')} ` +
121
+ `in the exec template (valid: ${EXEC_PLACEHOLDERS.map((p) => `{${p}}`).join(', ')})`,
122
+ );
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * The variables an `exec` template may interpolate, for one lead.
129
+ *
130
+ * Refuses to build a command containing a single quote. The documented template wraps `{command}` in
131
+ * a single-quoted shell word, so an apostrophe — reachable only through a configured `agent` or a
132
+ * repo path — would silently terminate that word and produce a command line that is not the one
133
+ * anybody wrote. Better to say so than to spawn it.
134
+ */
135
+ function execVars(lead) {
136
+ const command = agentCommand({ agent: lead.agent, slug: lead.slug, briefPath: lead.briefPath });
137
+
138
+ if (command.includes("'")) {
139
+ throw new Error(
140
+ `the command for "${lead.slug}" contains a single quote (${command}) — it cannot be safely ` +
141
+ "embedded in an exec template, which quotes {command} as '{command}'. Remove the apostrophe " +
142
+ 'from the agent command or the brief path.',
143
+ );
144
+ }
145
+
146
+ return {
147
+ slug: lead.slug,
148
+ name: leadName(lead.slug),
149
+ repo: lead.repo,
150
+ brief_path: lead.briefPath,
151
+ command,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The standing-lead brief crux ships — the actual product opinion, written down.
157
+ *
158
+ * Until now this was folklore: each lead was hand-started with "Read ~/leads/<slug>.md and follow it
159
+ * exactly", those files were hand-written, they lived outside the repo, and they encoded the entire
160
+ * operating contract. Nobody but their author could reproduce a swarm from them. So crux generates it, and the generic contract below is the distillation — every
161
+ * line is here because getting it wrong broke something real:
162
+ *
163
+ * - THE THREE TOPICS. `--topics items,activities,decisions` is not a default worth overriding. A
164
+ * human's reply arrives as an *activity* and the answer to a `crux need` arrives as a *decision*;
165
+ * an items-only tail hears the question it asked and never the answer, and the thread is one-way.
166
+ * - ACK ON RECEIPT, before the work, or the thread reads as idle while it is being worked.
167
+ * - `--type note`, because that is what the app renders as a chat bubble; and `--item <id>`, or the
168
+ * reply is filed against the project and no item view ever shows it (this made 66 of 70 lead
169
+ * answers invisible).
170
+ * - `crux need`, never a raw "NEEDS YOU" note — the escalations view never rendered those, and
171
+ * a typed block is the only thing that reaches a phone.
172
+ * - `crux cancel`, never a bare kill: a killed worker is indistinguishable from a crashed one, and
173
+ * a crash note that cries wolf gets the real ones ignored (see briefs/WORKER-CONTRACT.md).
174
+ * - NEVER EXIT. A lead that returns is a lead that stops listening, silently.
175
+ *
176
+ * What is deliberately NOT here: anybody's projects, private brief directories, or anything about a
177
+ * particular machine. The per-project facts (repo, what the project is, how to spawn a worker) are
178
+ * arguments.
179
+ */
180
+ function renderLeadBrief({ slug, projectName, description, repo, spawn = null, extra = null }) {
181
+ const actor = leadActor(slug);
182
+ const title = projectName || slug;
183
+
184
+ const lines = [
185
+ `# Standing lead: ${slug}`,
186
+ '',
187
+ `You are the durable PROJECT LEAD for **${title}** (crux slug \`${slug}\`), working in \`${repo}\`.`,
188
+ 'You run forever.',
189
+ '',
190
+ ];
191
+
192
+ if (description) lines.push(`${description}`, '');
193
+ if (extra) lines.push(extra, '');
194
+
195
+ lines.push(
196
+ '## Your loop (never exit)',
197
+ '',
198
+ '1. Park on the tail. It prints one JSON line per event you should wake on, and NOTHING',
199
+ ' otherwise — silence is your zero-token parked state, not a reason to poll:',
200
+ '',
201
+ ` crux tail --project ${slug} --topics items,activities,decisions --interval 60 --actor ${actor}`,
202
+ '',
203
+ ' All three topics are required. A human reply arrives as an **activity**, and the answer to a',
204
+ ' `crux need` block arrives as a **decision**. On `items` alone you would hear the question and',
205
+ ' never the answer, and the thread is one-way.',
206
+ '',
207
+ ` \`--actor ${actor}\` registers your live presence, so the dashboard can tell "no items" apart`,
208
+ ' from "the lead is dead" — the failure that once went unnoticed for hours.',
209
+ '',
210
+ '2. **PARK DURABLY — read this twice.** Your harness will kill a long-running foreground command',
211
+ ' when its timeout expires. That is normal, it is not an error, and it is **not the end of your',
212
+ ' job**: the moment the tail returns, START IT AGAIN. Forever.',
213
+ '',
214
+ ' - If you have a persistent monitor/watch facility (Claude Code: the `Monitor` tool), arm it on',
215
+ ' the command above and let it wake you. That is the cheap, correct way to park.',
216
+ ' - Otherwise run the tail with the longest timeout your harness allows (e.g. 600000 ms) and',
217
+ ' re-run it immediately every time it exits.',
218
+ '',
219
+ ' A lead whose tail is not running is not a lead. Nothing is consuming the stream, and from the',
220
+ ' outside that is indistinguishable from "there is no work" — which is exactly how a dead fleet',
221
+ ' goes unnoticed for hours. Your tail being up IS the job.',
222
+ '',
223
+ '3. When a line arrives, handle it (below), then go back to waiting. **Never exit.**',
224
+ '',
225
+ ' Delivery is at-least-once — the cursor is durable, so a restart replays rather than drops. An',
226
+ ' item can therefore repeat. Before acting on an item id, check whether you already logged a',
227
+ ' `crux activity` for it.',
228
+ '',
229
+ '## Handling an item',
230
+ '',
231
+ `- **Ack on receipt, and NAME IT.** The instant a thread arrives:`,
232
+ '',
233
+ ` crux accept <id> --actor ${actor} --title "<2-3 words>" --status "<what you are doing>"`,
234
+ '',
235
+ ' Accept flips the thread out of idle into "working" — do it BEFORE the work, not after, or the',
236
+ ' thread reads as untouched while you are busy on it.',
237
+ '',
238
+ ' `--title` is the thread\'s NAME and it is the row\'s main line from now on. **Nothing generates',
239
+ ' it but you.** There is no model on the server deriving titles from the captured text — you have',
240
+ ' just read the thread and you are about to work it, so you already know what it is called, and a',
241
+ ' thread you accept without `--title` stays untitled forever (its row goes on showing the raw',
242
+ ' captured text, which is what every unaccepted thread shows). Two or three words: "auth timeout",',
243
+ ' "invoice rounding". It is a name, not a summary — the row clips it to one line.',
244
+ '- **Answerable from what you know** → reply as chat:',
245
+ '',
246
+ ` crux activity --item <id> --project ${slug} --actor ${actor} --type note "<your answer>" \\`,
247
+ ' --status "<what is happening now>"',
248
+ '',
249
+ ' Always pass `--item <id>`, or the reply is filed against the project and the item view never',
250
+ ' shows it. `--type note` is what renders as a chat bubble.',
251
+ '- **Real code or task work** → spawn a worker; do not do long work inside your own loop, or you',
252
+ ' stop listening while you do it.',
253
+ );
254
+
255
+ if (spawn) {
256
+ lines.push(
257
+ '',
258
+ ` ${spawn}`,
259
+ '',
260
+ ' The worker runs HEADLESS: it works the thread, exits, and crux posts its final message back',
261
+ ' onto this thread automatically — plus a failure note and a push if it crashes or says nothing.',
262
+ ' Its report wakes you. Relay it, or act on it.',
263
+ );
264
+ } else {
265
+ lines.push(
266
+ '',
267
+ ' NOTE: `crux spawn` needs a Solo project id and this swarm is not running on Solo, so worker',
268
+ ' spawning is unavailable to you. Do the work yourself in the repo above, and keep it short —',
269
+ ' every minute you spend working is a minute you are not listening.',
270
+ );
271
+ }
272
+
273
+ lines.push(
274
+ `- **The human's call** (scope, money, client-facing, irreversible) → raise a typed block:`,
275
+ '',
276
+ ` crux need --item <id> --kind decide|answer|secret|desk|world "<one-line question>" \\`,
277
+ ` --actor ${actor} --status "<why it stopped>"`,
278
+ '',
279
+ ' A typed block is the ONLY thing that reaches their phone. A plain activity does not, and a raw',
280
+ ' "NEEDS YOU:" note is not an escalation — the escalations view cannot render it.',
281
+ '- **Misrouted** → `crux route <id> --project <other-slug> --note "<why>"`.',
282
+ '',
283
+ '## The row is yours to write',
284
+ '',
285
+ 'Every thread shows up as a ROW: a short **title** on the main line, and under it, smaller and in',
286
+ 'grey, a **status line** — one phrase saying what is happening to it right now.',
287
+ '',
288
+ '**You write both. Nothing else does.** The server does not generate them; there is no model on the',
289
+ 'write path re-reading the captured text to guess a title. There does not need to be one — you are',
290
+ 'already an LLM, you have already read the thread, and you already know what you are doing to it.',
291
+ 'Deriving that a second time, from less, would cost more and know less.',
292
+ '',
293
+ 'So the rule is: **you never make a separate call to update the row.** You carry `--status` on the',
294
+ 'request you were already sending. It is one flag on the verbs you already run:',
295
+ '',
296
+ '```',
297
+ 'crux accept <id> --title "<name>" --status "<phrase>" # name it as you pick it up',
298
+ 'crux activity --item <id> --status "<phrase>" "<text>" # ...as you report progress',
299
+ 'crux spawn <id> --title "<name>" --status "worker on it"',
300
+ 'crux need --item <id> --status "blocked on the DNS answer" ...',
301
+ 'crux done <id> --status "merged, closed" # a RESTING phrase — see below',
302
+ '```',
303
+ '',
304
+ 'That is why a status line cannot go stale: you set it AS you work, so it always says what you last',
305
+ 'said it says. A status line the server derived would be wrong the moment you did the next thing.',
306
+ '',
307
+ 'Keep it short and keep it TRUE-NOW ("running the migration", not "will fix the migration"). On',
308
+ '`crux done` the status line should describe REST, not work — "merged, closed", "shipped", "not',
309
+ 'reproducible". Omit it there and crux writes "done" (or "dropped") itself; either way a closed',
310
+ 'thread never sits there still announcing work that stopped hours ago.',
311
+ '',
312
+ '## A closed thread refuses to be worked',
313
+ '',
314
+ '`crux done` is a real ending. A `done` or `dropped` thread will not let you `accept` it, `spawn`',
315
+ 'onto it, or raise a `need` on it — all three exit non-zero and tell you so. This is not pedantry:',
316
+ 'accepting a closed thread used to report success and change nothing anyone could see, so the board',
317
+ 'went on saying **finished** while a worker built on it and reported into a thread nobody reads.',
318
+ '',
319
+ 'Follow-up work on a thread you already closed is one of two things, and you say which:',
320
+ '',
321
+ '```',
322
+ 'crux push --project <slug> "…follow-up, from #<id>" # usually this: new work, new thread',
323
+ 'crux accept <id> --reopen --title "…" --status "…" # or bring the old one back, on purpose',
324
+ 'crux spawn <id> --reopen --project-id <n> --brief … # same flag, if a worker goes straight on',
325
+ '```',
326
+ '',
327
+ '`--reopen` flips the thread back to `routed`, un-archives it, drops the resting status line, and',
328
+ 'writes a **reopened** line onto the thread — so it reads `done` → `reopened` → `accepted`, and',
329
+ 'never "finished, with a worker attached".',
330
+ '',
331
+ '## Hard rules',
332
+ '',
333
+ '- **Finish means CLOSED.** The moment a thread is handled:',
334
+ ' `crux done <id> --note "<why>" --status "<resting phrase>"` (`--drop` for won\'t-do). An unclosed',
335
+ ' thread reads as still-in-progress forever.',
336
+ '- **Do not work a closed thread.** Push a new item, or `accept --reopen` and mean it. See above.',
337
+ '- **Stop a worker with `crux cancel <id> --note "<why>"`, never a bare kill.** A killed worker is',
338
+ ' indistinguishable from a crashed one — the supervisor sees a process that ended having printed',
339
+ ' nothing, and posts "Worker failed" to the thread with a push. `crux cancel` leaves a tombstone',
340
+ ' first, so the death is reported as deliberate. A crash note that cries wolf gets the real ones',
341
+ ' ignored.',
342
+ '- **A blocked worker must block LOUDLY.** Headless means nobody is watching its prompt. If it is',
343
+ ' stuck it must `crux need`; waiting silently is death, not patience.',
344
+ '- **Never post to external services** (Slack, email, GitHub comments). `crux activity` is your',
345
+ ' reporting channel. Client-facing asks are always human-in-the-loop: escalate, never answer.',
346
+ `- **Stay inside \`${repo}\`** (worktrees included). Never commit secrets.`,
347
+ '- **Never exit.** You are a standing lead. Returning is the one thing you may not do.',
348
+ '',
349
+ '---',
350
+ '',
351
+ 'Generated by `crux swarm up`. Edit this file and restart the lead to change the contract.',
352
+ '',
353
+ );
354
+
355
+ return lines.join('\n');
356
+ }
357
+
358
+ /**
359
+ * Decide what `swarm up` starts, and what it refuses.
360
+ *
361
+ * The refusals are not an error path, they are the feature. A project that cannot be brought up is
362
+ * NAMED and EXPLAINED, never silently skipped — a project that quietly falls out of the plan is the
363
+ * exact failure `crux leads` was built to catch, and re-introducing it here would be worse, because
364
+ * `swarm up` is what a stranger runs on their first day and believes.
365
+ *
366
+ * @param {object} input
367
+ * @param {Array<{slug, repo, description, leadless, extra}>} input.candidates resolved projects
368
+ * @param {Array<{pid, slug}>} input.tails every `crux tail` the OS reports, from lib/leads
369
+ * @param {Array<{slug, ref, alive, started_at}>} input.started what crux swarm previously started,
370
+ * and whether that process is still alive per its backend
371
+ * @param {string[]|null} input.only restrict to these slugs (`--project`)
372
+ * @param {number} input.now epoch ms — injected, so a stalled lead is testable without waiting
373
+ */
374
+ function planUp({ candidates, tails, started = [], only = null, now = Date.now() }) {
375
+ const starts = [];
376
+ const refusals = [];
377
+ const skips = [];
378
+
379
+ const bySlug = new Map();
380
+ for (const tail of tails) {
381
+ if (typeof tail.slug !== 'string' || tail.slug === '') continue;
382
+ if (!bySlug.has(tail.slug)) bySlug.set(tail.slug, []);
383
+ bySlug.get(tail.slug).push(tail);
384
+ }
385
+
386
+ const aliveStart = new Map(started.filter((s) => s.alive).map((s) => [s.slug, s]));
387
+
388
+ const wanted = only ? candidates.filter((c) => only.includes(c.slug)) : candidates;
389
+
390
+ // `--project nope` must not quietly do nothing and report success.
391
+ if (only) {
392
+ for (const slug of only) {
393
+ if (!candidates.some((c) => c.slug === slug)) {
394
+ refusals.push({
395
+ slug,
396
+ reason: `no crux project "${slug}" — \`crux projects\` lists the slugs that exist.`,
397
+ });
398
+ }
399
+ }
400
+ }
401
+
402
+ for (const candidate of wanted) {
403
+ const { slug } = candidate;
404
+ const own = bySlug.get(slug) ?? [];
405
+
406
+ if (typeof candidate.leadless === 'string') {
407
+ skips.push({ slug, state: 'leadless', detail: candidate.leadless });
408
+ continue;
409
+ }
410
+
411
+ if (own.length > 1) {
412
+ refusals.push({
413
+ slug,
414
+ reason:
415
+ `${own.length} tails are already on "${slug}" (pids ${own.map((t) => t.pid).join(', ')}) — ` +
416
+ 'the stream is being double-read. Starting a third would make it worse. Investigate first.',
417
+ });
418
+ continue;
419
+ }
420
+
421
+ if (own.length === 1) {
422
+ skips.push({
423
+ slug,
424
+ state: 'covered',
425
+ detail: `pid ${own[0].pid} is already tailing "${slug}". Nothing to do.`,
426
+ });
427
+ continue;
428
+ }
429
+
430
+ // No tail yet — but if WE started this lead and its process is still up, it may simply still be
431
+ // booting. Starting another one here is how you get two tails on one stream.
432
+ const booting = aliveStart.get(slug);
433
+ if (booting) {
434
+ // A lead that HAS tailed before and is not tailing this second is between restarts, not stuck:
435
+ // its harness kills the foreground tail at a timeout and the brief tells it to start another.
436
+ // Never start a second lead here — the live one owns this slug and will bring its own tail
437
+ // back. But never call it green either, because a lead that stops restarting its tail looks
438
+ // exactly like this, and "the tail is down" is the one thing that must not pass unremarked.
439
+ if (booting.everTailed) {
440
+ skips.push({
441
+ slug,
442
+ state: 'tail-down',
443
+ detail:
444
+ `the lead (${booting.ref}) is alive and HAS tailed before, but nothing is tailing "${slug}" ` +
445
+ 'right now — it is most likely between tail restarts. Not starting a second lead. If this ' +
446
+ 'persists, read the lead\'s output: it has stopped restarting its tail.',
447
+ });
448
+ continue;
449
+ }
450
+
451
+ const age = now - Date.parse(booting.started_at ?? '');
452
+ const stalled = Number.isFinite(age) && age > STALL_AFTER_MS;
453
+
454
+ if (!stalled) {
455
+ skips.push({
456
+ slug,
457
+ state: 'starting',
458
+ detail:
459
+ `crux started this lead (${booting.ref}) and its backend still reports it up, but its ` +
460
+ 'tail has not appeared yet. Leaving it alone rather than starting a second one.',
461
+ });
462
+ continue;
463
+ }
464
+
465
+ // Alive, but it has never reached its tail. It is stuck BEFORE the thing it exists to do, and
466
+ // a lead that is not tailing is not a lead. Do not start a second one — it would stall in
467
+ // exactly the same place — and above all do not keep calling this green.
468
+ refusals.push({
469
+ slug,
470
+ state: 'stalled',
471
+ reason:
472
+ `its lead (${booting.ref}) has been up for ${Math.round(age / 60000)}m and has NEVER started ` +
473
+ `its tail — it is stuck before \`crux tail --project ${slug}\`, so nothing is consuming the ` +
474
+ 'stream. The usual cause is the agent sitting at a prompt: Claude Code asks you to trust a ' +
475
+ "directory it has not seen before, and `--dangerously-skip-permissions` does NOT skip that " +
476
+ `one. Read its output, or run \`claude\` once in the repo and accept the folder. Not starting ` +
477
+ `a second lead (it would stall too) — \`crux swarm down --project ${slug}\` clears it.`,
478
+ });
479
+ continue;
480
+ }
481
+
482
+ if (!candidate.repo) {
483
+ refusals.push({
484
+ slug,
485
+ reason:
486
+ `no repo path for "${slug}" — a lead has to run somewhere. Add ` +
487
+ `\`swarm.projects.${slug}.repo\` to the config, or give it a project on your backend.`,
488
+ });
489
+ continue;
490
+ }
491
+
492
+ // The config says the lead works in one place; the backend would run it in another. A lead whose
493
+ // brief names a repo it is not actually standing in will read the wrong code, write to the wrong
494
+ // tree, and report on work it never did — and every symptom of that points somewhere other than
495
+ // the config line that caused it. Say it now, plainly, rather than start it wrong.
496
+ if (candidate.repoConflict) {
497
+ const { backend, declared, project } = candidate.repoConflict;
498
+ refusals.push({
499
+ slug,
500
+ state: 'repo-conflict',
501
+ reason:
502
+ `your backend already has a project for "${slug}"${project ? ` (id ${project.id})` : ''} and ` +
503
+ `runs its agents in ${backend}, but \`swarm.projects.${slug}.repo\` says ${declared}. The ` +
504
+ 'backend decides the working directory, so the lead would run in the first path while its ' +
505
+ 'brief tells it the second — point both at the same place, or drop the backend project and ' +
506
+ 'let `crux swarm up` recreate it from your repo.',
507
+ });
508
+ continue;
509
+ }
510
+
511
+ starts.push(candidate);
512
+ }
513
+
514
+ return { starts, refusals, skips };
515
+ }
516
+
517
+ /**
518
+ * Decide what `swarm down` stops.
519
+ *
520
+ * It stops ONLY what `swarm up` started, and that restraint is the whole design. The machine this
521
+ * runs on may already have a hand-rolled fleet on it, and a `down` that swept every
522
+ * `crux tail` it could find would take out leads it knows nothing about, cannot restart, and was
523
+ * never asked to touch. So a running lead that is not in crux's state file is reported and left
524
+ * alone, loudly, rather than either killed or ignored.
525
+ *
526
+ * Each entry carries its OWN `addresses`, rather than the plan taking one for the whole run: the
527
+ * state file can legitimately hold leads from more than one backend (a user who moved from solo to
528
+ * exec, mid-swarm), and how a lead can be named is a property of the backend that started it — not
529
+ * of the backend that happens to be selected today.
530
+ *
531
+ * @param {object} input
532
+ * @param {Array<{slug, ref, backend, alive, addresses}>} input.started state file, liveness resolved
533
+ * @param {Array<{pid, slug}>} input.tails every `crux tail` the OS reports
534
+ * @param {string[]|null} input.only restrict to these slugs (`--project`)
535
+ */
536
+ function planDown({ started, tails, only = null }) {
537
+ const stops = [];
538
+ const refusals = [];
539
+ const skips = [];
540
+
541
+ const mine = new Set(started.map((s) => s.slug));
542
+ const tailed = new Set(tails.filter((t) => typeof t.slug === 'string').map((t) => t.slug));
543
+ const wanted = only ? started.filter((s) => only.includes(s.slug)) : started;
544
+
545
+ if (only) {
546
+ for (const slug of only) {
547
+ if (!mine.has(slug)) {
548
+ refusals.push({
549
+ slug,
550
+ reason:
551
+ `crux swarm did not start a lead for "${slug}" — it is not in the state file, so there ` +
552
+ 'is nothing here to stop. Refusing to guess at a process crux does not own.',
553
+ });
554
+ }
555
+ }
556
+ }
557
+
558
+ for (const entry of wanted) {
559
+ if (entry.alive) {
560
+ stops.push(entry);
561
+ continue;
562
+ }
563
+
564
+ // The process crux started is gone. Whether that means the LEAD is gone depends entirely on how
565
+ // the backend can name it.
566
+ const stillTailing = tailed.has(entry.slug);
567
+
568
+ if (!stillTailing) {
569
+ skips.push({
570
+ slug: entry.slug,
571
+ state: 'already-gone',
572
+ detail: `its ${entry.backend} process (${entry.ref}) has already exited. Forgetting it.`,
573
+ });
574
+ continue;
575
+ }
576
+
577
+ // A durable handle (a Solo process id, a tmux session name) still addresses a live lead even
578
+ // when the pid crux happened to spawn has moved on. Stop it.
579
+ if (entry.addresses === 'handle') {
580
+ stops.push(entry);
581
+ continue;
582
+ }
583
+
584
+ // A pid is the only handle this backend has, and it is dead — yet the lead is demonstrably still
585
+ // running, because its tail is. This is exactly what a forking template (`tmux new-session -d`)
586
+ // does. crux cannot address it, and reporting an un-stopped lead as stopped is the one outcome
587
+ // worse than failing: the user would walk away believing the swarm is down.
588
+ refusals.push({
589
+ slug: entry.slug,
590
+ reason:
591
+ `the ${entry.backend} process crux started (pid ${entry.ref}) has exited, but "${entry.slug}" ` +
592
+ 'is still being tailed — the lead outlived it. That is what a forking command template ' +
593
+ '(`tmux new-session -d`, `nohup … &`) does: crux is left holding a dead pid. Set ' +
594
+ '`swarm.exec.stop` (e.g. "tmux kill-session -t {name}") so crux can name the lead instead ' +
595
+ 'of the process, then run `crux swarm down` again. Nothing was stopped.',
596
+ });
597
+ }
598
+
599
+ // A tail crux did not start is somebody else's lead. Say so; never kill it.
600
+ if (!only) {
601
+ for (const tail of tails) {
602
+ if (typeof tail.slug !== 'string' || tail.slug === '') continue;
603
+ if (mine.has(tail.slug)) continue;
604
+ skips.push({
605
+ slug: tail.slug,
606
+ state: 'not-ours',
607
+ detail: `pid ${tail.pid} tails "${tail.slug}" but crux swarm did not start it — left running.`,
608
+ });
609
+ }
610
+ }
611
+
612
+ return { stops, refusals, skips };
613
+ }
614
+
615
+ module.exports = {
616
+ DEFAULT_AGENT,
617
+ EXEC_PLACEHOLDERS,
618
+ STALL_AFTER_MS,
619
+ agentCommand,
620
+ execVars,
621
+ leadActor,
622
+ leadName,
623
+ leadPrompt,
624
+ planDown,
625
+ planUp,
626
+ renderLeadBrief,
627
+ renderTemplate,
628
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@coulb/crux-cli",
3
+ "version": "0.1.0",
4
+ "description": "Run a swarm of standing agent leads from the terminal — capture work, route it, brief a worker, and get told when it needs you.",
5
+ "keywords": [
6
+ "crux",
7
+ "agents",
8
+ "ai",
9
+ "claude",
10
+ "swarm",
11
+ "orchestration",
12
+ "cli"
13
+ ],
14
+ "homepage": "https://github.com/DanielCoulbourne/crux-cli#readme",
15
+ "bugs": "https://github.com/DanielCoulbourne/crux-cli/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/DanielCoulbourne/crux-cli.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Daniel Coulbourne",
22
+ "bin": { "crux": "./crux.js" },
23
+ "files": [
24
+ "crux.js",
25
+ "lib/",
26
+ "leads.config.json",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test 'test/**/*.test.js'"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {}
40
+ }