@coulb/crux-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/crux.js +2697 -0
- package/leads.config.json +62 -0
- package/lib/backends.js +617 -0
- package/lib/cancel.js +125 -0
- package/lib/cursor.js +51 -0
- package/lib/discover.js +422 -0
- package/lib/leads.js +292 -0
- package/lib/orchestrator.js +237 -0
- package/lib/spawn.js +436 -0
- package/lib/store.js +119 -0
- package/lib/swarm-state.js +66 -0
- package/lib/swarm.js +628 -0
- package/package.json +40 -0
package/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)));
|