@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/crux.js ADDED
@@ -0,0 +1,2697 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * crux — push items, report activity, and read the Crux feed from any agent.
4
+ *
5
+ * Usage:
6
+ * crux push [--project <slug>] [--source <s>] "<text>"
7
+ * crux push [--project <slug>] [--source <s>] < text-on-stdin
8
+ * crux activity [--project <slug>] [--item <id>] [--actor <name>] [--type <t>]
9
+ * [--image <path>]… [--notify] "<text>"
10
+ * crux decide < decision.json # pipe a Decision (or array) to the decisions API
11
+ * crux feed [--project <slug>] [--status <s>] [--limit <n>] [--json]
12
+ * crux next [--claim] [--source <s>] [--project <slug>] [--actor <name>]
13
+ * crux tail [--unrouted | --project <slug>] [--topics <t,…>] [--interval <s>] [--since <ts>]
14
+ * crux route <id> --project <slug> [--actor <name>] [--note "<why>"]
15
+ * crux done <id> [--note "<why>"] [--actor <name>] # also stops the thread's agent
16
+ * crux spawn <id> --project-id <n> [--brief "<t>"] [--wait] [--live]
17
+ * crux projects [--json]
18
+ * crux leads [--fix] [--json] # reconcile the Solo lead fleet against running tails
19
+ *
20
+ * `crux next` drains the `unmatched` queue, oldest first, and prints ONE item as JSON.
21
+ * It exits 3 when the queue is empty, so a polling loop needs no jq:
22
+ *
23
+ * crux next --claim --source capture --actor god-agent || exit 0 # nothing to do
24
+ *
25
+ * `--claim` takes an exclusive lease first (POST /api/items/{id}/claim), so overlapping
26
+ * pollers can't both act on the same capture. Prefer `--source capture` for consumers with
27
+ * local side effects: it restricts them to human-typed input from behind the web login.
28
+ *
29
+ * Auth/config resolution order:
30
+ * 1. env CRUX_API_URL / CRUX_API_TOKEN
31
+ * 2. ~/.config/crux/config.json { "url": "...", "token": "..." }
32
+ */
33
+ const fs = require('fs');
34
+ const os = require('os');
35
+ const path = require('path');
36
+ const { randomUUID } = require('crypto');
37
+ const { execFileSync, spawn: spawnChild } = require('child_process');
38
+ const discoverLib = require('./lib/discover');
39
+ const leadsLib = require('./lib/leads');
40
+ const swarmLib = require('./lib/swarm');
41
+ const orchestrator = require('./lib/orchestrator');
42
+ const swarmState = require('./lib/swarm-state');
43
+ const backends = require('./lib/backends');
44
+ const { CURSOR_FILE, cursorKey, readCursors, saveCursor } = require('./lib/cursor');
45
+ const { writeTombstone, takeTombstone } = require('./lib/cancel');
46
+ const {
47
+ SOLO_TERMINAL,
48
+ spawnArgs,
49
+ threadName,
50
+ latestSessionId,
51
+ remoteControlUrl,
52
+ resultJson,
53
+ workerReport,
54
+ workerBrief,
55
+ soloTry,
56
+ soloStatus,
57
+ stopSoloProcess,
58
+ } = require('./lib/spawn');
59
+
60
+ /** Distinct from 1 (usage/transport error) so a poller can tell "empty" from "broken". */
61
+ const EXIT_EMPTY = 3;
62
+
63
+ function fail(msg) {
64
+ console.error(`crux: ${msg}`);
65
+ process.exit(1);
66
+ }
67
+
68
+ /**
69
+ * Flags that never take a value. Without this set, the next non-`--` token is read as the flag's
70
+ * value — so `crux activity --notify "shipped it"` would post an activity with NO description and
71
+ * a `notify` of "shipped it". The text a command exists to send must not be swallowable by a
72
+ * boolean sitting in front of it. Boolean-ness is a property of the flag NAME, and no name here
73
+ * takes a value in any other command, so one set covers the whole CLI.
74
+ */
75
+ const BOOLEAN_FLAGS = new Set([
76
+ 'yes',
77
+ 'notify',
78
+ 'claim',
79
+ 'drop',
80
+ 'json',
81
+ 'fix',
82
+ 'dry-run',
83
+ 'unrouted',
84
+ 'help',
85
+ 'no-remote-control',
86
+ 'live',
87
+ 'wait',
88
+ 'reopen',
89
+ ]);
90
+
91
+ /**
92
+ * The statuses that mean the thread is CLOSED — mirroring Item::TERMINAL_STATUSES on the server.
93
+ *
94
+ * The live-work verbs refuse a thread in one of these (`crux accept`, `crux spawn`, `crux need`),
95
+ * because work on a closed thread is work nobody is watching: the board says finished, and the report
96
+ * lands where no one is looking. The refusals that matter are the server's — a guard only in the CLI
97
+ * is a guard a script walks around — but `crux spawn` checks here as well, because by the time the
98
+ * server could say no, the worker is already running.
99
+ */
100
+ const CLOSED_STATUSES = ['done', 'dropped'];
101
+
102
+ function parseArgs(argv) {
103
+ const args = { _: [] };
104
+ for (let i = 0; i < argv.length; i++) {
105
+ if (argv[i].startsWith('--')) {
106
+ const key = argv[i].slice(2);
107
+ const next = argv[i + 1];
108
+ const takesValue = !BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith('--');
109
+ const value = takesValue ? argv[++i] : true;
110
+ // A repeated flag accumulates into an array — `--option A --option B` → ['A','B'] — which is
111
+ // how `crux need` collects a decide block's options. A flag given once stays a scalar, so
112
+ // every existing `typeof args.x === 'string'` check is unaffected.
113
+ if (Object.hasOwn(args, key)) {
114
+ args[key] = Array.isArray(args[key]) ? [...args[key], value] : [args[key], value];
115
+ } else {
116
+ args[key] = value;
117
+ }
118
+ } else {
119
+ args._.push(argv[i]);
120
+ }
121
+ }
122
+ return args;
123
+ }
124
+
125
+ function config() {
126
+ let url = process.env.CRUX_API_URL;
127
+ let token = process.env.CRUX_API_TOKEN;
128
+ if (!url || !token) {
129
+ const file = path.join(os.homedir(), '.config', 'crux', 'config.json');
130
+ if (fs.existsSync(file)) {
131
+ try {
132
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
133
+ url = url || cfg.url;
134
+ token = token || cfg.token;
135
+ } catch {
136
+ fail(`could not parse ${file}`);
137
+ }
138
+ }
139
+ }
140
+ if (!url) fail('no server URL — set CRUX_API_URL or ~/.config/crux/config.json');
141
+ if (!token) fail('no API token — set CRUX_API_TOKEN or ~/.config/crux/config.json');
142
+ return { url: url.replace(/\/$/, ''), token };
143
+ }
144
+
145
+ function readStdin() {
146
+ try {
147
+ return fs.readFileSync(0, 'utf8');
148
+ } catch {
149
+ return '';
150
+ }
151
+ }
152
+
153
+ /** Raw request. Returns the status alongside the body so callers can handle 409 themselves. */
154
+ async function request(method, endpoint, body, extraHeaders) {
155
+ const { url, token } = config();
156
+ const res = await fetch(`${url}${endpoint}`, {
157
+ method,
158
+ headers: {
159
+ 'Content-Type': 'application/json',
160
+ Accept: 'application/json',
161
+ Authorization: `Bearer ${token}`,
162
+ ...extraHeaders,
163
+ },
164
+ body: body !== undefined ? JSON.stringify(body) : undefined,
165
+ });
166
+ const text = await res.text();
167
+ let json;
168
+ try {
169
+ json = text ? JSON.parse(text) : null;
170
+ } catch {
171
+ json = null;
172
+ }
173
+ return { ok: res.ok, status: res.status, json, text };
174
+ }
175
+
176
+ /**
177
+ * Render a failed response as something an agent can act on.
178
+ *
179
+ * A 422 carries `errors: {field: [msg, …]}` alongside `message`, but `message` is only ever the
180
+ * *first* failure — the rest collapse into "(and 2 more errors)". Enumerating the map is the
181
+ * difference between a bare status line and knowing which field to shorten. `message` itself is
182
+ * redundant once the fields are listed, so it's dropped rather than printed twice.
183
+ */
184
+ function describeError(status, json, text) {
185
+ if (json && json.errors && typeof json.errors === 'object') {
186
+ const lines = Object.entries(json.errors).flatMap(([field, msgs]) =>
187
+ (Array.isArray(msgs) ? msgs : [msgs]).map((m) => ` ${field}: ${m}`),
188
+ );
189
+ if (lines.length) return `${status}:\n${lines.join('\n')}`;
190
+ }
191
+ if (json && json.message) return `${status}: ${json.message}`;
192
+ return `${status}: ${text.slice(0, 500)}`;
193
+ }
194
+
195
+ /** Request that treats any non-2xx as fatal. */
196
+ async function api(method, endpoint, body) {
197
+ const { ok, status, json, text } = await request(method, endpoint, body);
198
+ if (!ok) fail(`server returned ${describeError(status, json, text)}`);
199
+ return json;
200
+ }
201
+
202
+ // Text argument comes from the positional args, else stdin.
203
+ function textArg(args, startIndex) {
204
+ const positional = args._.slice(startIndex).join(' ').trim();
205
+ if (positional) return positional;
206
+ const piped = readStdin().trim();
207
+ if (piped) return piped;
208
+ return null;
209
+ }
210
+
211
+ async function push(args) {
212
+ const content = textArg(args, 1);
213
+ if (!content) fail(usageFor('push'));
214
+
215
+ const payload = { content };
216
+ if (typeof args.project === 'string') payload.project = args.project;
217
+ if (typeof args.source === 'string') payload.source = args.source;
218
+
219
+ const item = await api('POST', '/api/items', payload);
220
+ const proj = item.project ? item.project.slug : item.status;
221
+ const reason = item.meta && item.meta.reason ? ` — ${item.meta.reason}` : '';
222
+ console.log(`pushed item #${item.id} → ${proj}${reason}`);
223
+ }
224
+
225
+ /**
226
+ * Read `--item <id>`, the link that turns a loose activity into a message on an item's thread.
227
+ *
228
+ * Validated here rather than trusted, because `parseArgs` yields the boolean `true` for a
229
+ * valueless flag and a raw string otherwise. Both would serialise to a JSON `item_id` the API
230
+ * rejects — but a silent `undefined` would be worse: the activity would post *unlinked*, which
231
+ * is precisely the failure that made 66 of 70 existing lead answers invisible. An unusable
232
+ * `--item` must stop the write, not quietly drop the thread.
233
+ */
234
+ function itemIdArg(raw, command = 'activity') {
235
+ if (typeof raw !== 'string' || !/^\d+$/.test(raw)) fail(usageFor(command));
236
+ const id = Number.parseInt(raw, 10);
237
+ if (id < 1) fail(usageFor(command));
238
+ return id;
239
+ }
240
+
241
+ /**
242
+ * The image formats the API will accept, keyed by extension. Mirrors ActivityImage::ALLOWED_MIMES —
243
+ * raster only, and deliberately no SVG (an SVG is a document that can carry script). The server
244
+ * sniffs the real bytes with finfo rather than trusting what we declare here, so this map is not a
245
+ * security boundary; it exists so a wrong path fails on *this* side of the network with a sentence
246
+ * that names the problem, instead of coming back as a 422 about mimetypes.
247
+ */
248
+ const IMAGE_MIMES = {
249
+ '.jpg': 'image/jpeg',
250
+ '.jpeg': 'image/jpeg',
251
+ '.png': 'image/png',
252
+ '.webp': 'image/webp',
253
+ '.gif': 'image/gif',
254
+ };
255
+
256
+ /** ActivityImage::MAX_KILOBYTES. Rejected outright over this — never truncated. */
257
+ const MAX_IMAGE_BYTES = 10240 * 1024;
258
+
259
+ /**
260
+ * Resolve `--image <path>` (repeatable) into a list of files ready to upload.
261
+ *
262
+ * Every check that can be made locally is made here, and it is deliberately called *before* the
263
+ * activity is posted. Uploading an image needs an activity id, so the write has to come first — which
264
+ * means a typo'd path discovered afterwards would leave a posted, permanently image-less activity on
265
+ * the thread, and no way to attach to it retroactively. Validating up front turns the overwhelmingly
266
+ * common failure (wrong path, wrong format, too big) into a clean exit that has written nothing.
267
+ */
268
+ function imageArgs(raw) {
269
+ if (raw === undefined) return [];
270
+
271
+ const paths = Array.isArray(raw) ? raw : [raw];
272
+
273
+ return paths.map((p) => {
274
+ // `--image` with no value parses to `true`, which would otherwise stringify into a nonsense path.
275
+ if (typeof p !== 'string') fail('--image needs a path to an image file');
276
+
277
+ const file = path.resolve(p);
278
+ if (!fs.existsSync(file)) fail(`no such image: ${p}`);
279
+
280
+ const stat = fs.statSync(file);
281
+ if (!stat.isFile()) fail(`not a file: ${p}`);
282
+ if (stat.size > MAX_IMAGE_BYTES) {
283
+ fail(`image too large: ${p} is ${Math.round(stat.size / 1024)}KB, limit is ${MAX_IMAGE_BYTES / 1024}KB`);
284
+ }
285
+
286
+ const mime = IMAGE_MIMES[path.extname(file).toLowerCase()];
287
+ if (!mime) fail(`unsupported image type: ${p} (accepts ${Object.keys(IMAGE_MIMES).join(', ')})`);
288
+
289
+ return { file, mime, name: path.basename(file) };
290
+ });
291
+ }
292
+
293
+ /**
294
+ * POST one image onto an activity — the same multipart endpoint, and the same `file` field, the phone
295
+ * uses when you attach a photo to a reply.
296
+ *
297
+ * Content-Type is conspicuously *not* set: fetch derives it from the FormData, and the multipart
298
+ * boundary it generates is part of that header. Setting it by hand produces a body the server cannot
299
+ * parse. `request()` is no use here for the same reason — it hard-codes a JSON content type.
300
+ */
301
+ async function uploadActivityImage(activityId, image) {
302
+ const { url, token } = config();
303
+
304
+ const form = new FormData();
305
+ form.append('file', new Blob([fs.readFileSync(image.file)], { type: image.mime }), image.name);
306
+
307
+ const res = await fetch(`${url}/api/activities/${activityId}/images`, {
308
+ method: 'POST',
309
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
310
+ body: form,
311
+ });
312
+
313
+ if (!res.ok) {
314
+ const text = await res.text();
315
+ let json;
316
+ try {
317
+ json = text ? JSON.parse(text) : null;
318
+ } catch {
319
+ json = null;
320
+ }
321
+ throw new Error(describeError(res.status, json, text));
322
+ }
323
+ }
324
+
325
+ /**
326
+ * crux activity [--project <slug>] [--item <id>] [--actor <n>] [--type <t>] [--image <path>]…
327
+ * [--notify] "<text>"
328
+ *
329
+ * `--notify` pushes this one activity to your phone. It is how a lead hands over the thing you
330
+ * asked for mid-thread — NOT how it says it is blocked. A block is `crux need`, and diluting that
331
+ * would wreck the escalations view. Without the flag the write is silent, exactly as before.
332
+ */
333
+ async function activity(args) {
334
+ const description = textArg(args, 1);
335
+ if (!description) fail(usageFor('activity'));
336
+
337
+ // Before the write, so a bad path costs nothing. See imageArgs().
338
+ const images = imageArgs(args.image);
339
+
340
+ const payload = { description };
341
+ if (typeof args.project === 'string') payload.project = args.project;
342
+ if (typeof args.actor === 'string') payload.actor = args.actor;
343
+ if (typeof args.type === 'string') payload.type = args.type;
344
+ if (args.item !== undefined) payload.item_id = itemIdArg(args.item);
345
+ if (args.notify) payload.notify = true;
346
+ // The status line rides the report the lead was already writing — that is the entire design. The
347
+ // API refuses `--status` without `--item` (a status line belongs to a thread, not to a project).
348
+ if (typeof args.status === 'string') payload.status_line = args.status;
349
+
350
+ const a = await api('POST', '/api/activities', payload);
351
+ const onItem = a.item_id ? ` on item #${a.item_id}` : '';
352
+ console.log(`logged activity #${a.id} (${a.actor}: ${a.type})${onItem}${args.notify ? ' — pushed' : ''}`);
353
+
354
+ if (images.length === 0) return;
355
+
356
+ /*
357
+ * Serially, and never fatally. The activity is already on the thread and we have already said so —
358
+ * an image that fails to upload must not be reported as if the message itself were lost, because
359
+ * the agent's correct response to that would be to post it again. So each failure names itself and
360
+ * the run exits non-zero, but what was written stays written and what did upload is still reported.
361
+ */
362
+ let attached = 0;
363
+ const failures = [];
364
+
365
+ for (const image of images) {
366
+ try {
367
+ await uploadActivityImage(a.id, image);
368
+ attached++;
369
+ } catch (error) {
370
+ failures.push(`${image.name}: ${error.message}`);
371
+ }
372
+ }
373
+
374
+ if (attached > 0) {
375
+ console.log(`attached ${attached} ${attached === 1 ? 'image' : 'images'}`);
376
+ }
377
+
378
+ if (failures.length > 0) {
379
+ for (const failure of failures) console.error(`crux: image failed — ${failure}`);
380
+ console.error(`crux: activity #${a.id} was logged; ${failures.length} of ${images.length} images did not attach`);
381
+ process.exit(1);
382
+ }
383
+ }
384
+
385
+ async function decide() {
386
+ const raw = readStdin().trim();
387
+ if (!raw) fail('crux decide reads a Decision JSON (or array) on stdin');
388
+ let doc;
389
+ try {
390
+ doc = JSON.parse(raw);
391
+ } catch {
392
+ fail('stdin is not valid JSON');
393
+ }
394
+ const decisions = Array.isArray(doc) ? doc : [doc];
395
+ for (const d of decisions) {
396
+ const saved = await api('POST', '/api/decisions', d);
397
+ console.log(`decision ${saved.id} (${saved.status})`);
398
+ }
399
+ }
400
+
401
+ async function feed(args) {
402
+ const params = new URLSearchParams();
403
+ if (typeof args.project === 'string') params.set('project', args.project);
404
+ if (typeof args.status === 'string') params.set('status', args.status);
405
+ if (typeof args.limit === 'string') params.set('limit', args.limit);
406
+ const qs = params.toString();
407
+ const items = await api('GET', `/api/items${qs ? `?${qs}` : ''}`);
408
+
409
+ if (args.json) {
410
+ console.log(JSON.stringify(items, null, 2));
411
+ return;
412
+ }
413
+ if (!items.length) {
414
+ console.log('(no items)');
415
+ return;
416
+ }
417
+ for (const item of items) {
418
+ const proj = item.project ? item.project.slug : item.status;
419
+ const when = item.created_at ? new Date(item.created_at).toLocaleString() : '';
420
+ const oneLine = item.content.replace(/\s+/g, ' ').slice(0, 80);
421
+ console.log(`#${item.id} [${proj}] ${oneLine}${item.content.length > 80 ? '…' : ''} ${when}`);
422
+ }
423
+ }
424
+
425
+ /**
426
+ * crux next [--claim] [--source <s>] [--project <slug>] [--actor <name>]
427
+ *
428
+ * Prints exactly one unmatched item as JSON, oldest first (FIFO — drain the
429
+ * longest-waiting capture, not the newest). Exits EXIT_EMPTY when the queue is
430
+ * empty, so a poller is just:
431
+ *
432
+ * while :; do crux next --claim --source capture --actor god-agent | handle; sleep 30; done
433
+ *
434
+ * With --claim the item is leased via POST /api/items/{id}/claim before printing, so two
435
+ * overlapping pollers never both act on it. A 409 means someone else won the race — we
436
+ * simply move on to the next candidate rather than failing.
437
+ */
438
+ async function next(args) {
439
+ const params = new URLSearchParams({ status: 'unmatched', order: 'oldest' });
440
+ if (typeof args.source === 'string') params.set('source', args.source);
441
+ if (typeof args.project === 'string') params.set('project', args.project);
442
+ // Without --claim we only need the head; with it, keep spares to survive lost races.
443
+ params.set('limit', args.claim ? '10' : '1');
444
+
445
+ const items = await api('GET', `/api/items?${params}`);
446
+
447
+ if (!items.length) process.exit(EXIT_EMPTY);
448
+
449
+ if (!args.claim) {
450
+ console.log(JSON.stringify(items[0], null, 2));
451
+ return;
452
+ }
453
+
454
+ const actor = typeof args.actor === 'string' ? args.actor : 'consumer';
455
+
456
+ for (const candidate of items) {
457
+ const res = await request('POST', `/api/items/${candidate.id}/claim`, { actor });
458
+
459
+ if (res.ok) {
460
+ console.log(JSON.stringify(res.json, null, 2));
461
+ return;
462
+ }
463
+ if (res.status !== 409) {
464
+ fail(`server returned ${describeError(res.status, res.json, res.text)}`);
465
+ }
466
+ // 409 — another consumer got there first. Try the next one.
467
+ }
468
+
469
+ // Everything we saw was claimed out from under us: the queue is effectively empty.
470
+ process.exit(EXIT_EMPTY);
471
+ }
472
+
473
+ /**
474
+ * crux route <id> --project <slug> [--actor <name>] [--note "<why>"]
475
+ *
476
+ * Triage verb: reassign an item to a project and re-enter the routed flow.
477
+ */
478
+ async function route(args) {
479
+ const id = args._[1];
480
+ if (!id || typeof args.project !== 'string') fail(usageFor('route'));
481
+
482
+ const payload = { project: args.project };
483
+ if (typeof args.actor === 'string') payload.actor = args.actor;
484
+ if (typeof args.note === 'string') payload.note = args.note;
485
+
486
+ const item = await api('PATCH', `/api/items/${encodeURIComponent(id)}/route`, payload);
487
+ console.log(`routed item #${item.id} → ${item.project ? item.project.slug : '(none)'}`);
488
+ }
489
+
490
+ /**
491
+ * crux done <id> [--note "<why>"] [--actor <name>] [--drop]
492
+ *
493
+ * Triage verb: close an item. `--drop` records it as `dropped` rather than `done` —
494
+ * same terminal state to the queue, different meaning to whoever reads the feed later.
495
+ *
496
+ * Closing a thread also REAPS its agent. A headless worker has already exited by the time anyone
497
+ * runs this (its exit is what produced the report on the thread), but a `--live` one never exits on
498
+ * its own — and seven of them were found idling against threads that had been closed hours earlier,
499
+ * two still holding instructions nobody sent. An agent's life is bounded by its thread's, not by a
500
+ * human noticing, so the close is what kills it.
501
+ *
502
+ * Best-effort, and deliberately after the close: a thread whose process is already gone (or whose
503
+ * Solo is not there to ask) still closes cleanly. Reaping a corpse is a no-op, not an error.
504
+ */
505
+ async function done(args) {
506
+ const id = args._[1];
507
+ if (!id) fail(usageFor('done'));
508
+
509
+ // Two different things called "status", and they must not be confused. The payload's `status` is the
510
+ // DISPOSITION (done | dropped) and comes from `--drop`. `--status` is the human status LINE, and it
511
+ // maps to `status_line` — here it is the thread's RESTING phrase ("merged, closed"), because a closed
512
+ // thread should say something true about being closed rather than keep announcing work that stopped.
513
+ // Omit it and the server writes the disposition itself; it never leaves the old line standing.
514
+ const payload = { status: args.drop ? 'dropped' : 'done' };
515
+ if (typeof args.note === 'string') payload.note = args.note;
516
+ if (typeof args.actor === 'string') payload.actor = args.actor;
517
+ if (typeof args.status === 'string') payload.status_line = args.status;
518
+
519
+ const item = await api('PATCH', `/api/items/${encodeURIComponent(id)}`, payload);
520
+ console.log(`item #${item.id} → ${item.status}`);
521
+
522
+ const procId = item.agent_process_id;
523
+ if (procId === null || procId === undefined) return;
524
+
525
+ const outcome = stopSoloProcess(procId);
526
+ if (outcome === 'stopped') console.log(`stopped its agent (Solo process ${procId})`);
527
+ else if (outcome === 'already-gone') console.log(`its agent (Solo process ${procId}) had already exited`);
528
+ else console.error(`crux: could not reach Solo to stop process ${procId} — check it by hand`);
529
+ }
530
+
531
+ /**
532
+ * crux cancel <id> [--note "<why>"] [--actor <name>] [--process <n>]
533
+ *
534
+ * Stop a thread's worker ON PURPOSE, and have that not look like a crash.
535
+ *
536
+ * The thread stays open — this is the verb for "the brief is now wrong": you change your mind
537
+ * mid-flight, the lead stops the worker, re-briefs, and spawns a new one. `crux done` is the other
538
+ * thing (the thread is finished, and its worker dies with it).
539
+ *
540
+ * A lead used to do this with `solo processes stop`, and the supervisor — which cannot see intent,
541
+ * only an ended process with an empty buffer — posted "**Worker failed** … it crashed, was killed,
542
+ * or never started" and pushed it to your phone. Nothing had failed. Every mid-flight re-brief
543
+ * did it, and a crash note that cries wolf is worse than none, because the real ones stop being read.
544
+ *
545
+ * So the kill leaves a note. The ORDER below is the whole mechanism and is not incidental:
546
+ *
547
+ * 1. TOMBSTONE, first, and fatally if it fails {@see ../lib/cancel}. The supervisor polls every 5s;
548
+ * a tombstone written after the kill loses that race often enough to report a crash anyway. And
549
+ * a kill whose tombstone never landed is a kill that WILL be reported as a crash — so if we
550
+ * cannot write it, we stop here, having killed nothing, rather than proceed and lie about why.
551
+ * 2. STOP the process. The supervisor is already awake; it sees the exit, finds the tombstone, and
552
+ * reports a cancellation instead of a death {@see workerReport}.
553
+ * 3. NOTE on the thread, plainly, with no push. Someone reading the thread later needs to know why
554
+ * the worker stopped mid-sentence — but a deliberate act by a lead is not an escalation, and
555
+ * putting it on your phone is the noise this ticket exists to remove.
556
+ */
557
+ async function cancel(args) {
558
+ const id = args._[1];
559
+ if (!id) fail(usageFor('cancel'));
560
+
561
+ const itemId = itemIdArg(String(id), 'cancel');
562
+ const item = await api('GET', `/api/items/${itemId}`);
563
+
564
+ // `--process` is the escape hatch for a worker the item never recorded (a hand-spawned agent, an
565
+ // item whose accept never landed). Without either, there is nothing to cancel and nothing to
566
+ // excuse — and writing a tombstone for a process we cannot name would be a tombstone that excuses
567
+ // nobody, which is worse than a clean refusal.
568
+ const procId =
569
+ typeof args.process === 'string' ? args.process : (item.agent_process_id ?? null);
570
+ if (procId === null || procId === undefined) {
571
+ fail(`item #${itemId} has no agent process recorded — pass --process <n> if you know it`);
572
+ }
573
+
574
+ const note = typeof args.note === 'string' ? args.note : null;
575
+ const actor = typeof args.actor === 'string' ? args.actor : 'lead';
576
+
577
+ // Before the kill. Fatal if it fails — see above.
578
+ try {
579
+ writeTombstone({ item: itemId, process: procId, note, actor });
580
+ } catch (e) {
581
+ fail(`could not record the cancellation (${e.message}) — refusing to kill the worker, because ` +
582
+ 'its supervisor would then report a crash that did not happen');
583
+ }
584
+
585
+ const outcome = stopSoloProcess(procId);
586
+ if (outcome === 'stopped') console.log(`cancelled the worker on thread #${itemId} (Solo process ${procId})`);
587
+ else if (outcome === 'already-gone') console.log(`its worker (Solo process ${procId}) had already exited`);
588
+ else console.error(`crux: could not reach Solo to stop process ${procId} — check it by hand`);
589
+
590
+ await api('POST', '/api/activities', {
591
+ item_id: itemId,
592
+ actor,
593
+ type: 'note',
594
+ description: `worker cancelled by lead: ${note ?? 'no reason given'}`,
595
+ });
596
+ console.log(`noted the cancellation on thread #${itemId}`);
597
+ }
598
+
599
+ const NEED_KINDS = ['decide', 'answer', 'secret', 'desk', 'world'];
600
+
601
+ /**
602
+ * crux need --item <id> --kind decide|answer|secret|desk|world [flags] ["<question>"]
603
+ *
604
+ * Raise a needs-you block: the typed successor to the raw "NEEDS YOU: …" activity that the
605
+ * escalations view never rendered. A block is what makes a thread WAIT on you, so this IS the
606
+ * escalation verb — an open block is an escalation, no separate table to fall out of sync.
607
+ *
608
+ * The forcing function lives server-side and is surfaced here: a `decide` block needs `--option`s;
609
+ * every other kind needs a one-line framed question (positional text or stdin). A block may not
610
+ * wait on raw dumped context.
611
+ *
612
+ * crux need --item 8 --kind decide --option Hourly --option "Twice daily" --recommendation "Twice daily"
613
+ * crux need --item 8 --kind answer "What does 'term' mean for recurring subs?"
614
+ * crux need --item 8 --kind secret "Slack app token (xapp-…)" --who an-agent
615
+ */
616
+ async function need(args) {
617
+ const itemId = itemIdArg(args.item, 'need');
618
+ const kind = typeof args.kind === 'string' ? args.kind : null;
619
+ if (kind === null || !NEED_KINDS.includes(kind)) fail(usageFor('need'));
620
+
621
+ const payload = { kind };
622
+
623
+ const question = textArg(args, 1);
624
+ if (question) payload.question = question;
625
+ // --option is repeatable; a single one arrives as a scalar, several as an array.
626
+ if (args.option !== undefined) {
627
+ payload.options = Array.isArray(args.option) ? args.option : [args.option];
628
+ }
629
+ if (typeof args.recommendation === 'string') payload.recommendation = args.recommendation;
630
+ if (typeof args.where === 'string') payload.location = args.where; // phone-ok | desk-required
631
+ if (typeof args.when === 'string') payload.timing = args.when; // now | at-time | when-at-desk
632
+ if (typeof args.who === 'string') payload.waiting_on = args.who; // nobody | an-agent | an-external-person
633
+ if (typeof args.actor === 'string') payload.actor = args.actor;
634
+ // Blocking IS a status change: the thread just stopped, and the row should say so in the same breath.
635
+ if (typeof args.status === 'string') payload.status_line = args.status;
636
+
637
+ const item = await api('POST', `/api/items/${itemId}/needs`, payload);
638
+ const raised = item.open_need ? item.open_need.kind : kind;
639
+ console.log(`raised ${raised} block on item #${item.id} — now ${item.state}`);
640
+ }
641
+
642
+ /**
643
+ * crux accept <id> [--title "<short title>"] [--status "<phrase>"] [--actor <name>]
644
+ * [--agent-name <n>] [--agent-session-id <id>]
645
+ *
646
+ * Mark a thread accepted: who picked it up and when. With no open needs-you block that is what
647
+ * makes the thread read "working", and `accepted_at` powers the app's "time since pickup".
648
+ *
649
+ * `--title` is where a thread gets its NAME, and accept is the right moment for it: the lead has just
650
+ * read the thread and is about to work it, so it knows the answer already. Nothing on the server
651
+ * generates a title — a lead that never passes `--title` leaves the thread untitled forever, and the
652
+ * row goes on showing the captured text exactly as it does today. Keep it to a couple of words; it is
653
+ * a name, not a summary, and the row clips it to one line.
654
+ *
655
+ * `--agent-name` bakes the per-thread Claude session name spawned with `claude -n`; `--agent-session-id`
656
+ * stores the agent's local transcript uuid; `--remote-control-url` stores its claude.ai session — the
657
+ * one that is actually openable, and the one the thread view links to. `crux done` clears acceptance.
658
+ *
659
+ * A CLOSED thread (done | dropped) refuses to be accepted, and `--reopen` is how you mean it anyway.
660
+ * The server enforces both — see ItemController::accept — so this is not a CLI-side courtesy that a
661
+ * script can route around; the flag only carries the intent down.
662
+ */
663
+ async function accept(args) {
664
+ const id = args._[1];
665
+ if (!id) fail(usageFor('accept'));
666
+
667
+ const payload = {};
668
+ if (args.reopen === true) payload.reopen = true;
669
+ if (typeof args.actor === 'string') payload.actor = args.actor;
670
+ if (typeof args['agent-name'] === 'string') payload.agent_name = args['agent-name'];
671
+ if (typeof args['agent-session-id'] === 'string') payload.agent_session_id = args['agent-session-id'];
672
+ if (typeof args['remote-control-url'] === 'string') {
673
+ payload.remote_control_url = args['remote-control-url'];
674
+ }
675
+ if (typeof args.title === 'string') payload.title = args.title;
676
+ if (typeof args.status === 'string') payload.status_line = args.status;
677
+
678
+ const item = await api('POST', `/api/items/${encodeURIComponent(id)}/accept`, payload);
679
+ // `reopened_from` is only on the payload when the thread really was closed — so a `--reopen` on an
680
+ // already-open thread says nothing, rather than announcing a resurrection that never happened.
681
+ if (item.reopened_from) {
682
+ console.log(`reopened item #${item.id} — it was ${item.reopened_from}, and is now ${item.status}`);
683
+ }
684
+ const by = item.accepted_by ? ` by ${item.accepted_by}` : '';
685
+ console.log(`accepted item #${item.id}${by} — now ${item.state}`);
686
+ }
687
+
688
+ /** How long `crux spawn` waits for the agent to register with claude.ai before giving up on a link. */
689
+ const RC_TIMEOUT_MS = 90_000;
690
+ const RC_POLL_MS = 1_500;
691
+
692
+ /**
693
+ * Wait for a just-spawned agent to announce its Remote Control session, and return the URL.
694
+ *
695
+ * Remote Control connects a few seconds AFTER the process starts (it has to reach claude.ai), and it
696
+ * announces itself exactly once, as a toast drawn into the TUI — so there is nothing to look up and
697
+ * nothing on disk to read. We poll the agent's raw pty buffer until the toast shows up.
698
+ *
699
+ * Best-effort by contract: an agent with no claude.ai OAuth, or a Solo that will not cough up its
700
+ * buffer, means the thread has no link — never that the spawn failed. Returns null on timeout.
701
+ */
702
+ async function waitForRemoteControlUrl(procId, timeoutMs = RC_TIMEOUT_MS) {
703
+ if (procId === undefined || procId === null) return null;
704
+ const deadline = Date.now() + timeoutMs;
705
+
706
+ while (Date.now() < deadline) {
707
+ let raw = null;
708
+ try {
709
+ raw = execFileSync(
710
+ process.env.CRUX_SOLO_BIN || 'solo',
711
+ ['processes', 'output', String(procId), '--raw', '--lines', '2000', '--json'],
712
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
713
+ );
714
+ } catch {
715
+ // The process may not have a buffer yet. Keep waiting rather than treating this as terminal.
716
+ }
717
+
718
+ if (raw) {
719
+ let text = null;
720
+ try {
721
+ text = JSON.parse(raw)?.data?.text ?? null;
722
+ } catch {
723
+ text = null;
724
+ }
725
+ const url = remoteControlUrl(text);
726
+ if (url) return url;
727
+ }
728
+
729
+ await sleep(RC_POLL_MS);
730
+ }
731
+ return null;
732
+ }
733
+
734
+ /**
735
+ * crux spawn <id> --project-id <n> [--tool-id 3] [--brief "<text>"] [--actor <name>] [--wait]
736
+ * [--live [--no-remote-control] [--rc-timeout <seconds>]]
737
+ *
738
+ * Start the worker for a thread — and make sure it REPORTS and DIES, neither of which is left to the
739
+ * agent to remember. Two modes; reaping is structural in both, but bound to a different event.
740
+ *
741
+ * HEADLESS (default). `claude -p --output-format json` works the thread, prints one json object, and
742
+ * exits. Its own exit is the reap. A detached supervisor ({@see supervise}) outlives this command,
743
+ * waits for that exit, and posts the agent's final message to the thread as a note — success or
744
+ * failure, and a failure loudly. `crux spawn` itself returns as soon as the agent is up, because the
745
+ * lead that spawned it has other threads to run; `--wait` keeps the supervisor in the foreground
746
+ * instead (and exits non-zero if the worker failed), which is what makes it usable in a script.
747
+ *
748
+ * LIVE (`--live`). Interactive, with Remote Control on, so you can open the session from the
749
+ * Claude app and talk to it — the mode that shipped today, unchanged. Headless and Remote Control
750
+ * are mutually exclusive (`-p --remote-control` mints no URL), so this mode alone gets a link, and
751
+ * this mode alone cannot reap itself: an interactive agent never exits. Its life is instead bounded
752
+ * by its THREAD's, so the Solo process id goes on the item and `crux done` stops it {@see done}.
753
+ *
754
+ * Spawning is fast; LEARNING the URL is not — the agent has to reach claude.ai first, which is why
755
+ * live waits up to `--rc-timeout` seconds. `--no-remote-control` skips both the flag and the wait.
756
+ */
757
+ async function spawn(args) {
758
+ const id = args._[1];
759
+ const projectId = args['project-id'];
760
+ if (!id || typeof projectId !== 'string') fail(usageFor('spawn'));
761
+
762
+ const live = args.live === true;
763
+ const item = await api('GET', `/api/items/${encodeURIComponent(id)}`);
764
+
765
+ // Refuse a CLOSED thread BEFORE anything is started. The accept at the end of this function would
766
+ // be refused by the server anyway (ItemController::accept), but by then the worker is already up —
767
+ // and an agent whose accept was rejected is exactly the orphan this guard exists to prevent: it
768
+ // works, it reports, and its report lands on a thread the board says is finished, where nobody is
769
+ // looking for it. So the check happens here, on the item we have already fetched, at zero cost.
770
+ if (CLOSED_STATUSES.includes(item.status)) {
771
+ if (args.reopen !== true) {
772
+ fail(
773
+ `thread #${id} is ${item.status} — a worker on a closed thread reports where nobody is looking.\n` +
774
+ ` pass --reopen to bring it back and spawn onto it, or push a new item for the follow-up work.`,
775
+ );
776
+ }
777
+ console.log(`thread #${id} is ${item.status} — reopening it, then spawning.`);
778
+ }
779
+
780
+ const subject = (item.content || '').replace(/\s+/g, ' ').trim();
781
+ const name = threadName(id, subject);
782
+ const remoteControl = live && args['no-remote-control'] !== true;
783
+
784
+ const task =
785
+ typeof args.brief === 'string'
786
+ ? args.brief
787
+ : "Read the thread (crux feed / the item's activities) and work it.";
788
+ const brief = workerBrief({ id, task, live });
789
+
790
+ // Headless picks its own session id rather than guessing at it afterwards, so the `claude --resume`
791
+ // handle we store is a fact. Live still learns it from disk: it is the mode that shipped today and
792
+ // `--session-id` is unproven against an interactive pty — not a risk worth taking for a nicety.
793
+ const sessionId = live ? null : randomUUID();
794
+
795
+ const argv = spawnArgs({
796
+ projectId,
797
+ toolId: typeof args['tool-id'] === 'string' ? args['tool-id'] : 3,
798
+ id,
799
+ subject,
800
+ brief,
801
+ live,
802
+ sessionId,
803
+ remoteControl,
804
+ });
805
+
806
+ // Actually spawn it (Solo speaks {ok, data}); a broken CLI is fatal here.
807
+ const proc = solo(argv);
808
+ const procId = proc?.process?.id ?? proc?.process ?? null;
809
+
810
+ let url = null;
811
+ if (remoteControl) {
812
+ console.log(`spawned "${name}" — waiting for Remote Control to connect…`);
813
+ const rcTimeoutMs =
814
+ typeof args['rc-timeout'] === 'string' ? Number(args['rc-timeout']) * 1000 : RC_TIMEOUT_MS;
815
+ url = await waitForRemoteControlUrl(procId, rcTimeoutMs);
816
+ }
817
+
818
+ const acceptPayload = {
819
+ actor: typeof args.actor === 'string' ? args.actor : 'dispatch',
820
+ agent_name: name,
821
+ };
822
+ // Spawning IS accepting, so a spawn that means to reopen says so on the same call — the server does
823
+ // the resurrection there, and the thread is routed + accepted + worked in one write rather than in
824
+ // two that could half-land. Harmless on an open thread: the server ignores it.
825
+ if (args.reopen === true) acceptPayload.reopen = true;
826
+ // The process id is what `crux done` reaps, so it has to be on the item BEFORE the agent can
827
+ // outlive its thread — that is the whole mechanism, not bookkeeping.
828
+ if (procId !== null) acceptPayload.agent_process_id = procId;
829
+ const resumeId = sessionId ?? latestSessionId();
830
+ if (resumeId) acceptPayload.agent_session_id = resumeId;
831
+ if (url) acceptPayload.remote_control_url = url;
832
+ // Spawning IS accepting — this posts to the same endpoint `crux accept` does — so a lead that hands
833
+ // a thread straight to a worker names it here instead, and never has to make a separate accept call
834
+ // just to give the row a title. `--status` here is the handoff itself ("worker on it").
835
+ if (typeof args.title === 'string') acceptPayload.title = args.title;
836
+ if (typeof args.status === 'string') acceptPayload.status_line = args.status;
837
+ await api('POST', `/api/items/${encodeURIComponent(id)}/accept`, acceptPayload);
838
+
839
+ const where = procId !== null ? ` (Solo process ${procId})` : '';
840
+ console.log(`spawned "${name}"${where}${resumeId ? ` — session ${resumeId}` : ''}`);
841
+
842
+ if (live) {
843
+ if (url) console.log(`open on your phone: ${url}`);
844
+ else if (remoteControl) console.log('no Remote Control link — the agent never announced one (it still spawned).');
845
+ console.log(`it will not exit on its own — \`crux done ${id}\` stops it.`);
846
+ return;
847
+ }
848
+
849
+ if (procId === null) {
850
+ fail('Solo did not report a process id — there is nothing to supervise, so the worker would report nothing');
851
+ }
852
+
853
+ const superviseArgs = [
854
+ 'supervise',
855
+ String(id),
856
+ '--process',
857
+ String(procId),
858
+ '--project-id',
859
+ String(projectId),
860
+ ];
861
+
862
+ if (args.wait) {
863
+ console.log('waiting for the worker to finish — its report will be posted to the thread…');
864
+ return supervise(parseArgs(superviseArgs));
865
+ }
866
+
867
+ // Detached, so the lead that spawned this worker is free the moment it is up. The supervisor is
868
+ // now the only thing that will speak for the agent when it dies, which is why it must not be a
869
+ // child of this process — `crux spawn` is about to exit.
870
+ const child = spawnChild(process.execPath, [__filename, ...superviseArgs], {
871
+ detached: true,
872
+ stdio: 'ignore',
873
+ });
874
+ child.unref();
875
+
876
+ console.log(`reporting to thread #${id} when it exits (supervisor pid ${child.pid}).`);
877
+ }
878
+
879
+ /** How often the supervisor asks Solo whether the worker is still alive. */
880
+ const SUPERVISE_POLL_MS = 5_000;
881
+
882
+ /** How long a headless worker may run before the harness declares it hung and says so on the thread. */
883
+ const SUPERVISE_TIMEOUT_MS = 8 * 60 * 60 * 1000;
884
+
885
+ /**
886
+ * crux supervise <id> --process <n> [--project-id <n>] [--actor <name>] [--timeout <seconds>]
887
+ *
888
+ * Wait for a headless worker to exit, then post its report to the thread. This is the mechanism that
889
+ * replaces "the agent remembers to report": the agent's final message is lifted onto the thread by
890
+ * the harness, and a worker that dies without one is announced as failed rather than vanishing.
891
+ *
892
+ * `crux spawn` starts this for you, detached — you should rarely type it. But it is a real command,
893
+ * and deliberately so: the supervisor is the one thing whose death would restore the old silence, so
894
+ * it has to be re-attachable. Solo keeps a dead process's output buffer, so re-running this against
895
+ * an already-exited worker still recovers its report and posts it, however late.
896
+ *
897
+ * Exits non-zero when the worker failed, so `crux spawn --wait` is usable in a script.
898
+ */
899
+ async function supervise(args) {
900
+ const id = args._[1];
901
+ const procId = args.process;
902
+ if (!id || typeof procId !== 'string') fail(usageFor('supervise'));
903
+
904
+ const itemId = itemIdArg(id, 'supervise');
905
+ const projectId = typeof args['project-id'] === 'string' ? args['project-id'] : null;
906
+ const timeoutMs =
907
+ typeof args.timeout === 'string' ? Number(args.timeout) * 1000 : SUPERVISE_TIMEOUT_MS;
908
+
909
+ // Poll until the worker is no longer running. A process Solo has forgotten entirely counts as over
910
+ // — we still go and read whatever buffer it left behind rather than assume it said nothing. A Solo
911
+ // we cannot reach counts as nothing at all: keep waiting {@see soloStatus}.
912
+ const deadline = Date.now() + timeoutMs;
913
+ let status = null;
914
+ let timedOut = true;
915
+
916
+ while (Date.now() < deadline) {
917
+ const seen = soloStatus(procId, projectId);
918
+
919
+ if (seen !== null) {
920
+ status = seen;
921
+ if (status === 'gone' || SOLO_TERMINAL.has(status)) {
922
+ timedOut = false;
923
+ break;
924
+ }
925
+ }
926
+
927
+ await sleep(SUPERVISE_POLL_MS);
928
+ }
929
+
930
+ const raw = soloTry(['processes', 'output', String(procId), '--raw', '--lines', '5000'])?.text ?? null;
931
+
932
+ // Was this silence on purpose? Only a tombstone for THIS worker — this item, this Solo process —
933
+ // can say so, and only once. No tombstone means the worker died, which is still the loud answer.
934
+ const cancelled = timedOut ? null : takeTombstone(itemId, procId);
935
+ const report = workerReport({ result: resultJson(raw), status, timedOut, cancelled });
936
+
937
+ const actor = typeof args.actor === 'string' ? args.actor : `thread-${id}`;
938
+ await postReport(itemId, actor, report);
939
+
940
+ const ending = report.cancelled ? 'cancelled' : report.ok ? 'done' : 'FAILED';
941
+ console.log(`reported to thread #${itemId} (${ending})`);
942
+ if (!report.ok) process.exit(1);
943
+ }
944
+
945
+ /**
946
+ * Post a worker's report to its thread, retrying a transient failure.
947
+ *
948
+ * The only step with nothing behind it: the agent is gone, its final message exists nowhere but in
949
+ * this process's memory, and a dropped POST is precisely the silence the ticket exists to kill. So
950
+ * unlike everything else the supervisor does, this one does not shrug off a failure — it retries,
951
+ * and if it still cannot write it says so on stderr and exits non-zero rather than pretending.
952
+ */
953
+ async function postReport(itemId, actor, report, attempts = 4) {
954
+ const payload = {
955
+ item_id: itemId,
956
+ actor,
957
+ type: 'note',
958
+ description: report.text,
959
+ };
960
+ // A dead worker pushes. A note nobody is told about is just a quieter silence.
961
+ if (report.notify) payload.notify = true;
962
+
963
+ for (let attempt = 1; ; attempt++) {
964
+ try {
965
+ await api('POST', '/api/activities', payload);
966
+ return;
967
+ } catch (error) {
968
+ if (attempt >= attempts) {
969
+ fail(`could not post the worker's report to thread #${itemId}: ${error.message}`);
970
+ }
971
+ console.error(`crux: posting the report failed (${error.message}) — retrying…`);
972
+ await sleep(2_000 * attempt);
973
+ }
974
+ }
975
+ }
976
+
977
+ const TAIL_TOPICS = ['items', 'activities', 'decisions'];
978
+ const TAIL_MAX_BACKOFF_MS = 300_000;
979
+
980
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
981
+
982
+ /**
983
+ * Write one event and wait for it to actually leave the process.
984
+ *
985
+ * A Monitor consumer is woken by the line, not by the poll — if Node parks the write in its
986
+ * internal buffer (which it does as soon as stdout is a pipe rather than a tty), the event is
987
+ * silently swallowed until some later write happens to flush it. Awaiting `drain` is what
988
+ * makes "one flushed line per event" true.
989
+ */
990
+ function emit(record) {
991
+ return new Promise((resolve) => {
992
+ if (process.stdout.write(`${JSON.stringify(record)}\n`)) resolve();
993
+ else process.stdout.once('drain', resolve);
994
+ });
995
+ }
996
+
997
+
998
+ /**
999
+ * Who this tail says it is, when `--actor` is not given.
1000
+ *
1001
+ * Deliberately NOT `lead-<slug>`. A human running `crux tail --project acme` to debug would
1002
+ * otherwise adopt the dead lead's identity and light its status dot green — manufacturing the
1003
+ * exact false "everything is fine" that the presence signal exists to destroy. A machine-local
1004
+ * name is honest: someone is listening, and it isn't the lead.
1005
+ */
1006
+ function defaultActor() {
1007
+ let user = 'unknown';
1008
+ try {
1009
+ user = os.userInfo().username;
1010
+ } catch {
1011
+ /* no passwd entry (container); fall through */
1012
+ }
1013
+ return `${user}@${os.hostname()}`;
1014
+ }
1015
+
1016
+ /**
1017
+ * crux tail [--unrouted | --project <slug>] [--topics <t,…>] [--interval <s>] [--since <ts>]
1018
+ *
1019
+ * A long-running watcher that prints ONE single-line JSON record per new event, oldest
1020
+ * first, flushed, and prints NOTHING otherwise. Silence always means "no new input" —
1021
+ * transient API failures go to stderr and are retried with backoff, never to stdout.
1022
+ *
1023
+ * Each poll announces the actor and its poll interval (`X-Crux-Actor` / `X-Crux-Interval`), so
1024
+ * the server can render a listener status dot whose freshness threshold tracks *this* tail's
1025
+ * cadence rather than a hardcoded number of seconds. The identifier is self-asserted and
1026
+ * advisory — see {@see \App\Http\Controllers\Api\UpdatesController}.
1027
+ *
1028
+ * Arm it under Claude Code's Monitor and an agent is woken push-style by real Crux input
1029
+ * instead of burning tokens on an agentic poll:
1030
+ *
1031
+ * crux tail --unrouted # dispatch: the untriaged queue
1032
+ * crux tail --project crux # a project lead: its own feed
1033
+ *
1034
+ * The cursor is durable (~/.config/crux/tail-cursor.json), so a restart resumes where it
1035
+ * left off. Delivery is at-least-once — the cursor only advances over records already
1036
+ * printed — so a consumer that acts on events must be idempotent.
1037
+ *
1038
+ * --interval defaults to 60s. crux-api runs with hibernation on, and a poll faster than
1039
+ * that keeps the instance permanently awake for no benefit; events are not latency-critical.
1040
+ */
1041
+ async function tail(args) {
1042
+ if (args.unrouted && typeof args.project === 'string') {
1043
+ fail('crux tail: --unrouted and --project are different queues; pass only one');
1044
+ }
1045
+
1046
+ const topics = typeof args.topics === 'string' ? args.topics : 'items';
1047
+ for (const topic of topics.split(',')) {
1048
+ if (!TAIL_TOPICS.includes(topic.trim())) {
1049
+ fail(`crux tail: unknown topic "${topic.trim()}" (valid: ${TAIL_TOPICS.join(', ')})`);
1050
+ }
1051
+ }
1052
+
1053
+ // --unrouted is the alias the triage consumer arms: everything the router couldn't place.
1054
+ const status = args.unrouted ? 'unmatched' : typeof args.status === 'string' ? args.status : null;
1055
+ const project = typeof args.project === 'string' ? args.project : null;
1056
+
1057
+ const intervalSeconds = Math.max(1, Number.parseInt(args.interval, 10) || 60);
1058
+ const interval = intervalSeconds * 1000;
1059
+
1060
+ const actor = typeof args.actor === 'string' && args.actor.trim() !== '' ? args.actor.trim() : defaultActor();
1061
+ const presence = { 'X-Crux-Actor': actor, 'X-Crux-Interval': String(intervalSeconds) };
1062
+
1063
+ const key = cursorKey({ url: config().url, topics, project, status });
1064
+ // --since overrides a stored cursor; otherwise a first run starts from now (the server
1065
+ // returns a cursor pinned to the newest row and replays no history).
1066
+ let cursor = typeof args.since === 'string' ? args.since : readCursors()[key];
1067
+
1068
+ let failures = 0;
1069
+ let running = true;
1070
+ for (const signal of ['SIGINT', 'SIGTERM']) {
1071
+ process.on(signal, () => {
1072
+ running = false;
1073
+ process.exit(0);
1074
+ });
1075
+ }
1076
+
1077
+ while (running) {
1078
+ try {
1079
+ const params = new URLSearchParams({ topics });
1080
+ if (project) params.set('project', project);
1081
+ if (status) params.set('status', status);
1082
+ if (cursor) params.set('since', cursor);
1083
+
1084
+ const res = await request('GET', `/api/updates?${params}`, undefined, presence);
1085
+
1086
+ if (!res.ok) {
1087
+ // 4xx won't fix itself — a bad cursor or a revoked token means stop, not spin.
1088
+ if (res.status >= 400 && res.status < 500) {
1089
+ fail(`server returned ${describeError(res.status, res.json, res.text)}`);
1090
+ }
1091
+ throw new Error(`server returned ${res.status}`);
1092
+ }
1093
+
1094
+ for (const record of res.json.records || []) await emit(record);
1095
+
1096
+ // Persisted only after the lines are out: a crash in between replays, never drops.
1097
+ if (res.json.next_cursor && res.json.next_cursor !== cursor) {
1098
+ cursor = res.json.next_cursor;
1099
+ saveCursor(key, cursor);
1100
+ }
1101
+ failures = 0;
1102
+ } catch (e) {
1103
+ failures++;
1104
+ console.error(`crux tail: ${e.message} (retry ${failures})`);
1105
+ }
1106
+
1107
+ const backoff = failures > 0 ? Math.min(interval * 2 ** (failures - 1), TAIL_MAX_BACKOFF_MS) : interval;
1108
+ await sleep(backoff);
1109
+ }
1110
+ }
1111
+
1112
+ async function projects(args) {
1113
+ const list = await api('GET', '/api/projects');
1114
+ if (args.json) {
1115
+ console.log(JSON.stringify(list, null, 2));
1116
+ return;
1117
+ }
1118
+ if (!list.length) {
1119
+ console.log('(no projects)');
1120
+ return;
1121
+ }
1122
+ for (const p of list) {
1123
+ const kw = (p.keywords || []).join(', ');
1124
+ console.log(`${p.slug.padEnd(12)} ${p.name}${kw ? ` (${kw})` : ''}`);
1125
+ }
1126
+ }
1127
+
1128
+ /** `solo … --json` or a fatal error. Solo speaks `{ok, data}`; anything else is a broken CLI. */
1129
+ function solo(argv) {
1130
+ let raw;
1131
+ try {
1132
+ raw = execFileSync(process.env.CRUX_SOLO_BIN || 'solo', [...argv, '--json'], {
1133
+ encoding: 'utf8',
1134
+ stdio: ['ignore', 'pipe', 'pipe'],
1135
+ });
1136
+ } catch (e) {
1137
+ fail(`could not run \`solo ${argv.join(' ')}\`: ${e.message.split('\n')[0]}`);
1138
+ }
1139
+ let doc;
1140
+ try {
1141
+ doc = JSON.parse(raw);
1142
+ } catch {
1143
+ fail(`\`solo ${argv.join(' ')}\` did not return JSON`);
1144
+ }
1145
+ if (!doc.ok) fail(`\`solo ${argv.join(' ')}\` failed: ${doc.error || 'unknown error'}`);
1146
+ return doc.data;
1147
+ }
1148
+
1149
+ /**
1150
+ * Every Solo process, following pagination.
1151
+ *
1152
+ * `solo processes list` defaults to 50 rows and there are more than that. The lead fleet lives
1153
+ * in the `dispatch` project, whose rows sort last — so a single unpaginated call returns a list
1154
+ * that looks complete and contains not one lead. Paginate, always.
1155
+ */
1156
+ function soloProcesses() {
1157
+ const all = [];
1158
+ for (let offset = 0; ; ) {
1159
+ const page = solo(['processes', 'list', '--limit', '200', '--offset', String(offset)]);
1160
+ all.push(...page.processes);
1161
+ if (!page.hasMore) return all;
1162
+ offset = page.nextOffset ?? offset + page.processes.length;
1163
+ if (page.processes.length === 0) return all; // Defensive: never spin on a broken cursor.
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * Is this orchestrator actually on the machine? The one I/O the backend resolver needs.
1169
+ *
1170
+ * The `CRUX_*_BIN` overrides are honoured first, and that is not a test affordance bolted on — it is
1171
+ * the same seam `lib/backends.js` already runs every one of its commands through. If crux is going to
1172
+ * invoke `$CRUX_SOLO_BIN`, then "is solo installed?" has to mean "does THAT exist", or the check and
1173
+ * the thing it is checking are two different programs.
1174
+ *
1175
+ * tmux has no override because nothing shells out to a `CRUX_TMUX_BIN` — the exec template says
1176
+ * `tmux`, so a plain PATH lookup for `tmux` is exactly the question. Which means a test proves it the
1177
+ * way a user lives it: by putting a tmux on PATH.
1178
+ */
1179
+ const BIN_OVERRIDE = { solo: 'CRUX_SOLO_BIN', herdr: 'CRUX_HERDR_BIN' };
1180
+
1181
+ function installed(bin) {
1182
+ const override = BIN_OVERRIDE[bin] ? process.env[BIN_OVERRIDE[bin]] : null;
1183
+ const target = typeof override === 'string' && override !== '' ? override : bin;
1184
+
1185
+ const executable = (file) => {
1186
+ try {
1187
+ fs.accessSync(file, fs.constants.X_OK);
1188
+ return fs.statSync(file).isFile();
1189
+ } catch {
1190
+ return false;
1191
+ }
1192
+ };
1193
+
1194
+ if (target.includes(path.sep)) return executable(target);
1195
+ return (process.env.PATH || '').split(path.delimiter).some((dir) => dir && executable(path.join(dir, target)));
1196
+ }
1197
+
1198
+ /** The leads config: env override, then ~/.config, then the copy shipped beside this script. */
1199
+ function leadsConfig() {
1200
+ const candidates = [
1201
+ process.env.CRUX_LEADS_CONFIG,
1202
+ path.join(os.homedir(), '.config', 'crux', 'leads.json'),
1203
+ path.join(__dirname, 'leads.config.json'),
1204
+ ].filter((p) => typeof p === 'string' && p !== '');
1205
+
1206
+ for (const file of candidates) {
1207
+ if (!fs.existsSync(file)) continue;
1208
+ try {
1209
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
1210
+ const swarm = cfg.swarm || {};
1211
+ return {
1212
+ file,
1213
+ fleetProject: cfg.fleetProject || 'dispatch',
1214
+ map: cfg.map || {},
1215
+ leadless: cfg.leadless || {},
1216
+ leadProcess: cfg.leadProcess || {},
1217
+ swarm: {
1218
+ // NOT `|| 'solo'`. An absent backend means "crux, look at my machine" — and defaulting it
1219
+ // to Solo is what handed every stranger without Solo a first run that ended in `could not
1220
+ // run \`solo projects list\``. Absent is a real state; it is resolved, not assumed.
1221
+ // {@see ../lib/orchestrator}
1222
+ backend: typeof swarm.backend === 'string' && swarm.backend !== '' ? swarm.backend : null,
1223
+ agent: swarm.agent || swarmLib.DEFAULT_AGENT,
1224
+ solo: swarm.solo || {},
1225
+ exec: swarm.exec || {},
1226
+ herdr: swarm.herdr || {},
1227
+ projects: swarm.projects || {},
1228
+ },
1229
+ };
1230
+ } catch (e) {
1231
+ fail(`could not parse ${file}: ${e.message}`);
1232
+ }
1233
+ }
1234
+ return fail(`no leads config found (looked in: ${candidates.join(', ')})`);
1235
+ }
1236
+
1237
+ const LEAD_STATE_LABEL = {
1238
+ covered: 'OK ',
1239
+ leadless: 'OK ',
1240
+ unmanaged: 'WARN',
1241
+ uncovered: 'FAIL',
1242
+ duplicate: 'FAIL',
1243
+ orphan: 'FAIL',
1244
+ unmapped: 'FAIL',
1245
+ };
1246
+
1247
+ /**
1248
+ * crux leads [--fix] [--json]
1249
+ *
1250
+ * Walk every Solo project and report whether a live lead is tailing its crux stream. Exits
1251
+ * non-zero when anything is uncovered, so it works unattended as a health check.
1252
+ */
1253
+ async function leads(args) {
1254
+ const config = leadsConfig();
1255
+
1256
+ const registry = await api('GET', '/api/projects');
1257
+ const cruxSlugs = registry.map((p) => p.slug);
1258
+
1259
+ const projects = solo(['projects', 'list']).projects;
1260
+ const processes = soloProcesses();
1261
+
1262
+ const table = leadsLib.parseProcessTable(execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
1263
+ const soloByPid = new Map(processes.filter((p) => typeof p.pid === 'number').map((p) => [p.pid, p]));
1264
+ const tails = leadsLib.attributeOwners(leadsLib.findTails(table), table, soloByPid);
1265
+
1266
+ const report = leadsLib.reconcile({ soloProjects: projects, cruxSlugs, tails, config });
1267
+ const plan = args.fix ? leadsLib.planFix(report, processes, config) : null;
1268
+
1269
+ const started = [];
1270
+ if (plan) {
1271
+ for (const { slug, process: proc } of plan.starts) {
1272
+ if (args['dry-run']) {
1273
+ started.push({ slug, process: proc.id, dryRun: true });
1274
+ continue;
1275
+ }
1276
+ solo(['processes', 'start', String(proc.id)]);
1277
+ started.push({ slug, process: proc.id, dryRun: false });
1278
+ }
1279
+ }
1280
+
1281
+ if (args.json) {
1282
+ console.log(JSON.stringify({ ok: report.ok, config: config.file, entries: report.entries, fix: plan ? { started, refusals: plan.refusals } : null }, null, 2));
1283
+ process.exit(report.exitCode);
1284
+ }
1285
+
1286
+ for (const entry of report.entries) {
1287
+ const name = entry.slug ?? `(${entry.soloProject.name})`;
1288
+ console.log(`${LEAD_STATE_LABEL[entry.state]} ${name.padEnd(22)} ${entry.state.padEnd(10)} ${entry.detail}`);
1289
+ if (entry.state === 'covered' && entry.unsupervised) {
1290
+ console.log(` ${''.padEnd(22)} ${''.padEnd(10)} ^ no Solo ancestor — supervised by nothing.`);
1291
+ }
1292
+ }
1293
+
1294
+ if (plan) {
1295
+ for (const started_ of started) {
1296
+ console.log(`\nstarted lead for "${started_.slug}" (Solo process ${started_.process})${started_.dryRun ? ' [dry-run]' : ''}`);
1297
+ }
1298
+ for (const refusal of plan.refusals) {
1299
+ console.log(`\nrefused "${refusal.slug}": ${refusal.reason}`);
1300
+ }
1301
+ }
1302
+
1303
+ const counts = report.entries.reduce((acc, e) => ({ ...acc, [e.state]: (acc[e.state] || 0) + 1 }), {});
1304
+ console.log(`\n${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')}`);
1305
+ process.exit(report.exitCode);
1306
+ }
1307
+
1308
+ /* ------------------------------------------------------------------------------------------------
1309
+ * crux discover — step zero.
1310
+ *
1311
+ * `crux swarm up` needs a crux project and a repo path. Until this command existed, HAVING one meant
1312
+ * a stranger learning a config-file format and hand-editing JSON before the five-minute quickstart
1313
+ * could even begin. The pure half of this lives in lib/discover.js; what is here is the I/O it is
1314
+ * kept away from — git, the manifests, the questions, and the two writes.
1315
+ * --------------------------------------------------------------------------------------------- */
1316
+
1317
+ /** Run a git command in `dir`, or null. A repo with no remote is normal, not an error. */
1318
+ function git(dir, argv) {
1319
+ try {
1320
+ return execFileSync('git', ['-C', dir, ...argv], {
1321
+ encoding: 'utf8',
1322
+ stdio: ['ignore', 'pipe', 'ignore'],
1323
+ }).trim() || null;
1324
+ } catch {
1325
+ return null;
1326
+ }
1327
+ }
1328
+
1329
+ /** Parse a JSON manifest, or null. A malformed package.json must not take the command down. */
1330
+ function readJsonFile(file) {
1331
+ try {
1332
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1333
+ } catch {
1334
+ return null;
1335
+ }
1336
+ }
1337
+
1338
+ function readTextFile(file) {
1339
+ try {
1340
+ return fs.readFileSync(file, 'utf8');
1341
+ } catch {
1342
+ return null;
1343
+ }
1344
+ }
1345
+
1346
+ /** The first README this repo has, under any of the names people give it. */
1347
+ function readReadme(repo) {
1348
+ for (const name of ['README.md', 'readme.md', 'README', 'README.markdown']) {
1349
+ const text = readTextFile(path.join(repo, name));
1350
+ if (text !== null) return text;
1351
+ }
1352
+ return null;
1353
+ }
1354
+
1355
+ /**
1356
+ * Everything the repo can tell us without being asked. This is the whole point of the command:
1357
+ * every field below is one a stranger would otherwise have had to type.
1358
+ */
1359
+ function inspectRepo(dir) {
1360
+ const root = git(dir, ['rev-parse', '--show-toplevel']);
1361
+ if (!root) {
1362
+ fail(
1363
+ `${dir} is not a git repository — a lead has to have a repo to stand in, and crux will not ` +
1364
+ 'guess at one.\n' +
1365
+ ` Run \`git init\` in it first, or point discover somewhere else: crux discover <path>`,
1366
+ );
1367
+ }
1368
+
1369
+ // The toplevel, realpath'd: run it from a subdirectory (or a worktree, or through /tmp on macOS)
1370
+ // and the answer must still be the one path everything else in crux compares against.
1371
+ const repo = fs.realpathSync(root);
1372
+
1373
+ const readme = readReadme(repo);
1374
+ const stack = discoverLib.detectStack({
1375
+ composer: readJsonFile(path.join(repo, 'composer.json')),
1376
+ pkg: readJsonFile(path.join(repo, 'package.json')),
1377
+ goMod: readTextFile(path.join(repo, 'go.mod')),
1378
+ cargo: readTextFile(path.join(repo, 'Cargo.toml')),
1379
+ gemfile: readTextFile(path.join(repo, 'Gemfile')),
1380
+ python:
1381
+ readTextFile(path.join(repo, 'pyproject.toml')) ?? readTextFile(path.join(repo, 'requirements.txt')),
1382
+ });
1383
+
1384
+ const remote = git(repo, ['remote', 'get-url', 'origin']);
1385
+ const manifest =
1386
+ readJsonFile(path.join(repo, 'package.json'))?.name ??
1387
+ readJsonFile(path.join(repo, 'composer.json'))?.name ??
1388
+ null;
1389
+
1390
+ const name = discoverLib.deriveName({ remote, manifestName: manifest, dirName: path.basename(repo) });
1391
+ const title = discoverLib.readmeTitle(readme);
1392
+
1393
+ return { repo, remote, stack, title, name, slug: discoverLib.slugify(name) };
1394
+ }
1395
+
1396
+ /**
1397
+ * Ask a question that always has an answer.
1398
+ *
1399
+ * `readline` is in core — this command adds no dependency, deliberately.
1400
+ *
1401
+ * Lines are PULLED, one per question, through readline's async iterator, and that is not a style
1402
+ * choice. `rl.question()` registers interest in exactly the next line, but the interface goes on
1403
+ * consuming the stream regardless — so when stdin is a pipe rather than a tty (a test, a script,
1404
+ * `printf '…' | crux discover`) the whole input arrives in one chunk, readline emits every line of
1405
+ * it immediately, and the four nobody is awaiting yet are dropped on the floor. The command would
1406
+ * silently take defaults for questions the user had answered. The iterator pauses the stream between
1407
+ * reads, so a line waits for its question.
1408
+ *
1409
+ * EOF (`done`) returns the default, which makes an input-less run behave exactly like `--yes` rather
1410
+ * than hang forever at a prompt no one can see — the only honest reading of "there is nothing left
1411
+ * to type".
1412
+ */
1413
+ function prompter({ yes }) {
1414
+ if (yes) return { ask: async (_q, fallback) => fallback, close() {} };
1415
+
1416
+ const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
1417
+ const lines = rl[Symbol.asyncIterator]();
1418
+
1419
+ return {
1420
+ async ask(question, fallback) {
1421
+ process.stdout.write(`${question} [${fallback}] `);
1422
+ const { value, done } = await lines.next();
1423
+ // A tty echoes the Enter the user pressed; a pipe does not, and without this every prompt in a
1424
+ // scripted run runs into the next one on a single unreadable line.
1425
+ if (!process.stdin.isTTY) process.stdout.write('\n');
1426
+ if (done) return fallback;
1427
+ const answer = String(value).trim();
1428
+ return answer === '' ? fallback : answer;
1429
+ },
1430
+ close() {
1431
+ rl.close();
1432
+ },
1433
+ };
1434
+ }
1435
+
1436
+ /**
1437
+ * Which leads config discover WRITES to.
1438
+ *
1439
+ * It must be the file `leadsConfig()` will later READ, or `crux swarm up` looks somewhere else and
1440
+ * the repo path this command just took from a human is never found. So: `CRUX_LEADS_CONFIG` if it is
1441
+ * set, else `~/.config/crux/leads.json`.
1442
+ *
1443
+ * The subtle one is the third candidate, `cli/leads.config.json` — the copy shipped inside the
1444
+ * package. `leadsConfig()` falls back to it, so on a machine with no user config it is the file in
1445
+ * force, and creating an empty `~/.config/crux/leads.json` would SHADOW it: the shipped `map` and
1446
+ * `leadless` entries would silently stop applying, and a fleet that was working would quietly lose
1447
+ * its name mapping. Never write into the package (it is not the user's file, and an npm reinstall
1448
+ * would erase it) — but seed the new user config from whatever is in force, so creating it changes
1449
+ * exactly one thing: the project we are adding.
1450
+ */
1451
+ function discoverConfigTarget() {
1452
+ const env = process.env.CRUX_LEADS_CONFIG;
1453
+ const file = typeof env === 'string' && env !== '' ? env : path.join(os.homedir(), '.config', 'crux', 'leads.json');
1454
+
1455
+ if (fs.existsSync(file)) return { file, created: false, seed: readJsonFile(file) ?? {} };
1456
+
1457
+ // Nothing of the user's own yet. Inherit what is currently in force rather than replace it.
1458
+ const shipped = readJsonFile(path.join(__dirname, 'leads.config.json'));
1459
+ return { file, created: true, seed: shipped ?? {} };
1460
+ }
1461
+
1462
+ /** Fail now, with the fix, rather than after the project has been created in the API. */
1463
+ function assertWritable(file) {
1464
+ const dir = path.dirname(file);
1465
+ try {
1466
+ fs.mkdirSync(dir, { recursive: true });
1467
+ fs.accessSync(fs.existsSync(file) ? file : dir, fs.constants.W_OK);
1468
+ } catch {
1469
+ fail(
1470
+ `cannot write the leads config at ${file} — the crux project would be created in the API with ` +
1471
+ 'no repo on this machine, and `crux swarm up` could not start its lead.\n' +
1472
+ ` Fix the permissions (\`chmod u+w ${dir}\`), or point discover at a config you own ` +
1473
+ 'with CRUX_LEADS_CONFIG=/path/to/leads.json.',
1474
+ );
1475
+ }
1476
+ }
1477
+
1478
+ /** Is a lead already tailing this slug? An honest "not started" needs a real look, not an assumption. */
1479
+ function tailFor(slug) {
1480
+ try {
1481
+ return runningTails().find((t) => t.slug === slug) ?? null;
1482
+ } catch {
1483
+ return null;
1484
+ }
1485
+ }
1486
+
1487
+ /**
1488
+ * crux discover [path] [--yes] [--json] [--slug <s>] [--name <n>] [--color <c>] [--keywords <a,b>]
1489
+ *
1490
+ * Turn a directory into a crux project: registered with the API (so it exists in the feed, the app
1491
+ * and the web) and declared in the local swarm config (so `crux swarm up` knows where its lead runs).
1492
+ * Both, or neither — a project with no repo cannot be led, and a repo with no project has nothing to
1493
+ * lead. It is step zero, and until now it was a JSON file a stranger had to learn.
1494
+ */
1495
+ async function discover(args) {
1496
+ const yes = args.yes === true;
1497
+ const target = path.resolve(typeof args._[1] === 'string' ? args._[1] : '.');
1498
+
1499
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
1500
+ fail(`no such directory: ${target}\n usage: crux discover [path]`);
1501
+ }
1502
+
1503
+ // 1. DISCOVER — everything that can be read is read, and never asked.
1504
+ const found = inspectRepo(target);
1505
+
1506
+ // The config is opened (and checked writable) BEFORE the API is touched. Both writes must land or
1507
+ // neither should, and the local one is the only one we can test in advance — so a config we cannot
1508
+ // write fails here, having created nothing, rather than after a project exists in the feed with no
1509
+ // repo behind it.
1510
+ const config = discoverConfigTarget();
1511
+ assertWritable(config.file);
1512
+ const configProjects = config.seed?.swarm?.projects ?? {};
1513
+
1514
+ const registry = await api('GET', '/api/projects');
1515
+
1516
+ const defaults = {
1517
+ slug: typeof args.slug === 'string' ? discoverLib.slugify(args.slug) : found.slug,
1518
+ repo: found.repo,
1519
+ };
1520
+ if (!defaults.slug) {
1521
+ fail(`could not derive a slug from ${found.repo} — pass one: crux discover --slug <slug>`);
1522
+ }
1523
+ defaults.name =
1524
+ typeof args.name === 'string'
1525
+ ? args.name
1526
+ : discoverLib.deriveDisplayName({ name: found.name, title: found.title, slug: found.slug });
1527
+ defaults.color = typeof args.color === 'string' ? args.color : discoverLib.colorFor(defaults.slug);
1528
+ defaults.keywords =
1529
+ typeof args.keywords === 'string'
1530
+ ? args.keywords.split(',').map((k) => discoverLib.slugify(k)).filter(Boolean)
1531
+ : discoverLib.deriveKeywords({ ...found, slug: defaults.slug, name: defaults.name });
1532
+
1533
+ // 2. ASK — only what could not be read, always with the derived answer as the default, so the
1534
+ // common case is Enter, Enter, Enter. `--yes` (and a stdin with nothing on it) takes them all.
1535
+ const home = os.homedir();
1536
+ const quiet = yes || args.json === true;
1537
+ const rl = prompter({ yes: quiet });
1538
+
1539
+ if (!quiet) {
1540
+ console.log(`Found ${discoverLib.tildify(found.repo, home)}${found.stack ? ` — ${found.stack.label}` : ''}.\n`);
1541
+ }
1542
+
1543
+ const answers = {
1544
+ slug: await rl.ask('slug', defaults.slug),
1545
+ name: await rl.ask('name', defaults.name),
1546
+ color: await rl.ask(`color (${discoverLib.PALETTE.join(' ')})`, defaults.color),
1547
+ keywords: await rl.ask('keywords', defaults.keywords.join(',')),
1548
+ repo: await rl.ask('repo', defaults.repo),
1549
+ };
1550
+
1551
+ const slug = discoverLib.slugify(answers.slug);
1552
+ if (!slug) fail(`"${answers.slug}" is not a usable slug — letters, numbers and dashes.`);
1553
+
1554
+ if (!discoverLib.PALETTE.includes(answers.color)) {
1555
+ fail(
1556
+ `"${answers.color}" is not a colour crux renders — the app would colour it and the web would ` +
1557
+ 'show it grey, which is worse than either.\n' +
1558
+ ` Pick one of: ${discoverLib.PALETTE.join(', ')}`,
1559
+ );
1560
+ }
1561
+
1562
+ // A repo the user retyped is a repo we have not looked at. Check it like the first one.
1563
+ const repo = fs.existsSync(answers.repo) ? fs.realpathSync(answers.repo) : path.resolve(answers.repo);
1564
+ if (repo !== found.repo) inspectRepo(repo);
1565
+
1566
+ const keywords = String(answers.keywords)
1567
+ .split(',')
1568
+ .map((k) => discoverLib.slugify(k))
1569
+ .filter(Boolean);
1570
+
1571
+ const payload = discoverLib.projectPayload({
1572
+ slug,
1573
+ name: answers.name,
1574
+ color: answers.color,
1575
+ description:
1576
+ typeof args.description === 'string'
1577
+ ? args.description
1578
+ : discoverLib.describe({ name: answers.name, stack: found.stack, title: found.title }),
1579
+ keywords,
1580
+ });
1581
+
1582
+ // 3. WRITE — but decide first, so a second run neither duplicates anything nor lies about it.
1583
+ const plan = discoverLib.planWrite({ slug, repo, payload, registry, configProjects, samePath });
1584
+ if (plan.refusal) fail(plan.refusal);
1585
+
1586
+ // An existing project is never silently clobbered. Interactively it is a question — and one that
1587
+ // names the fields, so nothing changes without being seen. Under `--yes` (a script) the intent is
1588
+ // unambiguous: you asked for the repo's state to be the truth, so it is applied.
1589
+ let action = plan.project;
1590
+ if (plan.project === 'update' && !quiet) {
1591
+ const answer = await rl.ask(
1592
+ `\n"${slug}" is already a crux project. Update ${plan.changed.join(', ')}? (y/n)`,
1593
+ 'y',
1594
+ );
1595
+ if (!/^y(es)?$/i.test(answer)) action = 'unchanged';
1596
+ }
1597
+ rl.close();
1598
+
1599
+ let project = plan.existing;
1600
+ if (action !== 'unchanged') {
1601
+ project = await api('POST', '/api/projects', payload);
1602
+ }
1603
+ const did = action === 'create' ? 'created' : action === 'update' ? 'updated' : 'unchanged';
1604
+
1605
+ // 4. SETTLE THE ORCHESTRATOR — the step that was missing, and the reason the printed `Next:` used
1606
+ // to be a lie. Registering a project says WHERE the lead works; it never said what would RUN it,
1607
+ // so discover happily printed `crux swarm up` at a machine that had no way to start an agent.
1608
+ //
1609
+ // This is squarely discover's business. It is already the command that settles the things a
1610
+ // stranger should not have to learn a JSON file to state — and "what starts a lead here" is the
1611
+ // last of them. It is DERIVED, never asked: adding a sixth prompt to the common path to ask a
1612
+ // question the PATH already answers would be the form-shaped tool this command exists not to be.
1613
+ //
1614
+ // Resolved against the config discover is WRITING, not the one `leadsConfig()` reads. On a first
1615
+ // run those are different files — `leadsConfig()` is answering out of the shipped package copy
1616
+ // while discover is creating the user's own — and checking the wrong one would preflight a
1617
+ // backend that is not the one `crux swarm up` will pick up thirty seconds later.
1618
+ const chosen = orchestrator.resolveBackend({
1619
+ env: process.env.CRUX_SWARM_BACKEND || null,
1620
+ configured: config.seed?.swarm?.backend ?? null,
1621
+ exec: config.seed?.swarm?.exec ?? {},
1622
+ has: installed,
1623
+ file: discoverLib.tildify(config.file, home),
1624
+ });
1625
+
1626
+ // The config write is last because it is the one that can be re-run safely. If it throws, the
1627
+ // project exists in the API and this run is a half-write — so it says so, loudly, and exits
1628
+ // non-zero. What it must never do is print the success block.
1629
+ //
1630
+ // One write, both facts. The repo, and — when crux had to work the backend out for itself — the
1631
+ // backend it worked out. A derivation that lives only in crux's head is one the user cannot see,
1632
+ // cannot edit, and cannot tell apart from their own choice; written down it is a starting point
1633
+ // they own. Nothing already in the file is ever overwritten {@see mergeBackend}.
1634
+ const wrote = [];
1635
+ let merged = config.seed;
1636
+ if (plan.config !== 'unchanged') {
1637
+ merged = discoverLib.mergeConfig(merged, slug, repo);
1638
+ wrote.push(`swarm.projects.${slug}.repo`);
1639
+ }
1640
+ if (chosen.ok && chosen.writes) {
1641
+ const before = merged;
1642
+ merged = discoverLib.mergeBackend(merged, chosen.writes);
1643
+ if (merged !== before) wrote.push(...Object.keys(chosen.writes).map((k) => `swarm.${k}`));
1644
+ }
1645
+
1646
+ if (wrote.length) {
1647
+ try {
1648
+ fs.writeFileSync(config.file, `${JSON.stringify(merged, null, 2)}\n`);
1649
+ } catch (e) {
1650
+ fail(
1651
+ `the crux project "${slug}" was ${did}, but its repo could not be written to ${config.file} ` +
1652
+ `(${e.message}) — so \`crux swarm up\` cannot start its lead yet.\n` +
1653
+ ` Fix the file and run \`crux discover ${repo}\` again; it is idempotent.`,
1654
+ );
1655
+ }
1656
+ }
1657
+
1658
+ const tail = tailFor(slug);
1659
+ const next = tail
1660
+ ? `crux push --project ${slug} "<something for it to do>"`
1661
+ : `crux swarm up --project ${slug}`;
1662
+
1663
+ if (args.json) {
1664
+ console.log(
1665
+ JSON.stringify(
1666
+ {
1667
+ ok: true,
1668
+ project: { action: did, changed: plan.changed, ...project },
1669
+ config: { file: config.file, created: config.created, action: plan.config, repo, wrote },
1670
+ lead: tail ? { tailing: true, pid: tail.pid } : { tailing: false },
1671
+ backend: {
1672
+ name: chosen.name,
1673
+ source: chosen.source,
1674
+ ok: chosen.ok,
1675
+ exec: chosen.exec,
1676
+ problem: chosen.problem,
1677
+ },
1678
+ // A machine that cannot start a lead gets no `next`. A caller that trusted this field and
1679
+ // ran what it found would hit the same dead end a human did — so the field states the
1680
+ // truth instead: there IS no next command yet, and `backend.problem` says why.
1681
+ next: chosen.ok ? next : null,
1682
+ blocked: chosen.ok ? null : next,
1683
+ },
1684
+ null,
1685
+ 2,
1686
+ ),
1687
+ );
1688
+ return;
1689
+ }
1690
+
1691
+ // 5. TELL THEM WHAT IS NEXT. And say what actually happened — a run that changed nothing says so
1692
+ // in the same breath as the ✓, because "success" and "did something" are not the same claim.
1693
+ const summary = action === 'update' ? `updated (${plan.changed.join(', ')})` : did;
1694
+
1695
+ console.log(`\n✓ ${slug} is a crux project — ${summary}.`);
1696
+ console.log(` repo ${discoverLib.tildify(repo, home)}`);
1697
+ console.log(
1698
+ ` config ${discoverLib.tildify(config.file, home)} — ` +
1699
+ `${wrote.length ? `${wrote.join(', ')} added` : 'already had this repo'}` +
1700
+ `${config.created ? ' (created)' : ''}`,
1701
+ );
1702
+ console.log(` lead ${tail ? `tailing "${slug}" (pid ${tail.pid})` : 'not started'}`);
1703
+ console.log(
1704
+ ` runs on ${chosen.ok ? backendLabel(chosen) : 'nothing yet — see below'}` +
1705
+ `${chosen.ok && chosen.writes ? ' — written to the config above, edit it freely' : ''}`,
1706
+ );
1707
+
1708
+ // The whole point of this thread. A `Next:` crux can SEE will fail is not printed — because a
1709
+ // stranger who runs it and watches it die has had the failed first run, and the quality of the
1710
+ // error message they die with is not what makes it one.
1711
+ if (!chosen.ok) {
1712
+ console.log(`\n ✗ ${chosen.problem.reason}`);
1713
+ for (const line of chosen.problem.fix) console.log(` ${line}`);
1714
+ console.log(`\n Then: ${next}\n`);
1715
+ return;
1716
+ }
1717
+
1718
+ console.log(`\n Next: ${next}\n`);
1719
+ }
1720
+
1721
+ /**
1722
+ * The three things a member of the public does not have, and `crux swarm` supplies.
1723
+ *
1724
+ * The first crux fleet ran for a year before anyone else could have reproduced it, and the reasons
1725
+ * lived outside crux entirely: its leads were started from hand-written briefs in a private
1726
+ * directory; each lead had to be hand-declared in Solo's `solo.yml` before `crux leads --fix` would
1727
+ * start it; and there was no abstraction at all — `leads.js` shells out to `solo`. So `swarm`
1728
+ * generates the brief
1729
+ * {@see renderLeadBrief}, spawns the lead without any declaration, and does it through an adapter
1730
+ * {@see ../lib/backends}. "Bring your own orchestration, but ship our own opinions."
1731
+ */
1732
+
1733
+ /** `~/src/crux` → `/Users/…/src/crux`. A config file written by a human will contain tildes. */
1734
+ function expandHome(p) {
1735
+ if (typeof p !== 'string' || p === '') return null;
1736
+ if (p === '~') return os.homedir();
1737
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
1738
+ return path.resolve(p);
1739
+ }
1740
+
1741
+ /**
1742
+ * Every `crux tail` the OS can see. The truth about coverage, and the same source `crux leads` uses.
1743
+ *
1744
+ * `CRUX_PS_BIN` overrides the command, exactly as `CRUX_SOLO_BIN` overrides Solo. Coverage is the
1745
+ * one input to `swarm` that comes from the whole machine rather than from crux's own config, so
1746
+ * without a seam here a test would reconcile against whatever leads happen to be running on the
1747
+ * developer's laptop — and pass or fail accordingly.
1748
+ */
1749
+ function runningTails() {
1750
+ const ps = execFileSync(process.env.CRUX_PS_BIN || 'ps', ['-axo', 'pid=,ppid=,command='], {
1751
+ encoding: 'utf8',
1752
+ maxBuffer: 16 * 1024 * 1024,
1753
+ });
1754
+ return leadsLib.findTails(leadsLib.parseProcessTable(ps));
1755
+ }
1756
+
1757
+ /** `--project a --project b` → ['a','b']; absent → null (meaning "every project"). */
1758
+ function onlyArg(raw) {
1759
+ if (raw === undefined) return null;
1760
+ const list = (Array.isArray(raw) ? raw : [raw]).filter((p) => typeof p === 'string');
1761
+ if (list.length === 0) fail('--project needs a slug');
1762
+ return list;
1763
+ }
1764
+
1765
+ /**
1766
+ * Resolve every crux project into something the planner can decide about.
1767
+ *
1768
+ * The per-project facts are DELIBERATELY not baked in: the repo path and what the project *is* come
1769
+ * from the crux registry and the user's own config, never from anybody else's machine. The repo falls back
1770
+ * to the backend's own idea of where the project lives (Solo records a path per project), which is
1771
+ * what lets an existing Solo user run `crux swarm up` with no configuration at all.
1772
+ */
1773
+ function swarmCandidates({ registry, config, soloProjects }) {
1774
+ const slugs = new Set(registry.map((p) => p.slug));
1775
+ const soloBySlug = new Map();
1776
+
1777
+ for (const project of soloProjects) {
1778
+ const mapped = config.map[project.name];
1779
+ if (typeof mapped === 'string') {
1780
+ soloBySlug.set(mapped, project);
1781
+ continue;
1782
+ }
1783
+ // Not in the map, but its name IS a crux slug. That is not a guess — the names are equal — and
1784
+ // it is what lets a stranger with Solo run `crux swarm up` having configured nothing at all.
1785
+ // The explicit map exists for the cases where they are NOT equal (`acme-search-index` is the
1786
+ // slug `acme`), and it still wins wherever it is set.
1787
+ if (slugs.has(project.name) && !soloBySlug.has(project.name)) soloBySlug.set(project.name, project);
1788
+ }
1789
+
1790
+ return registry.map((project) => {
1791
+ const declared = config.swarm.projects[project.slug] || {};
1792
+ const soloProject = soloBySlug.get(project.slug) ?? null;
1793
+
1794
+ // Where the BACKEND will actually run the lead, when the backend gets to decide. Solo runs an
1795
+ // agent in its project's directory — the configured repo does not move it — so for a slug Solo
1796
+ // already has a project for, Solo's path is the truth and `repo` is only a claim.
1797
+ const backendRepo = soloProject ? soloProject.path : null;
1798
+ const declaredRepo = expandHome(declared.repo);
1799
+
1800
+ return {
1801
+ slug: project.slug,
1802
+ description: project.description || null,
1803
+ projectName: project.name || project.slug,
1804
+ leadless: typeof config.leadless[project.slug] === 'string' ? config.leadless[project.slug] : null,
1805
+ repo: backendRepo ?? declaredRepo,
1806
+ extra: typeof declared.brief === 'string' ? declared.brief : null,
1807
+ soloProject: soloProject ? { id: soloProject.id, name: soloProject.name } : null,
1808
+ repoConflict:
1809
+ backendRepo && declaredRepo && !samePath(backendRepo, declaredRepo)
1810
+ ? { backend: backendRepo, declared: declaredRepo, project: soloProject }
1811
+ : null,
1812
+ };
1813
+ });
1814
+ }
1815
+
1816
+ /** Same directory? Resolved, because macOS hands you `/tmp` and `/private/tmp` for the same place. */
1817
+ function samePath(a, b) {
1818
+ const real = (p) => {
1819
+ try {
1820
+ return fs.realpathSync(p);
1821
+ } catch {
1822
+ return path.resolve(p);
1823
+ }
1824
+ };
1825
+ return real(a) === real(b);
1826
+ }
1827
+
1828
+ /**
1829
+ * Read the state file back as something both plans can use: is each remembered lead still alive, and
1830
+ * how can the backend that started it name it?
1831
+ *
1832
+ * Liveness is asked of the backend that STARTED the lead, not the one selected today — a solo lead
1833
+ * does not become unaddressable because the user passed `--backend exec` this morning.
1834
+ */
1835
+ function startedLeads(state, backends) {
1836
+ return Object.values(state).map((entry) => {
1837
+ let alive = false;
1838
+ let addresses = 'handle';
1839
+ try {
1840
+ const backend = backends(entry.backend);
1841
+ addresses = backend.addresses;
1842
+ alive = backend.list().some((l) => String(l.ref) === String(entry.ref) && l.running);
1843
+ } catch {
1844
+ // A backend we can no longer build (uninstalled, misconfigured) cannot vouch for its lead. Not
1845
+ // alive, so `up` may restart it — and `down` will report it as already gone rather than claim
1846
+ // a stop that never happened.
1847
+ alive = false;
1848
+ }
1849
+ return { ...entry, alive, addresses, everTailed: Boolean(entry.first_tail_at) };
1850
+ });
1851
+ }
1852
+
1853
+ /**
1854
+ * One place to build (and reuse) a backend by name, so `list()` is not re-shelled per lead.
1855
+ *
1856
+ * `swarm.exec` is the RESOLVED block, not the raw config one — so a lead crux started with a derived
1857
+ * tmux template can still be stopped by a `swarm down` that derives the same one. Hand the raw config
1858
+ * to `down` and the stop template vanishes, `addresses` drops to `pid`, and crux refuses to stop a
1859
+ * lead it started ninety seconds ago. The resolution has to be the same on both sides of the round
1860
+ * trip, or the round trip is not one.
1861
+ */
1862
+ function backendCache(config, state, exec = null) {
1863
+ const swarm = exec ? { ...config.swarm, exec } : config.swarm;
1864
+ const cache = new Map();
1865
+ return (name) => {
1866
+ if (!cache.has(name)) cache.set(name, backends.makeBackend(name, { config: swarm, state }));
1867
+ return cache.get(name);
1868
+ };
1869
+ }
1870
+
1871
+ /**
1872
+ * Which backend this run uses, and whether it can run at all — the check that did not exist.
1873
+ *
1874
+ * The refusal here is the point. `makeBackend` would have thrown for a missing exec template anyway,
1875
+ * but only for THAT one case, and only at the moment of use; a machine with no Solo installed sailed
1876
+ * past it and died shelling out. One resolver, checked once, means `discover` can ask the identical
1877
+ * question BEFORE it prints `Next:` — which is the difference between a first run that works and a
1878
+ * first run that ends in a good error message.
1879
+ */
1880
+ function resolveSwarmBackend(args, config) {
1881
+ return orchestrator.resolveBackend({
1882
+ requested: typeof args.backend === 'string' ? args.backend : null,
1883
+ env: process.env.CRUX_SWARM_BACKEND || null,
1884
+ configured: config.swarm.backend,
1885
+ exec: config.swarm.exec,
1886
+ has: installed,
1887
+ file: discoverLib.tildify(config.file, os.homedir()),
1888
+ });
1889
+ }
1890
+
1891
+ /** A resolver problem, as the multi-line refusal a human can act on. */
1892
+ function backendProblem(problem) {
1893
+ return [problem.reason, ...problem.fix.map((line) => ` ${line}`)].join('\n');
1894
+ }
1895
+
1896
+ /**
1897
+ * crux swarm up|status|down [--backend solo|exec|herdr] [--project <slug>]… [--dry-run] [--json]
1898
+ *
1899
+ * `up` is idempotent — run it twice and the second run starts nothing. That is not a nicety: two
1900
+ * tails on one stream race the shared cursor file and deliver every item twice, so "start a lead"
1901
+ * has to mean "make sure exactly one lead is running", which is a reconciliation, not a spawn.
1902
+ *
1903
+ * `--dry-run` prints the plan and starts nothing, and for a launch demo that matters more than it
1904
+ * sounds — it is how a stranger builds trust before letting a tool spawn agents on their machine.
1905
+ */
1906
+ async function swarm(args) {
1907
+ const sub = args._[1];
1908
+ if (!['up', 'status', 'down'].includes(sub || '')) fail(usageFor('swarm'));
1909
+
1910
+ const config = leadsConfig();
1911
+ const only = onlyArg(args.project);
1912
+ const dryRun = args['dry-run'] === true;
1913
+
1914
+ const state = swarmState.readState();
1915
+
1916
+ const chosen = resolveSwarmBackend(args, config);
1917
+ if (!chosen.ok) fail(backendProblem(chosen.problem));
1918
+ const name = chosen.name;
1919
+
1920
+ const backends_ = backendCache(config, state, chosen.exec);
1921
+ let backend;
1922
+ try {
1923
+ backend = backends_(name);
1924
+ } catch (e) {
1925
+ fail(e.message);
1926
+ }
1927
+
1928
+ const registry = await api('GET', '/api/projects');
1929
+ const tails = runningTails();
1930
+
1931
+ // The first time we ever catch a lead tailing, write it down. That single fact separates the two
1932
+ // ways a lead can be up-but-not-tailing, which look identical in a process table and could not be
1933
+ // more different: one has never got there (stuck at boot — a trust prompt, a crashed agent) and is
1934
+ // a dead swarm; the other tailed happily for hours and is between tail restarts. Without this,
1935
+ // every healthy lead eventually trips the stall check simply by being old enough.
1936
+ const tailing = new Set(tails.map((t) => t.slug));
1937
+ for (const entry of Object.values(state)) {
1938
+ if (!entry.first_tail_at && tailing.has(entry.slug)) {
1939
+ entry.first_tail_at = new Date().toISOString();
1940
+ swarmState.recordLead(entry.slug, entry);
1941
+ }
1942
+ }
1943
+
1944
+ const started = startedLeads(state, backends_);
1945
+
1946
+ // Solo is the only backend that knows where its projects live — it records a path per project, and
1947
+ // that is what lets an existing Solo user bring a swarm up with no configuration at all. For every
1948
+ // other backend the repo has to be declared, and a slug with no repo is refused, never guessed at.
1949
+ const soloProjects = name === 'solo' ? solo(['projects', 'list']).projects : [];
1950
+ const candidates = swarmCandidates({ registry, config, soloProjects });
1951
+
1952
+ if (sub === 'down') return swarmDown({ args, backends_, started, tails, only, dryRun });
1953
+ return swarmUp({ args, backend, chosen, config, candidates, tails, started, only, dryRun, status: sub === 'status' });
1954
+ }
1955
+
1956
+ /**
1957
+ * How the backend was arrived at, in the header — because a DERIVED backend must never be a silent
1958
+ * one. crux picking tmux for you is a convenience; crux picking tmux for you without saying so is a
1959
+ * user who cannot find their agents.
1960
+ */
1961
+ function backendLabel(chosen) {
1962
+ const how = [];
1963
+ if (chosen.source === 'detected') how.push('auto-detected');
1964
+ if (chosen.execSource === 'tmux') how.push('tmux template');
1965
+ return how.length ? `${chosen.name} (${how.join(', ')})` : chosen.name;
1966
+ }
1967
+
1968
+ const SWARM_STATE_LABEL = {
1969
+ covered: 'OK ',
1970
+ leadless: 'OK ',
1971
+ starting: 'OK ',
1972
+ 'tail-down': 'WARN',
1973
+ 'not-ours': 'OK ',
1974
+ 'already-gone': 'OK ',
1975
+ start: 'START',
1976
+ refused: 'FAIL',
1977
+ };
1978
+
1979
+ /** `swarm up`, and `swarm status` — the same reconciliation, one of which is allowed to act on it. */
1980
+ async function swarmUp({ args, backend, chosen, config, candidates, tails, started, only, dryRun, status }) {
1981
+ const name = chosen.name;
1982
+ const plan = swarmLib.planUp({ candidates, tails, started, only });
1983
+
1984
+ const acted = [];
1985
+ const failures = [];
1986
+
1987
+ if (!status) {
1988
+ for (const lead of plan.starts) {
1989
+ const briefFile = swarmState.briefPath(lead.slug);
1990
+ const prompt = swarmLib.leadPrompt(briefFile);
1991
+
1992
+ if (dryRun) {
1993
+ acted.push({ slug: lead.slug, dryRun: true, brief: briefFile, repo: lead.repo, detail: null });
1994
+ continue;
1995
+ }
1996
+
1997
+ // The brief has to exist before the agent is told to read it. Written first, and never while
1998
+ // a lead is already reading it — that is why nothing here rewrites a brief for a covered slug.
1999
+ try {
2000
+ fs.mkdirSync(swarmState.BRIEF_DIR, { recursive: true });
2001
+ fs.writeFileSync(
2002
+ briefFile,
2003
+ swarmLib.renderLeadBrief({
2004
+ slug: lead.slug,
2005
+ projectName: lead.projectName,
2006
+ description: lead.description,
2007
+ repo: lead.repo,
2008
+ extra: lead.extra,
2009
+ spawn: lead.soloProject
2010
+ ? `crux spawn <id> --project-id ${lead.soloProject.id} --brief "<self-contained task, incl. repo path + git worktree>"`
2011
+ : null,
2012
+ }),
2013
+ );
2014
+
2015
+ const { ref, detail } = backend.start({ ...lead, prompt, briefPath: briefFile, agent: config.swarm.agent });
2016
+ swarmState.recordLead(lead.slug, {
2017
+ backend: name,
2018
+ ref,
2019
+ name: swarmLib.leadName(lead.slug),
2020
+ repo: lead.repo,
2021
+ brief: briefFile,
2022
+ started_at: new Date().toISOString(),
2023
+ });
2024
+ acted.push({ slug: lead.slug, dryRun: false, brief: briefFile, repo: lead.repo, detail });
2025
+ } catch (e) {
2026
+ // One project failing to come up must not abort the others — but it must be LOUD, and the
2027
+ // run must exit non-zero. A half-started swarm that reports success is the worst outcome.
2028
+ failures.push({ slug: lead.slug, reason: e.message });
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ if (args.json) {
2034
+ console.log(
2035
+ JSON.stringify(
2036
+ { ok: plan.refusals.length === 0 && failures.length === 0, backend: name,
2037
+ backendSource: chosen.source, exec: chosen.exec, execSource: chosen.execSource,
2038
+ dryRun, config: config.file,
2039
+ started: acted, skipped: plan.skips, refused: [...plan.refusals, ...failures],
2040
+ wouldStart: status ? plan.starts.map((s) => s.slug) : undefined },
2041
+ null, 2,
2042
+ ),
2043
+ );
2044
+ process.exit(plan.refusals.length || failures.length ? 1 : 0);
2045
+ }
2046
+
2047
+ console.log(`backend: ${backendLabel(chosen)} · config: ${config.file}${dryRun ? ' · DRY RUN — nothing will start' : ''}\n`);
2048
+
2049
+ for (const skip of plan.skips) {
2050
+ console.log(`${SWARM_STATE_LABEL[skip.state] ?? 'OK '} ${skip.slug.padEnd(22)} ${skip.state.padEnd(10)} ${skip.detail}`);
2051
+ }
2052
+
2053
+ if (status) {
2054
+ for (const lead of plan.starts) {
2055
+ console.log(`FAIL ${lead.slug.padEnd(22)} ${'uncovered'.padEnd(10)} no lead is tailing "${lead.slug}". \`crux swarm up\` would start one.`);
2056
+ }
2057
+ }
2058
+
2059
+ for (const start of acted) {
2060
+ console.log(
2061
+ `START ${start.slug.padEnd(22)} ${(start.dryRun ? 'would start' : 'started').padEnd(10)} ` +
2062
+ `${start.detail ?? `in ${start.repo}`}\n ${''.padEnd(22)} ${''.padEnd(10)} brief: ${start.brief}`,
2063
+ );
2064
+ }
2065
+
2066
+ for (const refusal of [...plan.refusals, ...failures]) {
2067
+ console.log(`FAIL ${String(refusal.slug).padEnd(22)} ${'refused'.padEnd(10)} ${refusal.reason}`);
2068
+ }
2069
+
2070
+ const parts = [];
2071
+ if (acted.length) parts.push(`${acted.length} ${dryRun ? 'would start' : 'started'}`);
2072
+ if (status && plan.starts.length) parts.push(`${plan.starts.length} uncovered`);
2073
+ for (const [state, n] of Object.entries(
2074
+ plan.skips.reduce((acc, s) => ({ ...acc, [s.state]: (acc[s.state] || 0) + 1 }), {}),
2075
+ )) {
2076
+ parts.push(`${n} ${state}`);
2077
+ }
2078
+ if (plan.refusals.length + failures.length) parts.push(`${plan.refusals.length + failures.length} refused`);
2079
+ console.log(`\n${parts.length ? parts.join(', ') : 'nothing to do'}`);
2080
+
2081
+ // `status` is a health check: uncovered is a failure, exactly as `crux leads` treats it.
2082
+ const bad = plan.refusals.length + failures.length + (status ? plan.starts.length : 0);
2083
+ process.exit(bad ? 1 : 0);
2084
+ }
2085
+
2086
+ /** `swarm down` — stops ONLY what `swarm up` started. A lead crux did not start is left alone. */
2087
+ async function swarmDown({ args, backends_, started, tails, only, dryRun }) {
2088
+ const plan = swarmLib.planDown({ started, tails, only });
2089
+
2090
+ const stopped = [];
2091
+ for (const entry of plan.stops) {
2092
+ if (dryRun) {
2093
+ stopped.push({ ...entry, outcome: 'would stop' });
2094
+ continue;
2095
+ }
2096
+ let outcome;
2097
+ try {
2098
+ outcome = backends_(entry.backend).stop(entry);
2099
+ } catch (e) {
2100
+ plan.refusals.push({ slug: entry.slug, reason: `could not stop it: ${e.message}` });
2101
+ continue;
2102
+ }
2103
+
2104
+ // A stop crux could not make sense of — and the one signal that can settle it.
2105
+ //
2106
+ // `exec` is the backend that reaches this: it hands the user's own `stop` line to a shell, and a
2107
+ // shell that exits non-zero does not say WHY. `tmux kill-session` fails identically whether the
2108
+ // session is already gone or the template is broken, and those two need opposite answers: the
2109
+ // first must be forgotten (or `down` refuses forever, and the state entry can never be cleared),
2110
+ // the second must be refused (or crux reports a running lead as stopped, which is the one outcome
2111
+ // worse than failing).
2112
+ //
2113
+ // So ask the OS, which is where coverage lives for every other question crux asks: is anything
2114
+ // still running `crux tail --project <slug>`? Nothing tailing means there is no lead left to
2115
+ // stop — that is an observation, not a guess. Something tailing means the lead outlived the stop,
2116
+ // and THAT is refused, loudly, with the ref to go and look at.
2117
+ if (outcome === 'unknown' && !tails.some((t) => t.slug === entry.slug)) outcome = 'already-gone';
2118
+
2119
+ // Forgotten only once it is actually down. A lead forgotten while still running is a lead crux
2120
+ // can never stop again — and the next `up` would start a second one on top of it.
2121
+ if (outcome === 'stopped' || outcome === 'already-gone') swarmState.forgetLead(entry.slug);
2122
+ else plan.refusals.push({ slug: entry.slug, reason: `its backend could not stop ${entry.ref} — check it by hand.` });
2123
+ if (outcome !== 'unknown') stopped.push({ ...entry, outcome });
2124
+ }
2125
+
2126
+ if (!dryRun) {
2127
+ for (const skip of plan.skips) {
2128
+ if (skip.state === 'already-gone') swarmState.forgetLead(skip.slug);
2129
+ }
2130
+ }
2131
+
2132
+ if (args.json) {
2133
+ console.log(JSON.stringify({ ok: plan.refusals.length === 0, dryRun, stopped, skipped: plan.skips, refused: plan.refusals }, null, 2));
2134
+ process.exit(plan.refusals.length ? 1 : 0);
2135
+ }
2136
+
2137
+ if (dryRun) console.log('DRY RUN — nothing will be stopped\n');
2138
+
2139
+ for (const entry of stopped) {
2140
+ console.log(`STOP ${entry.slug.padEnd(22)} ${String(entry.outcome).padEnd(12)} ${entry.backend} ${entry.ref}`);
2141
+ }
2142
+ for (const skip of plan.skips) {
2143
+ console.log(`OK ${skip.slug.padEnd(22)} ${skip.state.padEnd(12)} ${skip.detail}`);
2144
+ }
2145
+ for (const refusal of plan.refusals) {
2146
+ console.log(`FAIL ${String(refusal.slug).padEnd(22)} ${'refused'.padEnd(12)} ${refusal.reason}`);
2147
+ }
2148
+
2149
+ if (!stopped.length && !plan.skips.length && !plan.refusals.length) {
2150
+ console.log('crux swarm has not started any leads — nothing to stop.');
2151
+ }
2152
+
2153
+ process.exit(plan.refusals.length ? 1 : 0);
2154
+ }
2155
+
2156
+ const USAGE = `crux — push items, report activity, read the feed.
2157
+
2158
+ Commands:
2159
+ discover [path] [--yes] [--json] turn a repo into a crux project — step zero.
2160
+ Reads what it can (name, stack, keywords),
2161
+ asks for the rest, and writes both the
2162
+ project and the local swarm config.
2163
+ Then: crux swarm up
2164
+ push [--project <slug>] [--source <s>] "<text>" POST an item (routes if no project)
2165
+ activity [--project <slug>] [--item <id>] log progress; --item posts it onto that
2166
+ [--actor <n>] [--type <t>] item's thread, where the app can read it;
2167
+ [--image <path>]… [--notify] "<text>" --image attaches a picture to it, and
2168
+ --notify pushes it to your phone
2169
+ decide < decision.json pipe a Decision (or array) to the API
2170
+ feed [--project <slug>] [--status <s>] [--json] read the item feed
2171
+ next [--claim] [--source <s>] [--actor <n>] print one unmatched item as JSON,
2172
+ oldest first; --claim leases it.
2173
+ Exits ${EXIT_EMPTY} when the queue is empty.
2174
+ tail [--unrouted | --project <slug>] watch for new events; print one flushed
2175
+ [--topics items,activities,decisions] JSON line per record, silence otherwise.
2176
+ [--interval 60] [--since <ts>] Durable cursor; runs until killed.
2177
+ route <id> --project <slug> [--note "<why>"] reassign an item to a project
2178
+ done <id> [--note "<why>"] [--drop] close an item (--drop = dropped, not done)
2179
+ and stop its agent — a thread's death is
2180
+ its worker's
2181
+ cancel <id> [--note "<why>"] stop a thread's worker ON PURPOSE, without
2182
+ the thread reading it as a crash. The verb
2183
+ for a mid-flight re-brief; the thread stays
2184
+ open, ready to spawn again.
2185
+ need --item <id> --kind <k> [flags] ["<q>"] raise a needs-you block (the escalation verb):
2186
+ decide|answer|secret|desk|world
2187
+ accept <id> [--actor <n>] [--agent-name <n>] mark a thread accepted → "working"
2188
+ spawn <id> --project-id <n> [--brief "<t>"] start the worker for a thread. It reports to
2189
+ [--wait] [--live] the thread and exits, both mechanically.
2190
+ --live is interactive + Remote Control
2191
+ instead, openable from the Claude app.
2192
+ supervise <id> --process <n> wait for a headless worker and post its
2193
+ report. crux spawn does this for you.
2194
+ projects [--json] list projects
2195
+ leads [--fix] [--json] reconcile the Solo lead fleet against the
2196
+ running \`crux tail\` processes. Exits 1
2197
+ when anything is uncovered.
2198
+ swarm up|status|down [--backend solo|exec|herdr] spin up standing leads — one per project —
2199
+ [--project <slug>]… [--dry-run] on your own orchestrator. Writes the lead
2200
+ brief crux ships, starts a lead for every
2201
+ uncovered project, and is idempotent.
2202
+ \`down\` stops only what it started.
2203
+
2204
+ Run \`crux <command> --help\` for a command's own flags.
2205
+
2206
+ Config: env CRUX_API_URL / CRUX_API_TOKEN, then ~/.config/crux/config.json`;
2207
+
2208
+ /**
2209
+ * One entry per subcommand. `flags` is the source of truth for two things the dispatcher
2210
+ * does before the handler ever runs: reject a flag the command doesn't understand, and
2211
+ * print `usage` for --help/-h. A flag a handler reads must be listed here or the command
2212
+ * refuses to start — the coupling is the point. `--help` is accepted everywhere implicitly.
2213
+ */
2214
+ const COMMANDS = {
2215
+ discover: {
2216
+ run: discover,
2217
+ flags: ['yes', 'json', 'slug', 'name', 'color', 'keywords', 'description'],
2218
+ usage: `crux discover [path] [--yes] [--json]
2219
+ [--slug <s>] [--name <n>] [--color <c>] [--keywords <a,b>] [--description <d>]
2220
+
2221
+ Turn a repo into a crux project. This is step zero: \`crux swarm up\` needs a project and a repo
2222
+ path, and before this command the only way to have one was to hand-edit a config file.
2223
+
2224
+ crux discover # point it at a repo, answer a few questions
2225
+ crux swarm up # a standing lead is now working it
2226
+ crux push "…" # give it something to do
2227
+
2228
+ It DISCOVERS what it can and only ASKS for the rest, so the common case is Enter, Enter, Enter:
2229
+
2230
+ the repo path resolved from git (a subdirectory or a worktree still finds the root)
2231
+ the name the \`origin\` remote, else package.json / composer.json, else the directory
2232
+ what it is Laravel, a Node CLI, Rails, Go… read from the manifests. A lead that knows
2233
+ it is standing in a Laravel app writes better briefs.
2234
+ keywords derived from all of the above — this is what ROUTES a captured item here,
2235
+ so they matter more than they look
2236
+
2237
+ Then it writes BOTH places, and both must land: the crux PROJECT (POST /api/projects — so it
2238
+ exists in the feed, the app and the web) and the local SWARM CONFIG (so \`crux swarm up\` knows
2239
+ which repo the lead runs in). A project with no repo cannot be led.
2240
+
2241
+ IDEMPOTENT. Run it twice and you get one project and one config entry, not two. A slug already
2242
+ taken by a DIFFERENT repo is refused, and so is a repo that is already another project — one
2243
+ directory with two leads is two agents racing one codebase. It never reports success while doing
2244
+ nothing: a run that changes nothing says \`unchanged\`, and names what it compared.
2245
+
2246
+ --yes take every default and ask nothing (scripts, and the impatient)
2247
+ --json machine output; implies --yes
2248
+ --slug <s> override the derived slug — also how you point at an existing project
2249
+ --name <n> override the derived display name
2250
+ --color <c> ${discoverLib.PALETTE.join(' | ')}
2251
+ --keywords <a,b> comma-separated, replacing the derived list
2252
+ --description <d> one line. The router renders it into its prompt, so keep it a summary.
2253
+
2254
+ crux discover # this repo, interactively
2255
+ crux discover ~/src/acme --yes # every default, no questions
2256
+ crux discover ~/src/acme --slug orbit # register it under a slug of your choosing`,
2257
+ },
2258
+ push: {
2259
+ run: push,
2260
+ flags: ['project', 'source'],
2261
+ usage: `crux push [--project <slug>] [--source <s>] "<text>"
2262
+ crux push [--project <slug>] [--source <s>] < text-on-stdin
2263
+
2264
+ POST an item. Text comes from the positional args, else stdin.
2265
+
2266
+ --project <slug> file it directly; omit to let the API route by keyword
2267
+ --source <s> where it came from (e.g. cli, capture, slack)`,
2268
+ },
2269
+ activity: {
2270
+ run: activity,
2271
+ flags: ['project', 'item', 'actor', 'type', 'image', 'notify', 'status'],
2272
+ usage: `crux activity [--project <slug>] [--item <id>] [--actor <name>] [--type <t>]
2273
+ [--image <path>]… [--notify] [--status "<phrase>"] "<text>"
2274
+
2275
+ Log progress against a project. Text comes from the positional args, else stdin.
2276
+
2277
+ An activity with --item is a message on that item's thread: it is what the app renders when
2278
+ someone opens the item, and it is how a lead's answer reaches the person who asked. Without
2279
+ --item the activity is filed against the project only, and no item view will ever show it.
2280
+
2281
+ --image attaches a picture to the message — a screenshot of what you built, a chart, the actual
2282
+ failure. Both the phone and the web chat render it inline in the bubble. Repeat it for several.
2283
+ The paths are checked before the activity is posted, so a wrong one costs nothing; a network
2284
+ failure afterwards is reported but never retracts the message, which is already on the thread.
2285
+
2286
+ --project <slug> the project the work belongs to
2287
+ --item <id> the item this is about — answer the question on the thread it came from
2288
+ --actor <name> who did it (defaults server-side)
2289
+ --type <t> activity type (routed | progress | status | note | …)
2290
+ --image <path> attach an image (jpg | png | webp | gif, ≤10MB) — repeatable
2291
+ --notify push this one activity to your phone. For handing over the thing you
2292
+ asked for — an answer, a result, a link — not for saying you are blocked.
2293
+ Blocked is \`crux need\`, and that distinction is what the escalations view
2294
+ is built on. Without the flag, an activity is silent.
2295
+ --status "<phrase>" set the thread's STATUS LINE — one short phrase, what is happening NOW
2296
+ ("running the migration"). It shows under the title on the row, small and
2297
+ grey. Nothing generates it: the row says what you last said it says, so
2298
+ carry it on the reports you are already writing and it can never go stale.
2299
+ Requires --item; a status line belongs to a thread, not to a project.`,
2300
+ },
2301
+ decide: {
2302
+ run: decide,
2303
+ flags: [],
2304
+ usage: `crux decide < decision.json
2305
+
2306
+ Pipe a Decision object — or an array of them — to the decisions API.
2307
+ Takes no flags; the document is read from stdin.`,
2308
+ },
2309
+ feed: {
2310
+ run: feed,
2311
+ flags: ['project', 'status', 'limit', 'json'],
2312
+ usage: `crux feed [--project <slug>] [--status <s>] [--limit <n>] [--json]
2313
+
2314
+ Read the item feed, newest first.
2315
+
2316
+ --project <slug> only this project's items
2317
+ --status <s> only items in this status (e.g. unmatched, done)
2318
+ --limit <n> cap the number of items returned
2319
+ --json print the raw API response instead of one line per item`,
2320
+ },
2321
+ next: {
2322
+ run: next,
2323
+ flags: ['claim', 'source', 'project', 'actor'],
2324
+ usage: `crux next [--claim] [--source <s>] [--project <slug>] [--actor <name>]
2325
+
2326
+ Print exactly ONE unmatched item as JSON, oldest first. Exits ${EXIT_EMPTY} when the
2327
+ queue is empty, so a polling loop needs no jq:
2328
+
2329
+ crux next --claim --source capture --actor god-agent || exit 0
2330
+
2331
+ --claim take an exclusive lease first, so two overlapping pollers
2332
+ never both act on the same item
2333
+ --source <s> only items from this source; prefer --source capture for
2334
+ consumers with local side effects (human-typed input only)
2335
+ --project <slug> only this project's items
2336
+ --actor <name> recorded on the claim (default: consumer)`,
2337
+ },
2338
+ tail: {
2339
+ run: tail,
2340
+ flags: ['unrouted', 'project', 'topics', 'interval', 'since', 'status', 'actor'],
2341
+ usage: `crux tail [--unrouted | --project <slug>] [--topics <t,…>] [--interval <s>] [--since <ts>]
2342
+
2343
+ Watch for new events. Prints ONE flushed single-line JSON record per new event,
2344
+ oldest first, and nothing otherwise — silence always means "no new input".
2345
+ Runs until killed. The cursor is durable (${CURSOR_FILE.replace(os.homedir(), '~')}),
2346
+ so a restart resumes where it left off; delivery is at-least-once.
2347
+
2348
+ --unrouted the untriaged queue — everything the router couldn't place
2349
+ --project <slug> one project's feed (mutually exclusive with --unrouted)
2350
+ --topics <t,…> comma-separated, from: ${TAIL_TOPICS.join(', ')} (default: items)
2351
+ --interval <s> seconds between polls (default: 60)
2352
+ --since <ts> start from this cursor/timestamp, overriding the stored one
2353
+ --status <s> raw status filter, if --unrouted is too blunt
2354
+ --actor <name> who is listening, announced on every poll and shown as the project's
2355
+ listener status dot (default: <user>@<host>). Self-asserted, not
2356
+ authenticated — it is a health signal, not a credential.
2357
+
2358
+ crux tail --unrouted
2359
+ crux tail --project crux --topics items,activities,decisions --actor lead-crux`,
2360
+ },
2361
+ route: {
2362
+ run: route,
2363
+ flags: ['project', 'actor', 'note'],
2364
+ usage: `crux route <id> --project <slug> [--actor <name>] [--note "<why>"]
2365
+
2366
+ Triage verb: reassign an item to a project and re-enter the routed flow.
2367
+
2368
+ --project <slug> destination project (required)
2369
+ --actor <name> who routed it
2370
+ --note "<why>" recorded on the activity the route logs`,
2371
+ },
2372
+ done: {
2373
+ run: done,
2374
+ flags: ['note', 'actor', 'drop', 'status'],
2375
+ usage: `crux done <id> [--note "<why>"] [--actor <name>] [--drop] [--status "<phrase>"]
2376
+
2377
+ Triage verb: close an item.
2378
+
2379
+ Closing a thread you are actually in — one you captured from the app or the web bar, or one you
2380
+ have replied on — pushes "Done" to your phone. Fleet housekeeping (a thread you never touched, a
2381
+ lead channel) closes silently, and --drop never pushes: dropped is abandoned, not delivered.
2382
+
2383
+ --drop record it as dropped rather than done — same terminal state
2384
+ to the queue, different meaning to whoever reads the feed
2385
+ --note "<why>" recorded on the activity the close logs
2386
+ --actor <name> who closed it
2387
+ --status "<phrase>" the thread's RESTING status line — what is true about it now that it is
2388
+ closed ("merged, closed"), not what it was last doing. Omit it and the
2389
+ close writes "done" (or "dropped") itself. Closing ALWAYS rewrites the
2390
+ line, so a finished thread can never sit there still announcing work that
2391
+ has stopped. (Not to be confused with --drop, which sets the disposition.)`,
2392
+ },
2393
+ cancel: {
2394
+ run: cancel,
2395
+ flags: ['note', 'actor', 'process'],
2396
+ usage: `crux cancel <id> [--note "<why>"] [--actor <name>] [--process <n>]
2397
+
2398
+ Stop a thread's worker deliberately — and have the thread say so, instead of announcing a crash.
2399
+
2400
+ This is the verb for "the brief is now wrong": the plan changes mid-flight, you stop the
2401
+ worker, re-brief, and \`crux spawn\` a new one. The thread stays open. (\`crux done\` is the other
2402
+ thing: the thread is finished, and its worker dies with it.)
2403
+
2404
+ Stopping a worker by hand with \`solo processes stop\` does NOT do this. Its supervisor cannot see
2405
+ intent — only a process that ended having printed nothing, which is exactly what a crash looks like
2406
+ — so it posts "Worker failed … it crashed, was killed, or never started" and pushes that to your
2407
+ phone. This command leaves a tombstone for that exact worker BEFORE killing it, so the supervisor
2408
+ reports a cancellation instead. A worker killed any other way is still reported as dead, loudly, and
2409
+ that is on purpose: silence with nobody claiming it remains the bug.
2410
+
2411
+ --note "<why>" why you stopped it — goes on the thread, and into the supervisor's note.
2412
+ Worth writing: it is what the next reader (and the next worker) sees.
2413
+ --actor <name> who cancelled it (default: lead)
2414
+ --process <n> the Solo process to stop, if the item has none recorded
2415
+
2416
+ crux cancel 74 --note "the approach changed — re-briefing" --actor lead-crux`,
2417
+ },
2418
+ need: {
2419
+ run: need,
2420
+ flags: ['item', 'kind', 'option', 'recommendation', 'where', 'when', 'who', 'actor', 'status'],
2421
+ usage: `crux need --item <id> --kind decide|answer|secret|desk|world [flags] ["<question>"]
2422
+
2423
+ Raise a needs-you block on a thread — the typed successor to the raw "NEEDS YOU: …" activity
2424
+ the escalations view never rendered. An open block is what makes a thread wait on you, so this
2425
+ is the escalation verb. The forcing function: a block may not wait on raw dumped context.
2426
+
2427
+ --item <id> the thread the block is on (required)
2428
+ --kind <k> decide | answer | secret | desk | world (required)
2429
+ --option "<label>" a decide choice — repeat for each option (decide needs ≥1)
2430
+ --recommendation <r> the suggested option (decide)
2431
+ "<question>" a one-line framed question (required for every non-decide kind;
2432
+ positional text, else stdin)
2433
+ --where <w> phone-ok | desk-required (desk is always desk-required)
2434
+ --when <t> now | at-time | when-at-desk
2435
+ --who <p> nobody | an-agent | an-external-person (external trips the
2436
+ client-comms rule — never auto-answer a client)
2437
+ --actor <name> the lead/agent raising it
2438
+ --status "<phrase>" the thread's status line while it waits ("blocked on the DNS answer").
2439
+ Blocking IS a status change; say so in the same call.
2440
+
2441
+ crux need --item 8 --kind decide --option Hourly --option "Twice daily" --recommendation "Twice daily"
2442
+ crux need --item 8 --kind answer "What does 'term' mean for recurring subs?"`,
2443
+ },
2444
+ accept: {
2445
+ run: accept,
2446
+ flags: ['actor', 'agent-name', 'agent-session-id', 'remote-control-url', 'title', 'status', 'reopen'],
2447
+ usage: `crux accept <id> [--title "<short title>"] [--status "<phrase>"] [--actor <name>] [--reopen]
2448
+ [--agent-name <n>] [--agent-session-id <id>] [--remote-control-url <url>]
2449
+
2450
+ Mark a thread accepted — who picked it up and when. With no open block that is what makes the
2451
+ thread read "working"; \`crux done\` clears acceptance again.
2452
+
2453
+ This is also where a thread gets its NAME. You have just read it and you are about to work it, so
2454
+ you already know what it is called — nothing on the server generates a title, and a thread you
2455
+ accept without --title stays untitled forever (its row goes on showing the raw captured text).
2456
+
2457
+ A CLOSED thread (done | dropped) is REFUSED. Accepting one used to report success and change
2458
+ nothing anyone could see — the board went on saying finished while the thread was worked — so it
2459
+ is now an error, and --reopen is how you say you meant it.
2460
+
2461
+ --title "<short title>" name the thread. A COUPLE OF WORDS ("auth timeout", "invoice
2462
+ rounding") — it is a name, not a summary, and the row clips it to
2463
+ one line. This is the row's main line from now on.
2464
+ --status "<phrase>" the status line under the title — what is happening NOW, one short
2465
+ phrase ("reading the failing test"). Update it as you work by
2466
+ passing --status to activity / need / done.
2467
+ --reopen bring a done/dropped thread BACK: status → routed, un-archived, and a
2468
+ "reopened" line on the thread so it records that it came back. Say it
2469
+ on purpose — for follow-up work on a closed thread, the other honest
2470
+ answer is a NEW item that links to it.
2471
+ --actor <name> who (or what) accepted it (default: consumer)
2472
+ --agent-name <n> the per-thread Claude session name baked in with \`claude -n\`
2473
+ --agent-session-id <id> the agent's local transcript uuid on this machine
2474
+ --remote-control-url <url> the agent's claude.ai session — the link crux surfaces
2475
+
2476
+ crux accept 214 --title "auth timeout" --status "reading the failing test" --actor lead-orbit
2477
+ crux accept 100 --reopen --title "hero phone" --status "worker on the follow-up"`,
2478
+ },
2479
+ spawn: {
2480
+ run: spawn,
2481
+ flags: ['project-id', 'tool-id', 'brief', 'actor', 'rc-timeout', 'live', 'wait', 'title', 'status', 'reopen'],
2482
+ usage: `crux spawn <id> --project-id <n> [--tool-id 3] [--brief "<text>"] [--actor <name>] [--wait]
2483
+ [--title "<short title>"] [--status "<phrase>"] [--reopen]
2484
+ [--live [--no-remote-control] [--rc-timeout <seconds>]]
2485
+
2486
+ Start the worker for a thread — and make it report and die, rather than trusting it to remember.
2487
+ Two modes; the worker is reaped in both.
2488
+
2489
+ HEADLESS (default). The agent works the thread, prints its result, and EXITS — its own exit is
2490
+ the reap. Its final message is then posted to the thread automatically, by crux, whether the run
2491
+ succeeded or died. A worker that fails is announced just as loudly as one that finishes: silence
2492
+ is the failure mode this exists to kill.
2493
+
2494
+ LIVE (--live). Interactive, with Remote Control on, so you can open the session from the Claude
2495
+ app and talk to it. This agent never exits on its own, so its life is bounded by its thread's:
2496
+ \`crux done <id>\` stops it. Only this mode gets a claude.ai link (headless mints none).
2497
+
2498
+ --project-id <n> the Solo project to spawn the agent in (required)
2499
+ --tool-id <n> Solo agent-tool id (default: 3 — claude --dangerously-skip-permissions)
2500
+ --brief "<text>" the task. crux wraps it with how the run must end (report / crux need)
2501
+ --actor <name> recorded as who accepted the thread (default: dispatch)
2502
+ --title "<short title>" name the thread — a couple of words. Spawning IS accepting (it posts to
2503
+ the same endpoint), so title it here and you need no separate accept.
2504
+ --status "<phrase>" the status line while the worker runs ("worker on it")
2505
+ --reopen spawn onto a done/dropped thread, reopening it first. Without it, a closed
2506
+ thread is REFUSED before anything is started: a worker whose thread says
2507
+ "finished" reports where nobody is looking for it.
2508
+ --wait (headless) stay in the foreground until the worker reports; exit
2509
+ non-zero if it failed. Otherwise crux returns as soon as it is up and
2510
+ a detached supervisor posts the report.
2511
+ --live interactive + Remote Control instead of headless
2512
+ --no-remote-control (live) local-only agent: no claude.ai session, no link, no waiting
2513
+ --rc-timeout <s> (live) how long to wait for the link before giving up (default: 90)`,
2514
+ },
2515
+ supervise: {
2516
+ run: supervise,
2517
+ flags: ['process', 'project-id', 'actor', 'timeout'],
2518
+ usage: `crux supervise <id> --process <n> [--project-id <n>] [--actor <name>] [--timeout <s>]
2519
+
2520
+ Wait for a headless worker to exit, then post its report to the thread. \`crux spawn\` starts this
2521
+ for you, detached — you should rarely need to type it.
2522
+
2523
+ It exists as a real command because it is the one process whose death would restore the old
2524
+ silence. Solo keeps a dead process's output, so pointing this at an already-exited worker still
2525
+ recovers its report and posts it, however late.
2526
+
2527
+ --process <n> the Solo process id of the worker (required)
2528
+ --project-id <n> narrows the Solo lookup
2529
+ --actor <name> who the report is attributed to (default: thread-<id>)
2530
+ --timeout <s> give up waiting and report it hung (default: 28800 — 8h)`,
2531
+ },
2532
+ projects: {
2533
+ run: projects,
2534
+ flags: ['json'],
2535
+ usage: `crux projects [--json]
2536
+
2537
+ List projects and their routing keywords.
2538
+
2539
+ --json print the raw API response`,
2540
+ },
2541
+ leads: {
2542
+ run: leads,
2543
+ flags: ['fix', 'json', 'dry-run'],
2544
+ usage: `crux leads [--fix] [--dry-run] [--json]
2545
+
2546
+ Reconcile the standing lead fleet: walk every Solo project and report whether a
2547
+ live lead is tailing its crux stream. Coverage is read off the running
2548
+ \`crux tail --project <slug>\` command line, never a process name.
2549
+
2550
+ Reports three failure classes, not just the happy path:
2551
+
2552
+ uncovered a Solo project with no lead tailing it
2553
+ orphan a lead tailing a slug that has no Solo project
2554
+ duplicate two leads on one slug — the stream is double-processed
2555
+
2556
+ plus \`unmapped\` (a Solo project missing from the name→slug map, which is never
2557
+ silently skipped) and \`unmanaged\` (registry drift). Exits 1 if any of those
2558
+ appear, so it works unattended as a health check.
2559
+
2560
+ --fix start the solo.yml command for each uncovered project.
2561
+ Refuses to start a second tail on an already-covered slug.
2562
+ --dry-run with --fix, print what would start without starting it
2563
+ --json machine-readable report
2564
+
2565
+ The Solo-name→crux-slug mapping is explicit config, not string munging
2566
+ (\`acme-search-index\` is \`acme\`; \`status-page\` is \`beacon\`). Override with
2567
+ CRUX_LEADS_CONFIG, or ~/.config/crux/leads.json.`,
2568
+ },
2569
+ swarm: {
2570
+ run: swarm,
2571
+ flags: ['backend', 'project', 'dry-run', 'json'],
2572
+ usage: `crux swarm up [--backend solo|exec|herdr] [--project <slug>]… [--dry-run] [--json]
2573
+ crux swarm status [--backend solo|exec|herdr] [--project <slug>]… [--json]
2574
+ crux swarm down [--project <slug>]… [--dry-run] [--json]
2575
+
2576
+ Spin up standing leads — one per crux project — on your own orchestrator.
2577
+
2578
+ A lead is a durable agent that parks on \`crux tail\`, acks each thread that arrives, answers what
2579
+ it can, spawns workers for real work, escalates with \`crux need\`, and never exits. \`swarm up\`
2580
+ writes the brief that says so (${swarmState.BRIEF_DIR.replace(os.homedir(), '~')}/<slug>.md — read
2581
+ it, edit it, it is yours) and starts one lead per uncovered project.
2582
+
2583
+ up start a lead for every project that has none
2584
+ status what is covered and what is not (exits 1 if anything is uncovered)
2585
+ down stop the leads crux started — and ONLY those
2586
+
2587
+ BACKENDS ("bring your own orchestration, but ship our own opinions")
2588
+
2589
+ Set none of this and crux LOOKS: Solo, then herdr, then tmux (as \`exec\`). \`crux discover\` writes
2590
+ what it found into your config, so you can see it and change it. Nothing on your PATH at all is
2591
+ said out loud at step zero — never one command later, with a lead you were told to start.
2592
+
2593
+ solo preferred when installed. Spawns a Solo agent process per lead, creating the Solo
2594
+ project from the repo path if it does not exist. No solo.yml declaration needed.
2595
+ exec run any command you like — the escape hatch that makes "bring your own" true. With
2596
+ tmux on your PATH and no template set, crux derives this pair for you:
2597
+
2598
+ "swarm": {
2599
+ "backend": "exec",
2600
+ "exec": {
2601
+ "command": "tmux new-session -d -s {name} '{command}'",
2602
+ "stop": "tmux kill-session -t {name}"
2603
+ }
2604
+ }
2605
+
2606
+ Placeholders: ${swarmLib.EXEC_PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}.
2607
+ An unknown one is refused, never expanded to nothing. Set \`stop\` whenever your
2608
+ command forks (tmux, nohup): crux is otherwise left holding a dead pid, and it will
2609
+ tell you so rather than report a lead it did not stop as stopped.
2610
+
2611
+ herdr herdr.dev — a terminal multiplexer built for coding agents. Each lead becomes a named
2612
+ agent pane you can attach to (\`herdr\`), read, and reattach over ssh after a detach:
2613
+
2614
+ "swarm": { "backend": "herdr", "projects": { "crux": { "repo": "~/src/crux" } } }
2615
+
2616
+ Needs \`herdr\` on PATH and a \`repo\` per project. Starts a headless \`herdr server\`
2617
+ if none is running. Optional \`"herdr": { "session": "crux" }\` puts the leads in a
2618
+ named session, isolated from your own; omit it and they land in the default session,
2619
+ where a bare \`herdr\` shows you the whole swarm.
2620
+
2621
+ IDEMPOTENT. \`up\` twice starts nothing the second time. Coverage is read off the OS process
2622
+ table (who is really running \`crux tail --project <slug>\`), plus the leads crux started whose
2623
+ tail has not appeared yet — two tails on one stream race the cursor and double-deliver every item.
2624
+
2625
+ --backend <b> solo | exec | herdr. Default: CRUX_SWARM_BACKEND, else config \`swarm.backend\`,
2626
+ else whatever crux finds on your PATH.
2627
+ --project <slug> only this project — repeatable. Default: every project.
2628
+ --dry-run print the plan and start (or stop) NOTHING
2629
+ --json machine-readable report
2630
+
2631
+ Per-project config lives under \`swarm.projects.<slug>\` in the leads config:
2632
+
2633
+ "swarm": { "projects": { "crux": { "repo": "~/src/crux", "brief": "extra context for the lead" } } }
2634
+
2635
+ \`repo\` is required for any project your backend cannot locate itself (Solo already knows). A
2636
+ project with no repo is REFUSED, loudly — never silently skipped.
2637
+
2638
+ crux swarm up --dry-run # see the plan before it touches anything
2639
+ crux swarm up --project crux # just this one
2640
+ crux swarm status # health check, exits 1 if a project has no lead
2641
+ crux swarm down # stop what crux started; leave everything else alone`,
2642
+ },
2643
+ };
2644
+
2645
+ /** Handlers call this so their usage line and `--help` can never drift apart. */
2646
+ function usageFor(name) {
2647
+ return `usage: ${COMMANDS[name].usage}`;
2648
+ }
2649
+
2650
+ /**
2651
+ * `-h` never reaches parseArgs as a key — it isn't `--`-prefixed — so it lands in the
2652
+ * positionals. Both spellings have to be checked, and both before the handler runs: a
2653
+ * handler like tail() would otherwise swallow the flag and drop into its polling loop.
2654
+ */
2655
+ function wantsHelp(args) {
2656
+ return 'help' in args || args._.includes('-h');
2657
+ }
2658
+
2659
+ /**
2660
+ * An unrecognized flag is a typo or a stale invocation, and silently ignoring it is how
2661
+ * `crux tail --help` came to hang. Fail loudly instead.
2662
+ */
2663
+ function rejectUnknownFlags(name, args) {
2664
+ const allowed = COMMANDS[name].flags;
2665
+ for (const key of Object.keys(args)) {
2666
+ if (key === '_' || key === 'help' || allowed.includes(key)) continue;
2667
+ fail(`unknown flag "--${key}" for "crux ${name}"\n\n${usageFor(name)}`);
2668
+ }
2669
+ }
2670
+
2671
+ async function main() {
2672
+ const args = parseArgs(process.argv.slice(2));
2673
+ const command = args._[0];
2674
+
2675
+ // `crux`, `crux help`, `crux --help`, `crux -h`
2676
+ if (command === undefined || command === 'help' || (!COMMANDS[command] && wantsHelp(args))) {
2677
+ // `crux help tail` — the same page as `crux tail --help`.
2678
+ const topic = command === 'help' ? args._[1] : undefined;
2679
+ if (topic && COMMANDS[topic]) console.log(COMMANDS[topic].usage);
2680
+ else if (topic) fail(`unknown command "${topic}"\n\n${USAGE}`);
2681
+ else console.log(USAGE);
2682
+ return;
2683
+ }
2684
+
2685
+ const spec = COMMANDS[command];
2686
+ if (!spec) fail(`unknown command "${command}"\n\n${USAGE}`);
2687
+
2688
+ if (wantsHelp(args)) {
2689
+ console.log(spec.usage);
2690
+ return;
2691
+ }
2692
+ rejectUnknownFlags(command, args);
2693
+
2694
+ return spec.run(args);
2695
+ }
2696
+
2697
+ main().catch((e) => fail(e && e.message ? e.message : String(e)));