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