@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/lib/spawn.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-thread agent spawning (brief §6/§7) — and the mechanism that makes a worker REPORT and DIE.
|
|
5
|
+
*
|
|
6
|
+
* A spawned worker used to end its life at an idle prompt: task finished, nothing said, process
|
|
7
|
+
* still up. `WORKER-CONTRACT.md` asks the agent to report and to exit, but a contract an agent has
|
|
8
|
+
* to *remember* is not a mechanism — three workers forgot in one day. So both halves are moved into
|
|
9
|
+
* the harness, and there are two modes to move them into:
|
|
10
|
+
*
|
|
11
|
+
* HEADLESS (the default). `claude -p --output-format json` does the work, prints ONE json object
|
|
12
|
+
* — `{result, session_id, is_error, num_turns, total_cost_usd}` — and EXITS. The exit is the reap
|
|
13
|
+
* (nothing to kill, no prompt to idle at) and the `result` is the report, which crux posts to the
|
|
14
|
+
* thread on the agent's behalf. The agent no longer *chooses* to report; its final message simply
|
|
15
|
+
* IS the report. {@see resultJson} recovers that object and {@see workerReport} turns it into what
|
|
16
|
+
* lands on the thread — including when the agent died, which must be just as loud (silence is the
|
|
17
|
+
* bug being killed).
|
|
18
|
+
*
|
|
19
|
+
* LIVE (`--live`). Interactive + `--remote-control`, for a thread the human wants to open and talk
|
|
20
|
+
* to. Headless and Remote Control are mutually exclusive — `-p --remote-control` is accepted but
|
|
21
|
+
* silently mints no URL — so this mode keeps a real pty, and pays for it: an interactive agent
|
|
22
|
+
* never exits on its own, so its reap is bound to its THREAD's death instead (`crux done` stops
|
|
23
|
+
* the Solo process, hence `agent_process_id` on the item).
|
|
24
|
+
*
|
|
25
|
+
* `solo processes spawn --agent-tool-id 3` appends each `--arg` AFTER
|
|
26
|
+
* `claude --dangerously-skip-permissions`, so every flag has to ride in as its own `--arg`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { execFileSync } = require('child_process');
|
|
33
|
+
|
|
34
|
+
/** Solo process statuses that mean the agent is no longer running — see `solo processes list`. */
|
|
35
|
+
const SOLO_TERMINAL = new Set(['exited', 'failed', 'stopped']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `solo … --json`, but tolerantly: null on any failure instead of a fatal error.
|
|
39
|
+
*
|
|
40
|
+
* The counterpart to crux.js's `solo()`, and the distinction is deliberate. SPAWNING is load-bearing
|
|
41
|
+
* — if Solo cannot start the agent there is no agent, and dying loudly is right. Everything AFTER
|
|
42
|
+
* the spawn (polling the worker, reading its buffer, stopping it) is best-effort by contract: a Solo
|
|
43
|
+
* hiccup must not take down the supervisor that is the only thing left to report the worker's death,
|
|
44
|
+
* and `crux done` must still close a thread whose process is already gone.
|
|
45
|
+
*/
|
|
46
|
+
function soloTry(argv) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = execFileSync(process.env.CRUX_SOLO_BIN || 'solo', [...argv, '--json'], {
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
const doc = JSON.parse(raw);
|
|
53
|
+
return doc.ok ? doc.data : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A worker's Solo status: `running` / `exited` / `failed` / …, or `gone` when Solo has answered and
|
|
61
|
+
* has no such process — or NULL when Solo could not be asked at all.
|
|
62
|
+
*
|
|
63
|
+
* That last distinction is the whole reason this returns a status rather than a process. "Solo has
|
|
64
|
+
* forgotten this worker" and "Solo did not pick up the phone" are the same value to any tolerant
|
|
65
|
+
* lookup, and conflating them would be the worst bug in the feature: a Solo hiccup mid-poll would
|
|
66
|
+
* read as a dead agent, and the supervisor would file the worker's obituary while it was still
|
|
67
|
+
* working. So null means *unknown*, and unknown means keep waiting.
|
|
68
|
+
*
|
|
69
|
+
* Paginated, for the reason `soloProcesses()` spells out: an unpaginated list looks complete and is
|
|
70
|
+
* not. A worker that fell off a truncated page would read as `gone` — the same false death.
|
|
71
|
+
*/
|
|
72
|
+
function soloStatus(procId, projectId = null) {
|
|
73
|
+
const wanted = String(procId);
|
|
74
|
+
|
|
75
|
+
for (let offset = 0; ; ) {
|
|
76
|
+
const argv = ['processes', 'list', '--limit', '200', '--offset', String(offset)];
|
|
77
|
+
if (projectId !== undefined && projectId !== null) argv.push('--project-id', String(projectId));
|
|
78
|
+
|
|
79
|
+
const page = soloTry(argv);
|
|
80
|
+
if (page === null) return null; // Could not ask. NOT an answer of "no".
|
|
81
|
+
|
|
82
|
+
const processes = page.processes || [];
|
|
83
|
+
const found = processes.find((p) => String(p.id) === wanted);
|
|
84
|
+
if (found) return found.status;
|
|
85
|
+
|
|
86
|
+
if (!page.hasMore || processes.length === 0) return 'gone';
|
|
87
|
+
offset = page.nextOffset ?? offset + processes.length;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Stop a Solo process, and be honest about which of three things happened: `stopped` (it was alive
|
|
93
|
+
* and now is not), `already-gone` (Solo has it, but it had already exited — `changed: false`), or
|
|
94
|
+
* `unknown` (Solo 404s on it, or is not there to ask).
|
|
95
|
+
*
|
|
96
|
+
* Verified against the real CLI: stopping an exited process is `{ok: true, changed: false}`, not an
|
|
97
|
+
* error. Reaping a corpse being a no-op is exactly what lets `crux done` call this unconditionally —
|
|
98
|
+
* every headless thread's worker is already dead by the time the thread closes.
|
|
99
|
+
*/
|
|
100
|
+
function stopSoloProcess(procId) {
|
|
101
|
+
const data = soloTry(['processes', 'stop', String(procId)]);
|
|
102
|
+
if (data === null) return 'unknown';
|
|
103
|
+
return data.changed === true ? 'stopped' : 'already-gone';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the argv for `solo processes spawn` (everything after the literal `solo`).
|
|
108
|
+
*
|
|
109
|
+
* HEADLESS rides in as `-p --output-format json --session-id <uuid> "<brief>"`:
|
|
110
|
+
*
|
|
111
|
+
* -p print mode — do the work, print the result, exit. THE reaping mechanism.
|
|
112
|
+
* --output-format json the result as one machine-readable object, so the report needs no
|
|
113
|
+
* scraping and a failure is a field rather than a guess.
|
|
114
|
+
* --session-id <uuid> the session id, chosen by US rather than learned afterwards (verified:
|
|
115
|
+
* claude honours it and echoes it back). That is what lets the spawn store
|
|
116
|
+
* a working `claude --resume` handle in the same write that accepts the
|
|
117
|
+
* thread, instead of guessing at the newest jsonl {@see latestSessionId}.
|
|
118
|
+
*
|
|
119
|
+
* LIVE rides in as `--remote-control "<name>" -n "<name>" "<brief>"`. Two different flags take the
|
|
120
|
+
* SAME thread name, and they are not redundant:
|
|
121
|
+
*
|
|
122
|
+
* -n <name> names the LOCAL session (what `claude --resume` lists on this machine).
|
|
123
|
+
* --remote-control <name> registers the session with claude.ai under that name — this is the one
|
|
124
|
+
* that puts `Thread #<id>: <subject>` on the human's phone, and the one that
|
|
125
|
+
* mints the openable per-session URL {@see remoteControlUrl}.
|
|
126
|
+
*
|
|
127
|
+
* `--name` (Solo's process name) is a third, unrelated name. Ordering is the load-bearing part, so
|
|
128
|
+
* it is unit-tested rather than assembled inline.
|
|
129
|
+
*/
|
|
130
|
+
function spawnArgs({
|
|
131
|
+
projectId,
|
|
132
|
+
toolId = 3,
|
|
133
|
+
id,
|
|
134
|
+
subject,
|
|
135
|
+
brief,
|
|
136
|
+
processName,
|
|
137
|
+
live = false,
|
|
138
|
+
sessionId = null,
|
|
139
|
+
remoteControl = true,
|
|
140
|
+
}) {
|
|
141
|
+
const sessionName = threadName(id, subject);
|
|
142
|
+
const claudeArgs = [];
|
|
143
|
+
|
|
144
|
+
if (live) {
|
|
145
|
+
// Remote Control first: it is the flag that makes the agent reachable off this machine.
|
|
146
|
+
if (remoteControl) claudeArgs.push('--arg', '--remote-control', '--arg', sessionName);
|
|
147
|
+
claudeArgs.push('--arg', '-n', '--arg', sessionName);
|
|
148
|
+
} else {
|
|
149
|
+
claudeArgs.push('--arg', '-p', '--arg', '--output-format', '--arg', 'json');
|
|
150
|
+
if (sessionId) claudeArgs.push('--arg', '--session-id', '--arg', sessionId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
claudeArgs.push('--arg', brief);
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
'processes',
|
|
157
|
+
'spawn',
|
|
158
|
+
'--project-id',
|
|
159
|
+
String(projectId),
|
|
160
|
+
'--kind',
|
|
161
|
+
'agent',
|
|
162
|
+
'--agent-tool-id',
|
|
163
|
+
String(toolId),
|
|
164
|
+
'--name',
|
|
165
|
+
processName || `thread-${id}`,
|
|
166
|
+
// Everything below is appended after `claude --dangerously-skip-permissions`.
|
|
167
|
+
...claudeArgs,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Claude's Remote Control session URL, as painted into the TUI: `https://claude.ai/code/session_…`. */
|
|
172
|
+
const REMOTE_CONTROL_URL = /https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pull the Remote Control URL out of a raw pty buffer (`solo processes output <id> --raw`).
|
|
176
|
+
*
|
|
177
|
+
* There is no machine-readable source for this. Claude announces the session once, as a transient
|
|
178
|
+
* toast drawn into the TUI — "Remote control is active · Continue here, on your phone, or at
|
|
179
|
+
* <url>" — and writes it nowhere on disk. So the only way to learn the URL is to read it back off
|
|
180
|
+
* the terminal the agent is drawing to, which is why this is a regex over pty bytes and not a
|
|
181
|
+
* lookup. Solo's RAW buffer is an append-only byte log and keeps the toast; its *rendered* rows are
|
|
182
|
+
* only the current screen, where the toast has long since been overwritten.
|
|
183
|
+
*
|
|
184
|
+
* The bytes are ANSI-laden and `\r`-separated, so escape sequences come out first. Returns null when
|
|
185
|
+
* the toast is not (yet) present — the caller must treat a missing URL as normal, not as failure.
|
|
186
|
+
*/
|
|
187
|
+
function remoteControlUrl(raw) {
|
|
188
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
189
|
+
|
|
190
|
+
const clean = raw
|
|
191
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (window titles) — before CSI, they nest.
|
|
192
|
+
.replace(/\x1b\[[0-9;?>]*[a-zA-Z]/g, '') // CSI (colour, cursor moves)
|
|
193
|
+
.replace(/\x1b[()][AB012]/g, '') // charset selection
|
|
194
|
+
.replace(/\x1b[>=]/g, ''); // keypad mode
|
|
195
|
+
|
|
196
|
+
const match = clean.match(REMOTE_CONTROL_URL);
|
|
197
|
+
return match ? match[0] : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The one json object `claude -p --output-format json` printed, dug back out of the raw pty buffer.
|
|
202
|
+
*
|
|
203
|
+
* Print mode writes its result to stdout and nothing else, so Solo's raw buffer (an append-only byte
|
|
204
|
+
* log — see {@see remoteControlUrl}) holds that object verbatim: no ANSI, no line-wrapping, no
|
|
205
|
+
* interleaving. Verified against a real Solo-spawned headless agent rather than assumed, because the
|
|
206
|
+
* whole report path hangs off it.
|
|
207
|
+
*
|
|
208
|
+
* Even so, this parses rather than trusts: it anchors on the object's opening bytes, brace-matches
|
|
209
|
+
* forward with an awareness of strings (the agent's own `result` text routinely contains `{`, `}`
|
|
210
|
+
* and escaped quotes), and keeps the LAST object that parses cleanly. A buffer that got truncated,
|
|
211
|
+
* or a process that died mid-write, therefore yields null — which is not an edge case to be
|
|
212
|
+
* swallowed but the very signal {@see workerReport} turns into a failure report on the thread.
|
|
213
|
+
*/
|
|
214
|
+
function resultJson(raw) {
|
|
215
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
216
|
+
|
|
217
|
+
const ANCHOR = '{"type":"result"';
|
|
218
|
+
let found = null;
|
|
219
|
+
|
|
220
|
+
for (let at = raw.indexOf(ANCHOR); at !== -1; at = raw.indexOf(ANCHOR, at + 1)) {
|
|
221
|
+
const end = closingBrace(raw, at);
|
|
222
|
+
if (end === -1) continue;
|
|
223
|
+
try {
|
|
224
|
+
found = JSON.parse(raw.slice(at, end + 1));
|
|
225
|
+
} catch {
|
|
226
|
+
// Truncated or interleaved — not the object we want. Keep scanning.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The index of the `}` that closes the `{` at `start`, ignoring braces and quotes inside strings. */
|
|
234
|
+
function closingBrace(text, start) {
|
|
235
|
+
let depth = 0;
|
|
236
|
+
let inString = false;
|
|
237
|
+
let escaped = false;
|
|
238
|
+
|
|
239
|
+
for (let i = start; i < text.length; i++) {
|
|
240
|
+
const char = text[i];
|
|
241
|
+
|
|
242
|
+
if (inString) {
|
|
243
|
+
if (escaped) escaped = false;
|
|
244
|
+
else if (char === '\\') escaped = true;
|
|
245
|
+
else if (char === '"') inString = false;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (char === '"') inString = true;
|
|
250
|
+
else if (char === '{') depth++;
|
|
251
|
+
else if (char === '}' && --depth === 0) return i;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return -1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What the harness posts to the thread when a headless worker's process is over — the mechanised
|
|
259
|
+
* half of `WORKER-CONTRACT.md` §1. A run ends in exactly one of three ways, and this decides which.
|
|
260
|
+
*
|
|
261
|
+
* REPORTED. A finished worker reports its `result` verbatim: that text was written as the agent's
|
|
262
|
+
* final message, which the brief told it IS its report {@see workerBrief}, so it needs no reframing.
|
|
263
|
+
*
|
|
264
|
+
* DIED. A worker that died reports just as loudly, and that is the point of the whole mechanism.
|
|
265
|
+
* Every way a run can end without a usable report is enumerated here — no json printed, `is_error`,
|
|
266
|
+
* a non-zero exit (Solo surfaces it as `failed`; it exposes no exit code, only a status), an empty
|
|
267
|
+
* result, or a run that outlived the harness's patience — because each one used to look identical
|
|
268
|
+
* from the thread: nothing. Silence is the bug. A dead worker also `notify`s, since a note nobody is
|
|
269
|
+
* told about is just a quieter silence.
|
|
270
|
+
*
|
|
271
|
+
* CANCELLED. A lead stopping a worker on purpose is INDISTINGUISHABLE from a crash by observation —
|
|
272
|
+
* process gone, buffer empty — so it is not distinguished by observation. `crux cancel` leaves a
|
|
273
|
+
* tombstone for this exact worker before it kills it, the supervisor consumes it {@see
|
|
274
|
+
* ../lib/cancel}, and passes it here as `cancelled`. Only then is silence forgiven, and only for the
|
|
275
|
+
* ways of dying that a kill actually explains: a `timedOut` run is still RUNNING (the kill never
|
|
276
|
+
* landed) and a worker that got its report out before the axe fell has genuinely reported, so
|
|
277
|
+
* neither is rewritten. No tombstone still means crash — that default is the whole #70 guarantee and
|
|
278
|
+
* is deliberately not flipped.
|
|
279
|
+
*/
|
|
280
|
+
function workerReport({ result, status = null, timedOut = false, cancelled = null }) {
|
|
281
|
+
const text = typeof result?.result === 'string' ? result.result.trim() : '';
|
|
282
|
+
const meta = runMeta(result, status);
|
|
283
|
+
|
|
284
|
+
let died = null;
|
|
285
|
+
if (timedOut) {
|
|
286
|
+
died = `the harness gave up waiting for it (its Solo process is still \`${status}\`)`;
|
|
287
|
+
} else if (result === null || result === undefined) {
|
|
288
|
+
died = `it printed no result at all (Solo process \`${status ?? 'unknown'}\`) — it crashed, was killed, or never started`;
|
|
289
|
+
} else if (result.is_error === true) {
|
|
290
|
+
died = 'it ended in an error';
|
|
291
|
+
} else if (status === 'failed') {
|
|
292
|
+
died = 'it exited non-zero';
|
|
293
|
+
} else if (text === '') {
|
|
294
|
+
died = 'it finished but said nothing';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (died === null) {
|
|
298
|
+
return { ok: true, notify: false, text: `${text}\n\n—\n${meta}` };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (cancelled && !timedOut) {
|
|
302
|
+
const why = typeof cancelled.note === 'string' && cancelled.note.trim() !== '' ? cancelled.note.trim() : null;
|
|
303
|
+
const by = typeof cancelled.actor === 'string' && cancelled.actor.trim() !== '' ? cancelled.actor.trim() : 'the lead';
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
ok: true,
|
|
307
|
+
cancelled: true,
|
|
308
|
+
notify: false,
|
|
309
|
+
text:
|
|
310
|
+
`**Worker cancelled by ${by}${why ? ` — ${why}` : ''}. Not a crash.**\n\n` +
|
|
311
|
+
'It was stopped deliberately, so it printed no report; `crux cancel` said so before killing ' +
|
|
312
|
+
'it. Nothing here failed and nothing needs looking at.' +
|
|
313
|
+
`${text === '' ? '' : `\n\n${text}`}\n\n—\n${meta}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
notify: true,
|
|
320
|
+
text:
|
|
321
|
+
`**Worker failed — ${died}.**${text === '' ? '' : `\n\n${text}`}\n\n` +
|
|
322
|
+
'The agent reported nothing itself; `crux spawn` posted this for it, which is what a worker ' +
|
|
323
|
+
'dying quietly is supposed to look like now.\n\n' +
|
|
324
|
+
`—\n${meta}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The one-line provenance footer under every report: how the run went, and how to reopen it. */
|
|
329
|
+
function runMeta(result, status) {
|
|
330
|
+
const bits = [`solo: ${status ?? 'unknown'}`];
|
|
331
|
+
if (typeof result?.num_turns === 'number') bits.push(`${result.num_turns} turns`);
|
|
332
|
+
if (typeof result?.total_cost_usd === 'number') bits.push(`$${result.total_cost_usd.toFixed(2)}`);
|
|
333
|
+
if (typeof result?.session_id === 'string') bits.push(`\`claude --resume ${result.session_id}\``);
|
|
334
|
+
return bits.join(' · ');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The brief actually handed to a spawned worker: the task, wrapped in the two ways a run is allowed
|
|
339
|
+
* to end (`WORKER-CONTRACT.md`).
|
|
340
|
+
*
|
|
341
|
+
* The wrapper is not a politeness. In headless the agent's final message is *mechanically* lifted
|
|
342
|
+
* onto the thread {@see workerReport}, so the agent has to know that — an agent that signs off with
|
|
343
|
+
* "done!" has technically reported and told the human nothing. And an agent that gets stuck must be
|
|
344
|
+
* told, in the mode where it is true, that there is no one on the other end of its prompt: waiting
|
|
345
|
+
* silently is not patience, it is death. `crux need` is the only thing that reaches a phone.
|
|
346
|
+
*/
|
|
347
|
+
function workerBrief({ id, task, live = false }) {
|
|
348
|
+
const ending = live
|
|
349
|
+
? 'You are running INTERACTIVELY: the human may open this session and talk to you. Even so, ' +
|
|
350
|
+
'never sit idle waiting for them to notice you — they are not watching this pane.'
|
|
351
|
+
: 'You are running HEADLESS: there is no prompt to wait at, no one is watching this process, and ' +
|
|
352
|
+
'your final message is lifted onto the thread the moment you exit. Raise the block, say in ' +
|
|
353
|
+
'that final message that you are blocked on it, and exit.';
|
|
354
|
+
|
|
355
|
+
return [
|
|
356
|
+
`You are the worker for Crux thread #${id}.`,
|
|
357
|
+
'',
|
|
358
|
+
task,
|
|
359
|
+
'',
|
|
360
|
+
'---',
|
|
361
|
+
`HOW THIS RUN ENDS (enforced by the harness, not by you remembering):`,
|
|
362
|
+
'',
|
|
363
|
+
`1. DONE → your FINAL MESSAGE IS YOUR REPORT. When you exit, crux posts it to thread #${id}`,
|
|
364
|
+
' automatically. So make the last thing you say a real report: what you did, where the output',
|
|
365
|
+
' is (branch, paths, PR links), and what the next person needs to know. "Done!" is not a',
|
|
366
|
+
' report. You do not need to — and should not — post it yourself.',
|
|
367
|
+
'',
|
|
368
|
+
'2. BLOCKED needing the human (a decision, an approval, a secret, scope, money, anything',
|
|
369
|
+
' client-facing or irreversible) → raise a typed block. It is the ONLY thing that reaches their',
|
|
370
|
+
' phone; a plain note does not:',
|
|
371
|
+
'',
|
|
372
|
+
` crux need --item ${id} --kind decide|answer|secret|desk|world "<one-line question>"`,
|
|
373
|
+
'',
|
|
374
|
+
` ${ending}`,
|
|
375
|
+
'',
|
|
376
|
+
'Those are the only two endings. Do not end any other way.',
|
|
377
|
+
].join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The Claude session name for a thread — `Thread #<id>: <subject>`, subject trimmed to one line. */
|
|
381
|
+
function threadName(id, subject) {
|
|
382
|
+
const oneLine = String(subject || '')
|
|
383
|
+
.replace(/\s+/g, ' ')
|
|
384
|
+
.trim()
|
|
385
|
+
.slice(0, 60);
|
|
386
|
+
return `Thread #${id}: ${oneLine}`.trim();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The local Claude session id created at spawn — the forward-compatible seam for a future deep link
|
|
391
|
+
* (§7). Claude writes `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`, encoding the cwd by replacing
|
|
392
|
+
* every `/` and `.` with `-`. We return the newest uuid in that directory, or null if none is found
|
|
393
|
+
* (best-effort: a missing seam must never break the spawn).
|
|
394
|
+
*/
|
|
395
|
+
function latestSessionId(cwd, home) {
|
|
396
|
+
const base = home || os.homedir();
|
|
397
|
+
const workdir = cwd || process.cwd();
|
|
398
|
+
const encoded = workdir.replace(/[/.]/g, '-');
|
|
399
|
+
const dir = path.join(base, '.claude', 'projects', encoded);
|
|
400
|
+
|
|
401
|
+
let entries;
|
|
402
|
+
try {
|
|
403
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
if (entries.length === 0) return null;
|
|
408
|
+
|
|
409
|
+
let newest = null;
|
|
410
|
+
for (const file of entries) {
|
|
411
|
+
let mtime = 0;
|
|
412
|
+
try {
|
|
413
|
+
mtime = fs.statSync(path.join(dir, file)).mtimeMs;
|
|
414
|
+
} catch {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (newest === null || mtime > newest.mtime) {
|
|
418
|
+
newest = { id: file.replace(/\.jsonl$/, ''), mtime };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return newest ? newest.id : null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
SOLO_TERMINAL,
|
|
426
|
+
spawnArgs,
|
|
427
|
+
threadName,
|
|
428
|
+
latestSessionId,
|
|
429
|
+
remoteControlUrl,
|
|
430
|
+
resultJson,
|
|
431
|
+
workerReport,
|
|
432
|
+
workerBrief,
|
|
433
|
+
soloTry,
|
|
434
|
+
soloStatus,
|
|
435
|
+
stopSoloProcess,
|
|
436
|
+
};
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared shape of every small JSON file crux keeps under `~/.config/crux`: a whole-map
|
|
5
|
+
* read-modify-write, serialised under an exclusive lock and committed by rename.
|
|
6
|
+
*
|
|
7
|
+
* Both halves matter, and they were learned separately. Write-then-rename makes a *write* atomic —
|
|
8
|
+
* no reader ever sees a half-written map. It does not make the *read-modify-write* safe: several
|
|
9
|
+
* processes share each of these files (seven leads on the tail cursor; a lead and a supervisor on
|
|
10
|
+
* the cancel tombstones), each reads the whole map, edits one key, and writes it all back. Two of
|
|
11
|
+
* them interleaving that sequence lose the other's edit — last writer wins.
|
|
12
|
+
*
|
|
13
|
+
* So the sequence is serialised: `open(…, 'wx')` fails when the lock exists, which is the atomic
|
|
14
|
+
* test-and-set, and the read happens INSIDE the lock so that whatever another process committed
|
|
15
|
+
* while this one was thinking survives. Breaking a stale lock matters as much as taking a fresh
|
|
16
|
+
* one: without it a single `kill -9` mid-write would wedge every crux process on the machine
|
|
17
|
+
* forever, and a wedged fleet is the failure we started from.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
/** A lock older than this belonged to a process killed mid-write, and is broken rather than awaited. */
|
|
24
|
+
const LOCK_STALE_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
/** Enough to outlast any honest holder (an fs read + write), never enough to wedge a caller. */
|
|
27
|
+
const LOCK_TIMEOUT_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
/** Sleep without async: these paths are contended for milliseconds and every caller is synchronous. */
|
|
30
|
+
function spinSleep(ms) {
|
|
31
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Run `fn` holding an exclusive lock on `file`. */
|
|
35
|
+
function withFileLock(file, fn) {
|
|
36
|
+
const lock = `${file}.lock`;
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
|
|
39
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
40
|
+
let fd;
|
|
41
|
+
for (;;) {
|
|
42
|
+
try {
|
|
43
|
+
fd = fs.openSync(lock, 'wx');
|
|
44
|
+
break;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
if (e.code !== 'EEXIST') throw e;
|
|
47
|
+
|
|
48
|
+
let age = 0;
|
|
49
|
+
try {
|
|
50
|
+
age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
51
|
+
} catch {
|
|
52
|
+
continue; // Vanished between open and stat: the holder just released it.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (age > LOCK_STALE_MS) {
|
|
56
|
+
try {
|
|
57
|
+
fs.unlinkSync(lock);
|
|
58
|
+
} catch {
|
|
59
|
+
/* another process broke it first */
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${lock}`);
|
|
65
|
+
spinSleep(20);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return fn();
|
|
71
|
+
} finally {
|
|
72
|
+
fs.closeSync(fd);
|
|
73
|
+
try {
|
|
74
|
+
fs.unlinkSync(lock);
|
|
75
|
+
} catch {
|
|
76
|
+
/* a stale-breaker removed it */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The file's map, or an empty one — a missing or corrupt file reads as "nothing recorded". */
|
|
82
|
+
function readJson(file) {
|
|
83
|
+
try {
|
|
84
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
85
|
+
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Edit one key of `file`'s map without clobbering any other's, and return whatever `mutate` returned.
|
|
93
|
+
*
|
|
94
|
+
* `mutate` is handed the live map and edits it in place; the commit is a rename, so a crash between
|
|
95
|
+
* the write and the rename leaves the old map intact rather than a truncated one.
|
|
96
|
+
*/
|
|
97
|
+
function updateJson(file, mutate) {
|
|
98
|
+
return withFileLock(file, () => {
|
|
99
|
+
const map = readJson(file);
|
|
100
|
+
const result = mutate(map);
|
|
101
|
+
|
|
102
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`);
|
|
105
|
+
fs.renameSync(tmp, file);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
try {
|
|
108
|
+
fs.unlinkSync(tmp);
|
|
109
|
+
} catch {
|
|
110
|
+
/* never written */
|
|
111
|
+
}
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { LOCK_STALE_MS, LOCK_TIMEOUT_MS, withFileLock, readJson, updateJson };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `crux swarm up` started — the record that makes `up` idempotent and `down` safe.
|
|
5
|
+
*
|
|
6
|
+
* Two things need it, and neither can be answered from the process table alone:
|
|
7
|
+
*
|
|
8
|
+
* IDEMPOTENCE. A lead takes a few seconds to get from "process is up" to "tail is running". Inside
|
|
9
|
+
* that window the OS says the slug is uncovered, so a second `swarm up` would start a SECOND lead
|
|
10
|
+
* — two tails on one stream, racing the shared cursor file and delivering every item twice. A lead
|
|
11
|
+
* crux started is therefore remembered the moment it is started, not when its tail appears.
|
|
12
|
+
*
|
|
13
|
+
* OWNERSHIP. `swarm down` stops only what `swarm up` started. The machine may already carry a
|
|
14
|
+
* hand-rolled fleet, and a `down` that killed every `crux tail` it could find would
|
|
15
|
+
* take out leads crux never started, cannot restart, and was never asked to touch. The state file
|
|
16
|
+
* is the only thing that can tell those apart — a process table cannot.
|
|
17
|
+
*
|
|
18
|
+
* Same lock + write-then-rename as the tail cursor and the cancel tombstones {@see ./store}: several
|
|
19
|
+
* crux processes share this file, and a read-modify-write that is not serialised loses edits.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { readJson, updateJson } = require('./store');
|
|
25
|
+
|
|
26
|
+
const STATE_FILE =
|
|
27
|
+
process.env.CRUX_SWARM_STATE || path.join(os.homedir(), '.config', 'crux', 'swarm.json');
|
|
28
|
+
|
|
29
|
+
/** Where a generated standing-lead brief is written. One per slug, readable and editable by a human. */
|
|
30
|
+
const BRIEF_DIR =
|
|
31
|
+
process.env.CRUX_SWARM_BRIEFS || path.join(os.homedir(), '.config', 'crux', 'leads');
|
|
32
|
+
|
|
33
|
+
function briefPath(slug) {
|
|
34
|
+
return path.join(BRIEF_DIR, `${slug}.md`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every lead crux swarm believes it started, keyed by slug. */
|
|
38
|
+
function readState() {
|
|
39
|
+
return readJson(STATE_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remember a lead we just started. `ref` is the backend's own handle — a Solo id, a pid, a name. */
|
|
43
|
+
function recordLead(slug, entry) {
|
|
44
|
+
return updateJson(STATE_FILE, (map) => {
|
|
45
|
+
map[slug] = { ...entry, slug };
|
|
46
|
+
return map[slug];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Forget a lead, under the same lock that reads it.
|
|
52
|
+
*
|
|
53
|
+
* Called only after the stop actually succeeded, or after the lead was found already dead. A lead
|
|
54
|
+
* forgotten while still running would be a lead crux can never stop again — it would fall out of
|
|
55
|
+
* `down` (not ours) and out of `up` (no tail? start another one), which is the double-tail bug
|
|
56
|
+
* arriving by a different road.
|
|
57
|
+
*/
|
|
58
|
+
function forgetLead(slug) {
|
|
59
|
+
return updateJson(STATE_FILE, (map) => {
|
|
60
|
+
const had = Object.hasOwn(map, slug);
|
|
61
|
+
delete map[slug];
|
|
62
|
+
return had;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { BRIEF_DIR, STATE_FILE, briefPath, forgetLead, readState, recordLead };
|