@coulb/crux-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/leads.js ADDED
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reconciliation for the standing lead fleet: does every Solo project have exactly one live
5
+ * lead tailing the right crux stream?
6
+ *
7
+ * The fleet was once down for hours and the only symptom was silence — indistinguishable from
8
+ * "no items to route". Everything here exists to make that ambiguity impossible, so each check
9
+ * is written to be *loud* rather than convenient.
10
+ *
11
+ * ## Why coverage is read off the OS process table, not `solo processes list`
12
+ *
13
+ * A lead's Solo command is `cd /path/to/repo && claude … '<brief>'`. The slug it tails appears
14
+ * nowhere in that string: `crux tail --project <slug>` is started later, by the agent, as a
15
+ * grandchild of the Solo process. So Solo can tell us a lead *process* is running; only the OS
16
+ * can tell us which stream it is actually tailing — and that is the thing we care about.
17
+ *
18
+ * Names are worthless for this. The stopped `kind: agent` rows are named exactly like the new
19
+ * `kind: command` rows, and one of them (`lead-beacon`) is still running and still tailing.
20
+ * A reconciler that trusted names would call `beacon` covered by the command that is in fact
21
+ * stopped, and would happily start a second tail on top of the agent that is really doing the
22
+ * work. So: match on the running command line, attribute it to its owning Solo process by
23
+ * walking the process tree, and never look at a name.
24
+ */
25
+
26
+ /** A tail is the node process running the `crux` script — never a `timeout`/`zsh -c` wrapper. */
27
+ const TAIL_COMMAND = /^(?:\S*\/)?node(?:\.exe)?\s+(?:\S*\/)?crux(?:\.js)?\s+(.*)$/;
28
+
29
+ const ERROR_STATES = ['unmapped', 'uncovered', 'duplicate', 'orphan'];
30
+
31
+ /**
32
+ * Parse `ps -axo pid=,ppid=,command=` into a pid -> {pid, ppid, command} map.
33
+ *
34
+ * @param {string} output
35
+ * @returns {Map<number, {pid: number, ppid: number, command: string}>}
36
+ */
37
+ function parseProcessTable(output) {
38
+ const table = new Map();
39
+ for (const line of output.split('\n')) {
40
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line);
41
+ if (!match) continue;
42
+ const pid = Number(match[1]);
43
+ table.set(pid, { pid, ppid: Number(match[2]), command: match[3] });
44
+ }
45
+ return table;
46
+ }
47
+
48
+ /** Pull `--project x` / `--interval n` / `--unrouted` out of a `crux tail` argument string. */
49
+ function parseTailArgs(argString) {
50
+ const argv = argString.trim().split(/\s+/);
51
+ if (argv[0] !== 'tail') return null;
52
+
53
+ const flag = (name) => {
54
+ const i = argv.indexOf(`--${name}`);
55
+ if (i === -1) return undefined;
56
+ const next = argv[i + 1];
57
+ return next !== undefined && !next.startsWith('--') ? next : true;
58
+ };
59
+
60
+ const project = flag('project');
61
+ return {
62
+ slug: typeof project === 'string' ? project : null,
63
+ unrouted: flag('unrouted') === true,
64
+ interval: typeof flag('interval') === 'string' ? Number(flag('interval')) : 60,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Every running `crux tail`, one entry per real tail.
70
+ *
71
+ * `timeout 570 crux tail …` and `zsh -c "… eval 'crux tail …'"` both *contain* the command
72
+ * line, and both really are ancestors of one tail. Matching only the node process is what
73
+ * keeps a wrapped tail from being miscounted as two — which would report a phantom duplicate.
74
+ *
75
+ * @param {Map<number, {pid: number, ppid: number, command: string}>} table
76
+ */
77
+ function findTails(table) {
78
+ const tails = [];
79
+ for (const { pid, command } of table.values()) {
80
+ const match = TAIL_COMMAND.exec(command);
81
+ if (!match) continue;
82
+ const args = parseTailArgs(match[1]);
83
+ if (!args) continue;
84
+ tails.push({ pid, ...args, command });
85
+ }
86
+ return tails.sort((a, b) => a.pid - b.pid);
87
+ }
88
+
89
+ /**
90
+ * Attribute each tail to the Solo process that owns it by walking up the process tree.
91
+ *
92
+ * A tail with no Solo ancestor is running outside the supervisor — it covers the stream right
93
+ * now but nothing will restart it, so it is reported rather than counted as healthy coverage.
94
+ *
95
+ * @param {Array<object>} tails
96
+ * @param {Map<number, {pid: number, ppid: number, command: string}>} table
97
+ * @param {Map<number, object>} soloByPid
98
+ */
99
+ function attributeOwners(tails, table, soloByPid) {
100
+ return tails.map((tail) => {
101
+ const seen = new Set();
102
+ let pid = tail.pid;
103
+ while (pid && pid !== 1 && table.has(pid) && !seen.has(pid)) {
104
+ seen.add(pid);
105
+ if (soloByPid.has(pid)) return { ...tail, owner: soloByPid.get(pid) };
106
+ pid = table.get(pid).ppid;
107
+ }
108
+ return { ...tail, owner: null };
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Classify every slug into exactly one state. Pure: no processes, no I/O, no clock.
114
+ *
115
+ * States:
116
+ * covered — one running tail, owned by a Solo process. The happy path.
117
+ * uncovered — Solo project expects a lead; nothing is tailing. (failure class 1)
118
+ * orphan — something tails a slug that has no Solo project. (failure class 2)
119
+ * duplicate — two or more tails on one slug; the stream is double-read. (failure class 3)
120
+ * unmapped — a Solo project absent from the mapping. Never skipped silently.
121
+ * leadless — configured to expect no lead (e.g. `personal`). Expected, not an error.
122
+ * unmanaged — a crux slug with no Solo project and no tail. Registry drift; a warning.
123
+ *
124
+ * @param {{soloProjects: Array<{id: number, name: string}>, cruxSlugs: string[],
125
+ * tails: Array<object>, config: object}} input
126
+ */
127
+ function reconcile({ soloProjects, cruxSlugs, tails, config }) {
128
+ const entries = [];
129
+ const slugToSolo = new Map();
130
+
131
+ for (const project of soloProjects) {
132
+ const slug = config.map[project.name];
133
+ if (typeof slug !== 'string') {
134
+ entries.push({
135
+ state: 'unmapped',
136
+ slug: null,
137
+ soloProject: project,
138
+ tails: [],
139
+ detail: `Solo project "${project.name}" (id ${project.id}) is not in the name→slug map. Add it to leads.config.json.`,
140
+ });
141
+ continue;
142
+ }
143
+ slugToSolo.set(slug, project);
144
+ }
145
+
146
+ const projectTails = tails.filter((t) => typeof t.slug === 'string' && t.slug !== '');
147
+ const bySlug = new Map();
148
+ for (const tail of projectTails) {
149
+ if (!bySlug.has(tail.slug)) bySlug.set(tail.slug, []);
150
+ bySlug.get(tail.slug).push(tail);
151
+ }
152
+
153
+ const slugs = [...new Set([...slugToSolo.keys(), ...cruxSlugs, ...bySlug.keys()])].sort();
154
+
155
+ for (const slug of slugs) {
156
+ const own = bySlug.get(slug) ?? [];
157
+ const solo = slugToSolo.get(slug) ?? null;
158
+ const leadlessReason = config.leadless[slug];
159
+
160
+ if (own.length > 1) {
161
+ entries.push({
162
+ state: 'duplicate',
163
+ slug,
164
+ soloProject: solo,
165
+ tails: own,
166
+ detail: `${own.length} tails on "${slug}" (pids ${own.map((t) => t.pid).join(', ')}). The stream is being double-processed.`,
167
+ });
168
+ continue;
169
+ }
170
+
171
+ if (own.length === 1) {
172
+ const tail = own[0];
173
+ if (!solo) {
174
+ entries.push({
175
+ state: 'orphan',
176
+ slug,
177
+ soloProject: null,
178
+ tails: own,
179
+ detail: `pid ${tail.pid} tails "${slug}", which has no Solo project. Nothing will restart it.`,
180
+ });
181
+ continue;
182
+ }
183
+ entries.push({
184
+ state: 'covered',
185
+ slug,
186
+ soloProject: solo,
187
+ tails: own,
188
+ detail: tail.owner
189
+ ? `pid ${tail.pid} under Solo process ${tail.owner.id} (${tail.owner.name}).`
190
+ : `pid ${tail.pid} — running outside Solo; nothing will restart it if it dies.`,
191
+ unsupervised: !tail.owner,
192
+ });
193
+ continue;
194
+ }
195
+
196
+ if (typeof leadlessReason === 'string') {
197
+ entries.push({ state: 'leadless', slug, soloProject: solo, tails: [], detail: leadlessReason });
198
+ continue;
199
+ }
200
+
201
+ if (solo) {
202
+ entries.push({
203
+ state: 'uncovered',
204
+ slug,
205
+ soloProject: solo,
206
+ tails: [],
207
+ detail: `Solo project "${solo.name}" (id ${solo.id}) has no process tailing "${slug}".`,
208
+ });
209
+ continue;
210
+ }
211
+
212
+ entries.push({
213
+ state: 'unmanaged',
214
+ slug,
215
+ soloProject: null,
216
+ tails: [],
217
+ detail: `crux slug "${slug}" has no Solo project and no lead. Registry drift — remove it, or create the project.`,
218
+ });
219
+ }
220
+
221
+ const errors = entries.filter((e) => ERROR_STATES.includes(e.state));
222
+ return { entries, errors, ok: errors.length === 0, exitCode: errors.length === 0 ? 0 : 1 };
223
+ }
224
+
225
+ /** The Solo process name that starts a slug's lead. `lead-<slug>` unless overridden. */
226
+ function leadProcessName(slug, config) {
227
+ return config.leadProcess[slug] ?? `lead-${slug}`;
228
+ }
229
+
230
+ /**
231
+ * Decide what `--fix` would start, and what it must refuse.
232
+ *
233
+ * The refusals are the point. A slug can be covered by a *stopped-looking* `kind:
234
+ * agent` row while its `kind: command` twin sits idle — starting the command would put two
235
+ * tails on one stream. So the plan is built only from `uncovered` entries, and every other
236
+ * state is an explicit, explained refusal rather than a silent no-op.
237
+ *
238
+ * @param {{entries: Array<object>}} report
239
+ * @param {Array<{id: number, name: string, kind: string, status: string, projectName: string}>} soloProcesses
240
+ * @param {object} config
241
+ */
242
+ function planFix(report, soloProcesses, config) {
243
+ const starts = [];
244
+ const refusals = [];
245
+
246
+ for (const entry of report.entries) {
247
+ if (entry.state === 'covered' || entry.state === 'leadless' || entry.state === 'unmanaged') continue;
248
+
249
+ if (entry.state !== 'uncovered') {
250
+ refusals.push({
251
+ slug: entry.slug,
252
+ reason: `${entry.state}: ${entry.detail} --fix only starts leads for uncovered projects.`,
253
+ });
254
+ continue;
255
+ }
256
+
257
+ const wanted = leadProcessName(entry.slug, config);
258
+ const candidates = soloProcesses.filter((p) => p.projectName === config.fleetProject && p.name === wanted);
259
+
260
+ const command = candidates.find((p) => p.kind === 'command');
261
+ if (!command) {
262
+ const why = candidates.length
263
+ ? `only non-command rows exist (${candidates.map((p) => `${p.id}:${p.kind}`).join(', ')}); solo.yml declares no such command`
264
+ : `no process named "${wanted}" in Solo project "${config.fleetProject}"`;
265
+ refusals.push({ slug: entry.slug, reason: `cannot start ${wanted} — ${why}.` });
266
+ continue;
267
+ }
268
+
269
+ if (command.status === 'running') {
270
+ refusals.push({
271
+ slug: entry.slug,
272
+ reason: `${wanted} (id ${command.id}) is already running but is not tailing "${entry.slug}" — restarting it blindly would hide a real fault. Investigate.`,
273
+ });
274
+ continue;
275
+ }
276
+
277
+ starts.push({ slug: entry.slug, process: command });
278
+ }
279
+
280
+ return { starts, refusals };
281
+ }
282
+
283
+ module.exports = {
284
+ ERROR_STATES,
285
+ attributeOwners,
286
+ findTails,
287
+ leadProcessName,
288
+ parseProcessTable,
289
+ parseTailArgs,
290
+ planFix,
291
+ reconcile,
292
+ };
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WHICH orchestrator runs the leads on this machine, and — the question nobody was asking — CAN it.
5
+ *
6
+ * `lib/backends.js` knows how to drive three orchestrators. It does not know whether any of them is
7
+ * installed. Nothing did, and that is the whole bug this file exists to close:
8
+ *
9
+ * $ crux discover ~/src/mercury-billing --yes
10
+ * ✓ mercury-billing is a crux project — created.
11
+ * Next: crux swarm up --project mercury-billing
12
+ *
13
+ * $ crux swarm up --project mercury-billing
14
+ * crux: the exec backend needs a command template — set `swarm.exec.command`, …
15
+ *
16
+ * The error is a good error. It names its fix. It is still a **failed first run**, because the tool
17
+ * printed a command it could have known would not work. Step zero exists to stop exactly that, and a
18
+ * five-minute quickstart that stalls at step two is not saved by the politeness of the message it
19
+ * stalls with.
20
+ *
21
+ * Everything here is PURE. Whether a binary exists is I/O, so it arrives as `has(bin)` — injected,
22
+ * exactly as `planWrite` takes `samePath` and `planUp` takes `now`. The refusals are the product, and
23
+ * a refusal you cannot unit-test is a refusal that rots.
24
+ *
25
+ * ## The three answers, in order
26
+ *
27
+ * 1. **What the user SAID.** `--backend`, then `CRUX_SWARM_BACKEND`, then `swarm.backend` in the
28
+ * config. An explicit choice is never second-guessed — only checked.
29
+ * 2. **What is on the machine.** Nothing said? Then look: Solo, herdr, tmux. This is why the shipped
30
+ * config no longer hardcodes `"backend": "solo"`. That line meant every reader with no Solo — the
31
+ * overwhelmingly common case — got `solo` by default and hit `could not
32
+ * run \`solo projects list\`` on their first run. A default that is wrong for almost everyone who
33
+ * reads it is not a default, it is a trap.
34
+ * 3. **A refusal that names the fix.** No orchestrator at all is a real state, and the honest thing
35
+ * is to say so at the moment discover would otherwise print `Next:` — with the line to add and the
36
+ * file to add it to — rather than one command later.
37
+ *
38
+ * ## Why tmux gets to be a default at all, when exec is generic by design
39
+ *
40
+ * `exec` is the "bring your own orchestration" backend: it cannot know whether you run tmux, screen,
41
+ * systemd or nohup, and it must not pretend to. So the default is not "assume tmux" — it is **"tmux,
42
+ * if and only if tmux is actually installed"**. A default that assumes a binary and then fails is the
43
+ * same bug one layer down. {@see resolveBackend}: the tmux templates are only ever handed back when
44
+ * `has('tmux')` is true, and when it is false the exec backend refuses with the one line to write.
45
+ *
46
+ * And when crux does derive them, `crux discover` WRITES them into the config rather than keeping
47
+ * them in its head. A derived default that lives only in code is invisible and un-editable: the user
48
+ * cannot see what their leads are being started with, cannot change it, and cannot tell crux's
49
+ * opinion from their own. Written down, the derivation is a starting point they own — which is what
50
+ * "ship our own opinions" is supposed to mean.
51
+ */
52
+
53
+ /**
54
+ * The exec templates crux derives when it finds tmux and nothing else has been said.
55
+ *
56
+ * This is the pair from the error message that this whole thread is about, promoted from advice to
57
+ * behaviour — and it is a *pair*, which is the part that matters. `tmux new-session -d` FORKS: the
58
+ * process crux spawns exits immediately while the lead lives on inside the tmux server, so a `command`
59
+ * without a `stop` leaves crux holding a dead pid and `swarm down` correctly refuses to claim a stop
60
+ * it cannot make {@see ../lib/swarm.planDown}. Shipping `command` alone would have been shipping that
61
+ * refusal to every first-time user. With `stop`, the lead is addressable by NAME forever
62
+ * (`addresses: 'handle'`), and `up`/`down` are a clean round trip.
63
+ *
64
+ * `{repo}` is deliberately absent. The exec backend already runs the line with `cwd: lead.repo`, so
65
+ * tmux inherits the right directory from its client — and a `-c {repo}` would put an unquoted path
66
+ * into a shell line, which breaks the first user whose code lives in `~/My Projects`. The safest
67
+ * interpolation is the one you do not do.
68
+ */
69
+ const TMUX_EXEC = Object.freeze({
70
+ command: "tmux new-session -d -s {name} '{command}'",
71
+ stop: 'tmux kill-session -t {name}',
72
+ });
73
+
74
+ /** The backends `makeBackend` can build. Kept here so a typo is refused with the list, as before. */
75
+ const BACKENDS = ['solo', 'exec', 'herdr'];
76
+
77
+ /**
78
+ * The binary each backend cannot run without.
79
+ *
80
+ * `exec` is absent on purpose — it is the one backend with no binary of its own, because its binary
81
+ * is whatever the user's template names. It can only be checked through the template. {@see execFor}
82
+ */
83
+ const NEEDS = { solo: 'solo', herdr: 'herdr' };
84
+
85
+ /**
86
+ * What is on this machine, in the order crux would pick it.
87
+ *
88
+ * Solo first because it is the opinionated default and the richest: it spawns workers, so a Solo lead
89
+ * gets `crux spawn` and every other lead has to do its own work inline {@see ../lib/swarm.renderLeadBrief}.
90
+ * herdr next — a real multiplexer for agents, with named panes you can attach to. tmux last, and it
91
+ * is last precisely because it is the lowest common denominator: it hosts the lead and nothing more.
92
+ *
93
+ * Null means no orchestrator, which is a fact, not an error — and it is a fact worth stating out loud
94
+ * at step zero rather than discovering at step two.
95
+ */
96
+ function detectBackend(has) {
97
+ if (has('solo')) return 'solo';
98
+ if (has('herdr')) return 'herdr';
99
+ if (has('tmux')) return 'exec';
100
+ return null;
101
+ }
102
+
103
+ /** The exec templates in force: what the user wrote, else tmux if tmux is here, else nothing. */
104
+ function execFor(exec, has) {
105
+ const command = typeof exec?.command === 'string' ? exec.command.trim() : '';
106
+ if (command !== '') {
107
+ return {
108
+ exec: { command, stop: typeof exec.stop === 'string' && exec.stop.trim() !== '' ? exec.stop.trim() : null },
109
+ execSource: 'config',
110
+ };
111
+ }
112
+ if (has('tmux')) return { exec: { ...TMUX_EXEC }, execSource: 'tmux' };
113
+ return { exec: null, execSource: null };
114
+ }
115
+
116
+ /** The config block that would make the current derivation explicit. `null` when nothing is derived. */
117
+ function derivedWrites({ name, source, exec, execSource }) {
118
+ const writes = {};
119
+ // Only a DETECTED name is written down. A `--backend` flag or a `CRUX_SWARM_BACKEND` env var is a
120
+ // per-run override — persisting it would turn "just this once" into "forever", silently.
121
+ if (source === 'detected') writes.backend = name;
122
+ if (execSource === 'tmux') writes.exec = { ...exec };
123
+ return Object.keys(writes).length ? writes : null;
124
+ }
125
+
126
+ /**
127
+ * Which backend, can it run, and if not — what is the one line that fixes it.
128
+ *
129
+ * @param {object} input
130
+ * @param {string|null} input.requested `--backend` (a per-run override)
131
+ * @param {string|null} input.env `CRUX_SWARM_BACKEND` (a per-shell override)
132
+ * @param {string|null} input.configured `swarm.backend` (may be absent — then crux looks)
133
+ * @param {object} input.exec `swarm.exec` — `{command, stop}`, either or both possibly absent
134
+ * @param {(bin: string) => boolean} input.has injected: "is this binary on PATH?" is I/O
135
+ * @param {string} [input.file] the config file, so a fix can say WHERE to write it
136
+ *
137
+ * @returns {{
138
+ * name: string|null, source: 'flag'|'env'|'config'|'detected'|'none',
139
+ * exec: {command: string, stop: string|null}|null, execSource: 'config'|'tmux'|null,
140
+ * ok: boolean, problem: {reason: string, fix: string[]}|null,
141
+ * writes: {backend?: string, exec?: object}|null,
142
+ * }}
143
+ */
144
+ function resolveBackend({ requested = null, env = null, configured = null, exec = {}, has, file = 'the leads config' }) {
145
+ const said = (value, source) => (typeof value === 'string' && value.trim() !== '' ? { name: value.trim(), source } : null);
146
+
147
+ const chosen =
148
+ said(requested, 'flag') ??
149
+ said(env, 'env') ??
150
+ said(configured, 'config') ??
151
+ said(detectBackend(has), 'detected') ?? { name: null, source: 'none' };
152
+
153
+ const base = { ...chosen, exec: null, execSource: null, ok: false, problem: null, writes: null };
154
+
155
+ // Nothing said, and nothing found. Not a crash and not a crux bug — this machine has no way to run
156
+ // a long-lived agent yet, and that is a sentence a human can act on.
157
+ if (chosen.name === null) {
158
+ return {
159
+ ...base,
160
+ problem: {
161
+ reason: 'crux found no orchestrator on this machine — a lead is a long-running agent, so something has to host it.',
162
+ fix: [
163
+ 'Install one, and crux will find it — no configuration:',
164
+ ' brew install tmux # the simplest: each lead becomes a tmux session',
165
+ ' (or Solo, or herdr — see `crux swarm --help`)',
166
+ `Or bring your own, by naming the command that starts a lead in ${file}:`,
167
+ ` "swarm": { "backend": "exec", "exec": { "command": "${TMUX_EXEC.command}" } }`,
168
+ ],
169
+ },
170
+ };
171
+ }
172
+
173
+ // A typo in a config file is not a backend. Refuse with the list, exactly as `makeBackend` does —
174
+ // and refuse it HERE, before discover has printed a `Next:` that could never have worked.
175
+ if (!BACKENDS.includes(chosen.name)) {
176
+ return {
177
+ ...base,
178
+ problem: {
179
+ reason: `unknown backend "${chosen.name}" (available: ${BACKENDS.join(', ')})`,
180
+ fix: [`Set \`swarm.backend\` in ${file} to one of: ${BACKENDS.join(', ')}.`],
181
+ },
182
+ };
183
+ }
184
+
185
+ // An explicit choice crux cannot honour. Never silently fall back to a different one: a user who
186
+ // wrote `"backend": "solo"` and gets a tmux lead has been lied to about where their agents are.
187
+ const needs = NEEDS[chosen.name];
188
+ if (needs && !has(needs)) {
189
+ return {
190
+ ...base,
191
+ problem: {
192
+ reason:
193
+ `the ${chosen.name} backend needs \`${needs}\` on your PATH, and it is not installed` +
194
+ `${chosen.source === 'config' ? ` (\`swarm.backend\` in ${file} asks for it)` : ''}.`,
195
+ fix: [
196
+ `Install ${needs}, or pick a backend this machine has:`,
197
+ ' crux swarm up --backend exec # any command you like — tmux, screen, nohup, systemd',
198
+ `Or set \`swarm.backend\` in ${file}.`,
199
+ ],
200
+ },
201
+ };
202
+ }
203
+
204
+ if (chosen.name !== 'exec') return { ...base, ok: true, writes: derivedWrites({ ...chosen, exec: null, execSource: null }) };
205
+
206
+ const { exec: resolved, execSource } = execFor(exec, has);
207
+
208
+ // exec with no template and no tmux to derive one from. This is the message that was the whole
209
+ // thread — kept, because it was never the wrong message, only the wrong MOMENT to say it.
210
+ if (!resolved) {
211
+ return {
212
+ ...base,
213
+ problem: {
214
+ reason:
215
+ 'the exec backend needs a command template — it runs whatever you tell it to, and nothing ' +
216
+ 'has told it. tmux, the obvious default, is not installed either, so crux has nothing to derive one from.',
217
+ fix: [
218
+ 'Install tmux and crux will write the template for you:',
219
+ ' brew install tmux',
220
+ `Or say how YOUR orchestrator starts a lead, in ${file}:`,
221
+ ' "swarm": {',
222
+ ' "backend": "exec",',
223
+ ` "exec": { "command": "${TMUX_EXEC.command}",`,
224
+ ` "stop": "${TMUX_EXEC.stop}" }`,
225
+ ' }',
226
+ 'Placeholders: {slug} {name} {repo} {brief_path} {command}. Set `stop` whenever your command',
227
+ 'forks (tmux, nohup) — without it crux is left holding a dead pid and cannot stop the lead.',
228
+ ],
229
+ },
230
+ };
231
+ }
232
+
233
+ const out = { ...base, exec: resolved, execSource, ok: true };
234
+ return { ...out, writes: derivedWrites(out) };
235
+ }
236
+
237
+ module.exports = { BACKENDS, TMUX_EXEC, detectBackend, execFor, resolveBackend };