nomarmy 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
@@ -0,0 +1,2248 @@
1
+ #!/usr/bin/env node
2
+ // nomArmy CLI. Every command proposes before it writes anything -- init,
3
+ // setup, model and update all show exactly what would change and write only
4
+ // after explicit confirmation ([y/N]) or an explicit non-interactive flag
5
+ // (--write, --json with the required choices given up front). Nothing here
6
+ // provisions SYSTEM-level infrastructure on its own: install.sh (builds
7
+ // llama.cpp, installs OpenClaw, configures the sandbox) stays a separate,
8
+ // manual step in every case, printed but never run.
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { spawn, spawnSync, execFileSync } from "node:child_process";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { stdin as input, stdout as output } from "node:process";
15
+ import { loadConfig, validateConfig, stringifyConfig, findConfigFile, parseYaml, CONFIG_FILENAMES } from "../lib/config.mjs";
16
+ import { scanRepository, compareEvidence } from "../lib/scan.mjs";
17
+ import { buildConfigProposal } from "../lib/propose.mjs";
18
+ import { detectHardware } from "../lib/hardware.mjs";
19
+ import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
20
+ import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
21
+ import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected } from "../lib/connect.mjs";
22
+ import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
23
+ import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
24
+ import { loadArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
25
+ import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
26
+ import { recordProbeSuccess } from "../lib/health.mjs";
27
+ import { pruneJobRuntime } from "../lib/prune.mjs";
28
+ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
29
+
30
+ // Add a new coordinator: add its name here, teach commandExists/connectTarget
31
+ // about it below (a JSON-file target like Cursor has no PATH binary to check
32
+ // and should short-circuit commandExists to true), and give cmdUpdate its own
33
+ // "already connected, so resync" detection if it has no CLI to probe.
34
+ const KNOWN_TARGETS = ["claude", "codex", "cursor"];
35
+
36
+ function connectTarget(target, { nomarmyRoot, run }) {
37
+ if (target === "claude") return connectClaude({ nomarmyRoot, run });
38
+ if (target === "codex") return connectCodex({ nomarmyRoot, run });
39
+ if (target === "cursor") return connectCursor({ nomarmyRoot, run });
40
+ throw new Error(`unknown connect target: ${target}`);
41
+ }
42
+
43
+ const argv = process.argv.slice(2);
44
+ const command = argv[0];
45
+ const flag = (name) => argv.includes(`--${name}`);
46
+ const value = (name, fallback = null) => {
47
+ const i = argv.indexOf(`--${name}`);
48
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
49
+ };
50
+ const repoDir = path.resolve(value("repo", process.cwd()));
51
+ // --json shape for `agents add`/`agents update`'s thinking field:
52
+ // `--thinking low|medium|high` sets that entry's own fixed reasoning floor
53
+ // (see thinkingSchema's doc comment); a bare `--thinking` (no value, or a
54
+ // value that isn't a real level) keeps the original boolean meaning (pass
55
+ // through the job's requested reasoning); `--no-thinking` is always false.
56
+ // Returns undefined when none of these flags were passed at all, so the
57
+ // caller can tell "not touched" apart from "explicitly set".
58
+ const THINKING_LEVELS = ["low", "medium", "high"];
59
+ function resolveThinkingFlag() {
60
+ const level = value("thinking");
61
+ if (level && THINKING_LEVELS.includes(level)) return level;
62
+ if (flag("no-thinking")) return false;
63
+ if (flag("thinking")) return true;
64
+ return undefined;
65
+ }
66
+ const json = flag("json");
67
+ const out = (obj) => console.log(JSON.stringify(obj, null, 2));
68
+ const gib = (n) => (typeof n === "number" ? `${(n / 1024 ** 3).toFixed(1)} GiB` : "unknown");
69
+ const K = (n) => (typeof n === "number" ? `${Math.round(n / 1024)}K` : "?");
70
+
71
+ // A plain wall of text doesn't match this project's own tone (a mascot, a
72
+ // tagline). Used only by the newer interactive commands (init/setup/model);
73
+ // the rest of the CLI's output is untouched, on purpose, to keep this change
74
+ // scoped. Off under --json and when stdout isn't a real terminal (piped,
75
+ // redirected) so color codes never leak into something meant to be parsed.
76
+ const useColor = process.stdout.isTTY && !json;
77
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
78
+ const c = { bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), yellow: paint(33), cyan: paint(36) };
79
+
80
+ function usage(code = 0) {
81
+ console.log(`nomArmy - bounded coding workers with independently verified results
82
+
83
+ Usage: nomarmy <command> [options]
84
+
85
+ scan Inspect this repository and report its execution environment.
86
+ --check compare the evidence against a committed .nomarmy.yml
87
+ init Propose a .nomarmy.yml from this repository's scan evidence
88
+ and write it after confirmation.
89
+ --force overwrite an existing .nomarmy.yml
90
+ --write with --json, write without prompting (needs a valid proposal)
91
+ setup Detect this machine, recommend a profile (offering "more
92
+ noms" vs "nominal" when they differ), choose a model, and
93
+ write config/profiles/<name>.env (+ config/common.env).
94
+ Prints the install.sh command; never runs it.
95
+ --tier <more|nominal> with --json, skip the prompt
96
+ model Change the configured model later, without the rest of
97
+ setup's questions. Offers to also resync the MCP
98
+ registration's worker-routing env vars, and to restart
99
+ local inference so the running llama-server actually
100
+ loads the new model (writing the config alone leaves
101
+ the running process serving whatever it loaded at its
102
+ own last start).
103
+ --update-mcp with --json, also resync the MCP
104
+ registration (never done silently)
105
+ --restart-inference with --json, also stop/start local
106
+ inference (never done silently)
107
+ update Pull the latest nomArmy code and re-sync the installed
108
+ MCP copy (fast-forward only; refuses on local changes).
109
+ agents <list|add|update|remove>
110
+ Every account a job can run on, in one list:
111
+ ~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
112
+ local the local model (built in as \`local\`)
113
+ api a metered API key: xai, openai,
114
+ anthropic, deepinfra, bedrock, azure,
115
+ any OpenClaw provider by id ("openclaw",
116
+ e.g. DeepSeek), or an OpenAI-compatible URL
117
+ subscription ONE person's own Claude, ChatGPT or Muse
118
+ Code plan; never pooled, and every job
119
+ must name its owner in on_behalf_of
120
+ Changes apply to the next job, no restart.
121
+ list show your agents
122
+ An api or subscription agent is an account; its
123
+ model is only an optional default, since each role
124
+ (or the General, per job) picks the model.
125
+ add [local|api|subscription] [claude|codex|meta]
126
+ walks through what that kind needs: an API
127
+ key registered with OpenClaw (over stdin,
128
+ never saved to a file), or the vendor's own
129
+ CLI install and login, OpenClaw's plugin and
130
+ login, and for Meta the key Muse Code's login
131
+ left in the macOS keychain. Then a real test
132
+ call before saving. Interactive, or --json
133
+ --name <n> --kind <kind> plus that kind's
134
+ fields: --slot coder|gpt (local); --provider
135
+ --model --auth-env [--base-url]
136
+ [--openclaw-provider --plugin] [--register]
137
+ [--update-mcp] (api); --provider --model
138
+ --owner (subscription); and optionally
139
+ --max-concurrent --context-window
140
+ --thinking [low|medium|high] --no-thinking
141
+ update <name>
142
+ change the model (picked from what OpenClaw
143
+ lists; a subscription gets a real test call),
144
+ slot, auth_env, base_url, max_concurrent,
145
+ context_window or thinking. Kind, provider and
146
+ owner stay fixed: that's a different agent.
147
+ --no-model clears the default model, so every
148
+ role or job names its own.
149
+ (--json with the matching flags; --probe to
150
+ test-call a subscription's new model first)
151
+ remove <name>
152
+ remove one agent
153
+ army <show|init|assign|general>
154
+ Who does what. The General is your coordinator session:
155
+ its charter is fixed by nomArmy, and you define which
156
+ agent it is. Each other role has a description, a phase
157
+ (build, review, acceptance) and one agent. Layers merge
158
+ like Claude Code's settings, later wins: global
159
+ ~/.config/nomarmy/config.yml, the repo's .nomarmy.yml,
160
+ then its gitignored .nomarmy.local.yml. Army sections
161
+ only pick agents by name; they can never hold a
162
+ credential or endpoint. Jobs dispatch with
163
+ \`army_role\`; edits apply to the next job, no restart.
164
+ show the General, the roster, which layer set what,
165
+ and roles that share the General's model or
166
+ usage (--json is what the \`army\` tool returns)
167
+ init write the default roster (Sr Dev, Jr Dev,
168
+ UI/UX, data architect, security analyst, PM,
169
+ PO, stakeholder, all on \`local\`) to --global
170
+ (default), --project or --local; --force
171
+ replaces an existing one
172
+ assign <role> <agent|none> [model|auto]
173
+ give a role an agent, and optionally the model
174
+ to run on it ("auto" lets the General pick per
175
+ job; omitted, the agent's default), in --global
176
+ (default), --project or --local. A named model
177
+ gets one real test call first (a listed model
178
+ isn't proof it runs); --no-check skips that
179
+ general <agent>
180
+ which agent the General is, in --global
181
+ (default) or --local
182
+ config paths where agents.yml and the three army layers live
183
+ jobs [--watch|--events|--prune] [--interval N] [--older-than DAYS]
184
+ what's running across every session (agent, model, phase,
185
+ last tool call, files changed, heartbeat) and what just
186
+ finished; --watch redraws every N seconds (default 3);
187
+ --events prints one line per start, phase change and
188
+ finish (for Claude Code's background monitor; --json for
189
+ JSON lines); --prune removes the bulky runtime data
190
+ from finished jobs older than DAYS (default 2), keeping
191
+ their records, reports and any retained worktree
192
+ health check what's likely to break a run before it does:
193
+ expiring logins, an outdated OpenClaw or plugin, roles
194
+ that can't be dispatched, an unloadable agents.yml,
195
+ piled-up job storage. The MCP server also runs this
196
+ every 6 hours and notifies once per new warning
197
+ statusline the one-line summary Claude Code's status line shows
198
+ (installed by \`nomarmy connect claude\` when no status
199
+ line is set); reads the session JSON on stdin
200
+ connect [claude] [codex] [cursor]
201
+ (Re-)register the MCP server with one or more coordinators.
202
+ With no target and not --json, prompts an interactive
203
+ multi-select instead.
204
+ start <profile> Start local inference (wraps scripts/start-inference.sh).
205
+ stop <profile> Stop local inference (wraps scripts/stop-inference.sh).
206
+ uninstall Remove the MCP registration and install directory.
207
+ --clear-agents also remove job records, logs and the
208
+ built llama.cpp binary (rebuilt on next
209
+ install)
210
+ --clear-models also remove the model repo config/
211
+ common.env references from the local
212
+ Hugging Face cache
213
+ --all both of the above
214
+ --force skip the confirmation prompt for either
215
+ (required alongside --json)
216
+ validate Validate .nomarmy.yml against the schema.
217
+ sizing Recommend context and nom count for this machine.
218
+ --check evaluate the loaded profile instead of recommending
219
+ --noms <N> size for exactly N workers instead of the max
220
+ that fits ("more noms") or the fixed
221
+ shipped-profile default ("nominal"); also
222
+ offered as an interactive prompt at the end
223
+ of the plain (non --json) report
224
+ doctor Check this host is ready to run nomArmy, with a fix for
225
+ anything missing.
226
+ help
227
+
228
+ Options:
229
+ --repo <dir> repository to inspect (default: cwd)
230
+ --json machine-readable output; disables interactive prompts
231
+ --model <path> GGUF file to size against (default: auto-discover)
232
+ --execution <m> local | bedrock (default: $NOMARMY_EXECUTION or local)
233
+ `);
234
+ process.exit(code);
235
+ }
236
+
237
+ // Model discovery and split-shard size summing live in lib/gguf.mjs, tested
238
+ // there; this is a thin wrapper binding the CLI's own --model flag.
239
+ function findModel() {
240
+ return resolveModelPath({ explicit: value("model"), env: process.env });
241
+ }
242
+
243
+ function printWarnings(warnings = []) {
244
+ if (!warnings.length) return;
245
+ console.log("");
246
+ for (const w of warnings) console.log(` [${w.severity}] ${w.code}: ${w.message}`);
247
+ }
248
+
249
+ function cmdScan() {
250
+ const evidence = scanRepository(repoDir);
251
+ if (flag("check")) return scanCheck(evidence);
252
+ if (json) return out(evidence);
253
+
254
+ console.log(`Repository evidence for ${evidence.repoName}\n`);
255
+ for (const [name, count] of Object.entries(evidence.counts)) {
256
+ if (count > 0) {
257
+ console.log(` ${String(name).padEnd(14)} ${count}${evidence[name]?.truncated ? " (truncated)" : ""}`);
258
+ }
259
+ }
260
+ const services = evidence.services?.items ?? [];
261
+ if (services.length) {
262
+ console.log("\nServices:");
263
+ for (const s of services) {
264
+ console.log(` ${s.name}${s.image ? ` ${s.image}` : ""}${s.source ? ` (${s.source})` : ""}`);
265
+ }
266
+ }
267
+ const notes = evidence.notes?.items ?? [];
268
+ if (notes.length) {
269
+ console.log("\nNotes:");
270
+ for (const n of notes) console.log(` ${n.message ?? n}`);
271
+ }
272
+ console.log("\nThis is deterministic evidence only - nothing here was executed.");
273
+ console.log("Describe the environment in .nomarmy.yml, then run 'nomarmy validate'.");
274
+ }
275
+
276
+ function scanCheck(evidence) {
277
+ let loaded;
278
+ try {
279
+ loaded = loadConfig(repoDir);
280
+ } catch (err) {
281
+ if (json) return out({ error: err.message, errors: err.errors ?? [] });
282
+ console.error(`Cannot compare: ${err.message}`);
283
+ process.exit(1);
284
+ }
285
+ const drift = compareEvidence(evidence, loaded.found ? loaded.config : null);
286
+ if (json) return out({ evidence, drift });
287
+ if (!loaded.found) {
288
+ console.log(`No ${CONFIG_FILENAMES.join(" or ")} found in ${evidence.repoName}.`);
289
+ console.log("Run 'nomarmy scan' to see what this repository appears to need.");
290
+ process.exit(1);
291
+ }
292
+ console.log(`Comparing ${path.basename(loaded.path)} against repository evidence\n`);
293
+ if (drift.summary) console.log(`${drift.summary}\n`);
294
+ for (const section of ["services", "ports", "environment", "commandKinds"]) {
295
+ const d = drift[section];
296
+ if (!d) continue;
297
+ for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${m}`);
298
+ for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${m}`);
299
+ }
300
+ process.exit(drift.ok ? 0 : 1);
301
+ }
302
+
303
+ function cmdValidate() {
304
+ let loaded;
305
+ try {
306
+ loaded = loadConfig(repoDir);
307
+ } catch (err) {
308
+ if (json) return out({ valid: false, errors: err.errors ?? [err.message], path: err.path ?? null });
309
+ console.error(`Invalid ${err.path ? path.basename(err.path) : "config"}:`);
310
+ for (const e of err.errors ?? [err.message]) console.error(` ${e}`);
311
+ process.exit(1);
312
+ }
313
+ if (!loaded.found) {
314
+ if (json) return out({ found: false });
315
+ console.log(`No ${CONFIG_FILENAMES.join(" or ")} in ${repoDir}. Nothing to validate.`);
316
+ return;
317
+ }
318
+ const res = validateConfig(loaded.config);
319
+ if (json) {
320
+ return out({ found: true, path: loaded.path, valid: res.valid, errors: res.errors, elevated: loaded.elevated });
321
+ }
322
+ console.log(`${path.basename(loaded.path)} is valid.`);
323
+ const { shared = [], remote = [] } = loaded.elevated ?? {};
324
+ if (shared.length || remote.length) {
325
+ console.log("\nElevated services requiring explicit policy approval:");
326
+ for (const s of shared) console.log(` shared: ${s} (mutable state shared across jobs)`);
327
+ for (const r of remote) console.log(` remote: ${r} (traffic leaves this machine)`);
328
+ console.log("\nThese are accepted by the schema but are not enabled by default.");
329
+ }
330
+ }
331
+
332
+ /**
333
+ * `nomarmy init`: scan the repository, propose a `.nomarmy.yml` from the
334
+ * evidence, and write it only after explicit confirmation (interactive) or
335
+ * an explicit `--write` flag (non-interactive, `--json`). Never overwrites an
336
+ * existing file without `--force` -- a human-authored config is never
337
+ * silently clobbered by a guess.
338
+ */
339
+ async function cmdInit() {
340
+ const existing = findConfigFile(repoDir);
341
+ if (existing && !flag("force")) {
342
+ if (json) { out({ error: `${path.basename(existing)} already exists`, path: existing }); process.exit(1); }
343
+ console.log(c.yellow(`${path.basename(existing)} already exists at ${existing}.`));
344
+ console.log("Not overwriting a config someone already wrote. Re-run with --force to replace it.");
345
+ process.exit(1);
346
+ }
347
+
348
+ const evidence = scanRepository(repoDir);
349
+ const { proposal, valid, errors, excludedFixturePaths, notes } = buildConfigProposal(evidence);
350
+ // --force regenerates what the scan can see; the army section is a human
351
+ // decision the scan knows nothing about, so it carries over untouched.
352
+ if (existing) {
353
+ try {
354
+ const previousArmy = parseYaml(fs.readFileSync(existing, "utf8"), existing)?.army;
355
+ if (previousArmy) proposal.army = previousArmy;
356
+ } catch { /* an unparseable old file has nothing safe to carry over */ }
357
+ }
358
+ const targetPath = path.join(repoDir, existing ? path.basename(existing) : CONFIG_FILENAMES[0]);
359
+
360
+ if (json) {
361
+ if (!flag("write")) return out({ proposal, valid, errors, excludedFixturePaths, notes, wouldWriteTo: targetPath });
362
+ if (!valid) { out({ error: "proposal does not validate; refusing to write", errors }); process.exit(1); }
363
+ fs.writeFileSync(targetPath, stringifyConfig(proposal));
364
+ return out({ written: targetPath, proposal });
365
+ }
366
+
367
+ console.log(c.bold(`🍪 Proposed ${path.basename(targetPath)}`) + c.dim(`, built from ${evidence.repoName}'s scan evidence:`) + "\n");
368
+ console.log(stringifyConfig(proposal));
369
+ if (excludedFixturePaths.length) {
370
+ console.log(c.yellow("Excluded as likely test fixtures (review by hand if any of these is real):"));
371
+ for (const p of excludedFixturePaths) console.log(c.dim(` ${p}`));
372
+ console.log("");
373
+ }
374
+ if (notes.length) { for (const n of notes) console.log(c.dim(`Note: ${n}`)); console.log(""); }
375
+
376
+ if (!valid) {
377
+ console.log(c.red("This proposal does not validate against the schema:"));
378
+ for (const e of errors) console.log(c.red(` ${e}`));
379
+ console.log("\nNot offering to write an invalid config. Fix the evidence or write .nomarmy.yml by hand.");
380
+ process.exit(1);
381
+ }
382
+
383
+ if (!process.stdin.isTTY) throw new Error("nomarmy init needs an interactive terminal to confirm the write, or --json --write for a non-interactive one.");
384
+ const rl = createInterface({ input, output });
385
+ try {
386
+ const answer = (await rl.question(c.bold(`Write this to ${path.basename(targetPath)}? [y/N] `))).trim().toLowerCase();
387
+ if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
388
+ fs.writeFileSync(targetPath, stringifyConfig(proposal));
389
+ console.log(c.green(`✓ Wrote ${targetPath}.`) + " Run 'nomarmy validate' any time to re-check it.");
390
+ } finally {
391
+ rl.close();
392
+ }
393
+ }
394
+
395
+ // This file's own location, not --repo (the target repo being scanned) --
396
+ // setup/model need to find THIS package's config/ and scripts/ as siblings
397
+ // of bin/, the same way select-model.mjs resolves its own root.
398
+ const nomarmyRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
399
+
400
+ /** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
401
+ function readEnvValue(filePath, key) {
402
+ if (!fs.existsSync(filePath)) return null;
403
+ const m = fs.readFileSync(filePath, "utf8").match(new RegExp(`^${key}=(.*)$`, "m"));
404
+ return m ? m[1].trim() : null;
405
+ }
406
+
407
+ /** Read-modify-write one KEY=VALUE line, replacing it if present, appending if not -- the exact pattern scripts/select-model.mjs already uses for config/common.env. */
408
+ function writeEnvLine(filePath, key, value) {
409
+ const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
410
+ const line = `${key}=${value}`;
411
+ const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
412
+ ? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
413
+ : `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
414
+ fs.writeFileSync(filePath, updated);
415
+ }
416
+
417
+ // Each entry's repo/quant/alias is verified against this project's own real
418
+ // usage (downloaded, loaded, dispatched against), not guessed from a model
419
+ // card. `thinking` records whether the model has a reasoning mode at all --
420
+ // Coder-Next does not, the other two do, at reasoning: medium specifically
421
+ // (see docs/experiments/2026-09-20-model-bakeoff-and-economics.md): high
422
+ // reasoning was a strict downgrade on every model tested, from a full
423
+ // timeout (Qwen3.6-27B on an open-ended task) to a 5x slowdown with real
424
+ // failures (gpt-oss-20b: 318s/4 failures at high vs 62s/0 failures at
425
+ // medium on an identical ticket). `recommended` is this project's own
426
+ // honest opinion given everything measured so far, not a formula -- it
427
+ // prints as a note, never a hidden default no one chose.
428
+ /**
429
+ * Ask a question and keep re-asking until the answer matches `pattern` (or
430
+ * is blank and `allowEmpty`, returning `fallback`) -- validated ON THE SPOT,
431
+ * not left to fail only at the very end via schema validation with no
432
+ * indication of which of several answers was the problem.
433
+ */
434
+ async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
435
+ for (;;) {
436
+ const answer = (await rl.question(c.bold(prompt))).trim();
437
+ if (!answer && allowEmpty) return fallback;
438
+ if (pattern.test(answer)) return answer;
439
+ console.log(c.red(` ✗ ${invalidMessage}`));
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Like rl.question, for a real secret typed directly into the terminal
445
+ * rather than exported as an env var first. NOT masked: the classic
446
+ * "monkey-patch readline's private write hook" trick relies on
447
+ * `_writeToOutput`, which the callback-based `readline.Interface` exposes
448
+ * but this project's promises-based one (`node:readline/promises`,
449
+ * confirmed directly against this Node version) does not -- and a
450
+ * hand-rolled raw-mode character reader is exactly the kind of thing that's
451
+ * unsafe to ship without testing against a real interactive terminal, which
452
+ * this environment cannot do. So this echoes plainly, like every other
453
+ * question in this file, and says so up front rather than silently doing
454
+ * something fragile. The value is still never written to config/
455
+ * agents.yml or any other file -- it's held in memory for the one
456
+ * immediate registration call and discarded.
457
+ */
458
+ async function askSecret(rl, prompt) {
459
+ console.log(c.yellow("(this will be visible as you type it -- not masked, not sent anywhere, not saved to a file)"));
460
+ const answer = await rl.question(c.bold(prompt));
461
+ return answer.trim();
462
+ }
463
+
464
+ const KNOWN_MODELS = {
465
+ default: {
466
+ label: "Qwen3-Coder-Next (shipped default; no thinking mode -- 0 failures across every case tested tonight)",
467
+ repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
468
+ },
469
+ "gpt-oss-20b": {
470
+ label: "gpt-oss-20b (thinking, use reasoning: medium -- fastest of every model tested on the hardest case: 62s/0 failures; reasoning: high on the SAME ticket was the worst result measured: 318s/4 failures)",
471
+ repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
472
+ },
473
+ "qwen3.6-27b": {
474
+ label: "Qwen3.6-27B (thinking, use reasoning: medium -- best measured reliability, noticeably slower per-token; reasoning: high caused a full timeout on an open-ended task)",
475
+ repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
476
+ },
477
+ };
478
+
479
+ /**
480
+ * The one model-choice menu both `setup` and `model` show. Returns
481
+ * `{ kind: "known", repo, quant, alias, thinking }` for a curated entry, or
482
+ * `{ kind: "search" }` once scripts/select-model.mjs (spawned as a child
483
+ * process, not reimplemented -- it already owns the Hugging Face search,
484
+ * confirm and write flow) has finished. A searched model's `thinking`
485
+ * support isn't knowable from a repo/quant alone, so it's asked directly.
486
+ */
487
+ async function chooseModel(rl) {
488
+ const entries = Object.entries(KNOWN_MODELS);
489
+ console.log("\n" + c.bold("Which model?"));
490
+ entries.forEach(([, m], i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m.label}`));
491
+ console.log(` ${c.cyan(`${entries.length + 1}.`)} Search Hugging Face for something else`);
492
+ const defaultChoice = String(entries.findIndex(([, m]) => m.recommended) + 1 || 1);
493
+ const choice = (await rl.question(c.bold(`Choice [${defaultChoice}]: `))).trim() || defaultChoice;
494
+ const picked = entries[Number(choice) - 1];
495
+ if (picked) return { kind: "known", ...picked[1] };
496
+ const term = (await rl.question("Search term (or owner/model-GGUF repo): ")).trim();
497
+ if (!term) throw new Error("A search term or repo is required for the Hugging Face search path.");
498
+ await new Promise((resolve, reject) => {
499
+ const child = spawn(process.execPath, [path.join(nomarmyRoot, "scripts", "select-model.mjs"), term], { stdio: "inherit" });
500
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`select-model.mjs exited ${code}`))));
501
+ child.on("error", reject);
502
+ });
503
+ const thinkingAnswer = (await rl.question(c.bold("Does this model have a thinking/reasoning mode? [y/N] "))).trim().toLowerCase();
504
+ return { kind: "search", thinking: thinkingAnswer === "y" || thinkingAnswer === "yes" };
505
+ }
506
+
507
+ /**
508
+ * `nomarmy setup`: detect hardware, recommend a profile the same way
509
+ * `nomarmy sizing` already does, let the user pick a model, then write the
510
+ * result to config/profiles/<name>.env and (for the two curated model
511
+ * choices) config/common.env. Stops there -- prints the exact `install.sh`
512
+ * command rather than running it. install.sh builds llama.cpp, curl-pipes an
513
+ * installer and touches sandbox/provider config; that is not a proportionate
514
+ * thing for an opt-in flag on a CLI whose whole brand is "reports or
515
+ * proposes" to cross, unlike the cheap, reversible, single-file writes this
516
+ * command itself does.
517
+ */
518
+ async function cmdSetup() {
519
+ const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
520
+ const isCloud = execution !== "local";
521
+ const hardware = isCloud ? null : await detectHardware();
522
+ const modelPath = isCloud ? null : findModel();
523
+ const gguf = modelPath ? await readGGUFMetadata(modelPath) : { found: false };
524
+ const res = recommend({ hardware, gguf, execution });
525
+
526
+ const nonInteractive = json;
527
+ if (nonInteractive && !flag("profile-name")) throw new Error("--json requires --profile-name <name>.");
528
+ if (nonInteractive && !isCloud && !flag("model")) throw new Error(`--json requires --model <${Object.keys(KNOWN_MODELS).join("|")}> for a local profile (Hugging Face search is interactive-only).`);
529
+
530
+ let rl = null;
531
+ if (!nonInteractive) {
532
+ if (!process.stdin.isTTY) throw new Error("nomarmy setup needs an interactive terminal, or --json with --profile-name (and --model for a local profile).");
533
+ rl = createInterface({ input, output });
534
+ }
535
+
536
+ try {
537
+ if (!json) {
538
+ console.log(c.bold("🍪 nomArmy setup\n"));
539
+ console.log(isCloud
540
+ ? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
541
+ : `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
542
+ console.log(`More noms ${c.dim(`(confidence: ${res.confidence})`)}: ${c.green(res.summary ?? JSON.stringify(res.env))}`);
543
+ if (res.nominal && !res.nominal.sameAsRecommended) {
544
+ console.log(`Nominal: ${res.nominal.fits ? c.dim(res.nominal.summary) : c.red(`${res.nominal.summary} DOES NOT FIT either -- nothing on this machine does.`)}`);
545
+ }
546
+ }
547
+
548
+ // "More noms" fits as many noms as memory allows; "nominal" is 1 worker
549
+ // at the same context, matching every profile actually shipped in
550
+ // config/profiles/*.env. No genuinely distinct third "fast" tier is
551
+ // offered: worker count is the only speed-relevant lever this project
552
+ // has real (measured, README-documented) data for, and a smaller
553
+ // context per nom has no established speed relationship in this
554
+ // codebase, only a memory one -- inventing one would be a guess
555
+ // presented as a measurement.
556
+ let sizingTier = "more";
557
+ if (res.nominal && !res.nominal.sameAsRecommended) {
558
+ if (nonInteractive) {
559
+ sizingTier = value("tier", "more");
560
+ if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
561
+ } else {
562
+ console.log(`\n${c.bold("Which sizing?")}`);
563
+ console.log(` ${c.cyan("1.")} More noms -- as many as fit in memory`);
564
+ console.log(` ${c.cyan("2.")} Nominal -- 1 nom, matching this project's own shipped profiles`);
565
+ const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
566
+ sizingTier = choice === "2" ? "nominal" : "more";
567
+ }
568
+ }
569
+ const sizingEnv = sizingTier === "nominal" ? res.nominal.env : res.env;
570
+
571
+ let model = null;
572
+ if (!isCloud) {
573
+ if (nonInteractive) {
574
+ const which = value("model");
575
+ if (!KNOWN_MODELS[which]) throw new Error(`--model must be one of ${Object.keys(KNOWN_MODELS).join(", ")} under --json, got "${which}".`);
576
+ model = { kind: "known", ...KNOWN_MODELS[which] };
577
+ } else {
578
+ model = await chooseModel(rl);
579
+ }
580
+ }
581
+
582
+ const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
583
+ const profilePath = path.join(nomarmyRoot, "config", "profiles", `${profileName}.env`);
584
+ const commonPath = path.join(nomarmyRoot, "config", "common.env");
585
+
586
+ const profileWrites = { ...sizingEnv };
587
+ if (!isCloud) {
588
+ // recommend() only returns context/parallel/worker counts -- every
589
+ // hand-authored profile also sets these two, and start-inference.sh
590
+ // references both unconditionally under `set -euo pipefail`, so a
591
+ // profile missing them fails outright on first use, not gracefully.
592
+ profileWrites.NOMARMY_LLAMA_GPU_LAYERS = (hardware.gpu?.count > 0 || hardware.platform === "darwin") ? 999 : 0;
593
+ profileWrites.NOMARMY_LLAMA_THREADS = hardware.cpu?.physicalCores ?? hardware.cpu?.logicalCores ?? 4;
594
+ }
595
+
596
+ if (!json) {
597
+ console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
598
+ for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
599
+ if (model?.kind === "known") {
600
+ console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
601
+ console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
602
+ console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
603
+ console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
604
+ console.log(c.dim(` NOMARMY_WORKER_MODEL=${model.alias}`));
605
+ console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
606
+ }
607
+ if (!nonInteractive) {
608
+ const answer = (await rl.question(c.bold("\nWrite this configuration? [y/N] "))).trim().toLowerCase();
609
+ if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
610
+ }
611
+ }
612
+
613
+ fs.mkdirSync(path.dirname(profilePath), { recursive: true });
614
+ for (const [k, v] of Object.entries(profileWrites)) writeEnvLine(profilePath, k, v);
615
+ if (model?.kind === "known" && model.repo) {
616
+ writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
617
+ writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
618
+ }
619
+ if (model?.kind === "known") {
620
+ writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
621
+ // The keys `nomarmy connect` actually reads to route worker dispatch --
622
+ // writing NOMARMY_MODEL_ALIAS alone (the old behavior) left the MCP
623
+ // registration permanently pointed at whatever it last had, regardless
624
+ // of what was chosen here.
625
+ writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
626
+ writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
627
+ } else if (model?.kind === "search") {
628
+ const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
629
+ if (searchedAlias) {
630
+ writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
631
+ writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
632
+ }
633
+ }
634
+
635
+ const installCmd = `./install.sh --profile ${profileName}${isCloud ? "" : ""}`;
636
+ if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
637
+ console.log(c.green(`\n✓ Wrote ${path.relative(nomarmyRoot, profilePath)}${model?.kind === "known" ? ` and ${path.relative(nomarmyRoot, commonPath)}` : ""}.`));
638
+ console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
639
+ console.log(` ${c.bold(installCmd)}\n`);
640
+ } finally {
641
+ rl?.close();
642
+ }
643
+ }
644
+
645
+ /**
646
+ * `nomarmy model`: swap the configured model later without re-running the
647
+ * whole `setup` wizard. Same menu `setup` offers; never restarts inference
648
+ * itself, matching every other "propose a config change" command in this
649
+ * CLI.
650
+ */
651
+ // The third layer this closes, alongside NOMARMY_WORKER_MODEL/--update-mcp
652
+ // above: writing config/common.env and resyncing the MCP registration still
653
+ // leaves the ACTUAL RUNNING llama-server serving whatever model it loaded
654
+ // at its own last start -- a real, confirmed incident (config said
655
+ // Qwen3.6-27B, the live process was still gpt-oss-20b 11 minutes later,
656
+ // and a delegated worker correctly refused to guess a launch command or
657
+ // kill a 13.7GB process without authorization rather than silently doing
658
+ // nothing). stop/start-inference.sh already no-op harmlessly on a cloud
659
+ // profile and auto-detect the profile from the OS when none is given
660
+ // (see lib.sh's load_profile), so this needs no profile argument itself.
661
+ function restartInference() {
662
+ runScript("stop-inference.sh", []);
663
+ runScript("start-inference.sh", []);
664
+ }
665
+ async function cmdModel() {
666
+ const commonPath = path.join(nomarmyRoot, "config", "common.env");
667
+ if (json) {
668
+ const which = value("model");
669
+ if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
670
+ const m = KNOWN_MODELS[which];
671
+ if (m.repo) { writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", m.repo); writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", m.quant); }
672
+ writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", m.alias);
673
+ writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", m.alias);
674
+ writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(m.thinking));
675
+ // Same destructive-action-needs-explicit-opt-in-under-json rule as
676
+ // uninstall's --clear-*: this runs claude mcp remove/add for real.
677
+ if (flag("update-mcp")) connectClaude({ nomarmyRoot, run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: "ignore", ...opts }) });
678
+ // Also real and destructive (kills the running llama-server, however
679
+ // briefly) -- same opt-in-under-json rule.
680
+ if (flag("restart-inference")) restartInference();
681
+ return out({ written: commonPath, model: m, mcpUpdated: flag("update-mcp"), inferenceRestarted: flag("restart-inference") });
682
+ }
683
+ if (!process.stdin.isTTY) throw new Error(`nomarmy model needs an interactive terminal, or --json --model <${Object.keys(KNOWN_MODELS).join("|")}> (add --update-mcp to also resync the MCP registration, --restart-inference to also reload the running local model).`);
684
+ const rl = createInterface({ input, output });
685
+ try {
686
+ console.log(c.bold("🍪 nomArmy model"));
687
+ const model = await chooseModel(rl);
688
+ let alias;
689
+ if (model.kind === "search") {
690
+ console.log(c.green("\n✓ Done") + " -- config/common.env was already updated by the search above.");
691
+ alias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
692
+ } else {
693
+ console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
694
+ console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
695
+ const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
696
+ if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
697
+ writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
698
+ writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
699
+ writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
700
+ console.log(c.green(`✓ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
701
+ alias = model.alias;
702
+ }
703
+ if (alias) {
704
+ writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", alias);
705
+ writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
706
+ }
707
+
708
+ // The gap this closes: NOMARMY_WORKER_MODEL above was, until now, never
709
+ // read back by anything -- picking a model here had no effect on which
710
+ // model workers actually dispatched to until someone separately, and
711
+ // manually, re-ran the MCP registration by hand.
712
+ if (alias && commandExists("claude")) {
713
+ const answer = (await rl.question(c.bold(`\nAlso update the Claude Code MCP registration to use "${alias}" now? [y/N] `))).trim().toLowerCase();
714
+ if (answer === "y" || answer === "yes") {
715
+ const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: "inherit", ...opts });
716
+ connectClaude({ nomarmyRoot, run });
717
+ console.log(c.green("✓ MCP registration updated.") + " Restart your Claude Code session to pick this up (the MCP server is a per-session child process).");
718
+ }
719
+ }
720
+
721
+ // The third layer: config/common.env and the MCP registration can both
722
+ // now say the right model while the actually-running llama-server keeps
723
+ // serving whatever it loaded at its own last start, unnoticed until
724
+ // something fails against the wrong model.
725
+ if (alias) {
726
+ const answer = (await rl.question(c.bold(`\nAlso restart local inference now to load "${alias}"? [y/N] `))).trim().toLowerCase();
727
+ if (answer === "y" || answer === "yes") {
728
+ restartInference();
729
+ } else {
730
+ console.log(c.dim("Skipped -- restart inference yourself when ready:\n nomarmy stop\n nomarmy start"));
731
+ }
732
+ }
733
+ } finally {
734
+ rl.close();
735
+ }
736
+ }
737
+
738
+ // One label/default per api provider type the `nomarmy agents add api` menu shows.
739
+ // `native: true` types register through OpenClaw's own dedicated onboarding
740
+ // flag (--anthropic-api-key etc, verified via `openclaw onboard --help`);
741
+ // the rest register as a custom endpoint (the same mechanism
742
+ // scripts/configure-openclaw.sh already uses for Bedrock) and need base_url.
743
+ const KNOWN_PROVIDERS = {
744
+ anthropic: { label: "Anthropic (Claude)", defaultModel: "claude-sonnet-4-6", authEnvSuggestion: "NOMARMY_ANTHROPIC_API_KEY", native: true },
745
+ openai: { label: "OpenAI", defaultModel: "gpt-5.6-terra", authEnvSuggestion: "NOMARMY_OPENAI_API_KEY", native: true },
746
+ xai: { label: "xAI (Grok)", defaultModel: "grok-build-0.1", authEnvSuggestion: "NOMARMY_XAI_API_KEY", native: true },
747
+ deepinfra: { label: "DeepInfra", defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", authEnvSuggestion: "NOMARMY_DEEPINFRA_API_KEY", native: true },
748
+ openclaw: { label: "Any other OpenClaw provider (DeepSeek, Mistral, Groq, ... -- by its OpenClaw id)", native: true },
749
+ bedrock: { label: "AWS Bedrock (custom OpenAI-compatible endpoint)", defaultModel: "amazon.nova-micro-v1:0", authEnvSuggestion: "NOMARMY_BEDROCK_API_KEY", native: false, baseUrlHint: "https://bedrock-runtime.<region>.amazonaws.com/openai/v1" },
750
+ "azure-openai": { label: "Azure OpenAI", defaultModel: "gpt-4o-mini", authEnvSuggestion: "NOMARMY_AZURE_OPENAI_API_KEY", native: false, baseUrlHint: "https://YOUR-RESOURCE.openai.azure.com" },
751
+ "openai-compatible": { label: "Custom OpenAI-compatible endpoint", defaultModel: "", authEnvSuggestion: "NOMARMY_CUSTOM_API_KEY", native: false, baseUrlHint: "https://example.com/v1" },
752
+ "llama-cpp": { label: "Local llama.cpp server (already configured -- adds it to a pool alongside remote providers)", native: false },
753
+ };
754
+
755
+ /**
756
+ * Registers one provider entry with OpenClaw. The credential is ALWAYS
757
+ * piped via stdin, never passed as a CLI argument -- a bare argv value is
758
+ * visible to any other process on this machine for the child's lifetime
759
+ * (`ps aux`/`/proc/<pid>/cmdline`), which an earlier version of this
760
+ * function got wrong for native providers specifically (--anthropic-api-key
761
+ * <key> as a literal argument), a real regression against the discipline
762
+ * scripts/configure-openclaw.sh already established for Bedrock/local.
763
+ * `openclaw models auth paste-api-key --provider <id>` is that same
764
+ * stdin-piped primitive, used uniformly here for every provider type.
765
+ * Custom endpoints (bedrock/azure-openai/openai-compatible) additionally
766
+ * need `openclaw onboard --custom-*` first to define the provider's shape
767
+ * (base URL, model id) before a credential can attach to it; native
768
+ * providers (anthropic/openai/xai/deepinfra, or any id via the generic
769
+ * `openclaw` type) are already known to OpenClaw, or become known once the
770
+ * entry's `plugin` is installed, and skip straight to the credential step. On failure, the raw exec error
771
+ * is deliberately never printed -- Node's own error message embeds the
772
+ * full child command line, which for a failed `paste-api-key` call would
773
+ * otherwise still be safe (the key was on stdin, not argv) but is not worth
774
+ * trusting blindly across every future code path this function might grow.
775
+ */
776
+ // Takes a pool entry in its REAL, on-disk shape (snake_case auth_env/
777
+ // base_url, exactly what config/providers.yml and the schema use) rather
778
+ // than a translated camelCase copy -- a prior version of this function
779
+ // destructured `authEnv`/`baseUrl` while every real entry object actually
780
+ // carries `auth_env`/`base_url`, so both were silently always undefined at
781
+ // every call site and the piped credential was the literal string "null".
782
+ function registerProviderWithOpenClaw({ id, provider, model, auth_env: authEnv, base_url: baseUrl, openclaw_provider: genericId, plugin, apiKeyOverride }) {
783
+ // apiKeyOverride is the value from askSecret's "enter it now instead"
784
+ // path -- checked first so a key typed directly into the wizard is used
785
+ // immediately, without also requiring it be exported first.
786
+ const apiKey = apiKeyOverride || (authEnv ? process.env[authEnv] : null);
787
+ if (!apiKey) {
788
+ console.log(c.red(authEnv
789
+ ? `✗ ${authEnv} is not set in this shell -- export it, then run this registration again.`
790
+ : "✗ No credential available to register (no auth_env on this entry and none entered)."));
791
+ return false;
792
+ }
793
+ const isNative = isNativeProviderType(provider);
794
+ const authProviderId = isNative ? openclawProviderId({ provider, openclaw_provider: genericId }) : id;
795
+ const skipFlags = ["--skip-daemon", "--skip-channels", "--skip-skills", "--skip-search", "--skip-hooks", "--skip-ui"];
796
+ // Override point for tests (and for an operator pointing at a specific
797
+ // openclaw binary/path rather than relying on PATH resolution).
798
+ const openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw";
799
+ try {
800
+ // Official plugins register under their provider's id (meta, codex,
801
+ // deepseek -- confirmed live), so inspecting by that id is the
802
+ // already-installed check; installing an installed plugin errors out.
803
+ if (plugin && spawnSync(openclawCmd, ["plugins", "inspect", authProviderId], { stdio: "ignore" }).status !== 0) {
804
+ console.log(c.dim(`Installing OpenClaw plugin ${plugin}...`));
805
+ execFileSync(openclawCmd, ["plugins", "install", plugin], { stdio: "inherit" });
806
+ spawnSync(openclawCmd, ["plugins", "registry", "--refresh"], { stdio: "ignore" });
807
+ }
808
+ if (!isNative) {
809
+ execFileSync(openclawCmd, ["onboard", "--non-interactive", "--accept-risk",
810
+ "--custom-base-url", baseUrl, "--custom-model-id", model, "--custom-provider-id", id, "--custom-compatibility", "openai", ...skipFlags], { stdio: "ignore" });
811
+ }
812
+ execFileSync(openclawCmd, ["models", "auth", "paste-api-key", "--provider", authProviderId, "--profile-id", `${authProviderId}:nomarmy`],
813
+ { input: `${apiKey}\n`, stdio: ["pipe", "ignore", "ignore"] });
814
+ console.log(c.green(`✓ Registered "${id}" with OpenClaw.`));
815
+ console.log(c.yellow(`This project has not run a real job against this specific provider type yet -- run \`openclaw models list --provider ${authProviderId}\` to confirm it registered as expected, then dispatch one real job against this pool before trusting it in production.`));
816
+ return true;
817
+ } catch (error) {
818
+ console.log(c.red(`✗ Registration failed (exit ${error.status ?? "?"}). Run the equivalent \`openclaw onboard\` / \`openclaw models auth paste-api-key --provider ${authProviderId}\` commands by hand to see the real error -- it is not repeated here, since a raw exec error can embed a full child command line and this one is not worth trusting blindly not to.`));
819
+ return false;
820
+ }
821
+ }
822
+
823
+ // --- subscription setup (`agents add subscription <vendor>`): wrap every OpenClaw step
824
+ //
825
+ // The operator shouldn't need to know OpenClaw exists for the common case.
826
+ // Each helper below runs one real command, reports what it found in plain
827
+ // terms, and only prompts when something actually needs doing. Logins use
828
+ // stdio: "inherit" deliberately: they are device-auth/OAuth flows a human
829
+ // completes in a browser (`openclaw models auth login` refuses outright
830
+ // without a TTY, confirmed live) -- nomArmy starts the flow, the vendor CLI
831
+ // and OpenClaw do the authenticating, and nomArmy never sees a token.
832
+
833
+ function openclawCmd() { return process.env.NOMARMY_OPENCLAW_CMD || "openclaw"; }
834
+
835
+ // stdout AND stderr, on success too: `codex login status` prints its
836
+ // "Logged in using ChatGPT" line to stderr (confirmed live), and an earlier
837
+ // version that kept only stdout on a zero exit read that as "not logged
838
+ // in" -- then told a user who had just logged in successfully that they
839
+ // still weren't.
840
+ function runQuiet(cmd, args) {
841
+ const result = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
842
+ const out = `${result.stdout ?? ""}${result.stderr ?? ""}`;
843
+ return { ok: !result.error && result.status === 0, out, stdout: result.stdout ?? "", status: result.status };
844
+ }
845
+
846
+ function runInteractive(cmd, args) {
847
+ try { execFileSync(cmd, args, { stdio: "inherit" }); return true; }
848
+ catch { return false; }
849
+ }
850
+
851
+ async function confirm(rl, question, { defaultYes = true } = {}) {
852
+ const answer = (await rl.question(c.bold(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `))).trim().toLowerCase();
853
+ if (!answer) return defaultYes;
854
+ return answer === "y" || answer === "yes";
855
+ }
856
+
857
+ /** The vendor CLI's own login state: its status command, or (Muse Code, which has none) its non-secret descriptor file. */
858
+ function readLoginStatus(vendorKey) {
859
+ const cli = SUBSCRIPTION_VENDORS[vendorKey].cli;
860
+ if (cli.statusArgs) return parseCliLoginStatus(vendorKey, runQuiet(cli.bin, cli.statusArgs).out);
861
+ let text = "";
862
+ try { text = fs.readFileSync(cli.statusFile.replace(/^~(?=\/)/, os.homedir()), "utf8"); } catch { /* missing = not logged in */ }
863
+ return parseMuseAuthDescriptor(text);
864
+ }
865
+
866
+ /**
867
+ * credential.kind "minted-key": copies the key the vendor CLI's own login
868
+ * minted into the OS keychain over to OpenClaw, keychain -> this process ->
869
+ * `paste-api-key` stdin. Never printed, never on argv, never written to a
870
+ * file nomArmy owns; child stdio is discarded so no error path can echo it.
871
+ * Run on every setup, not just the first: the vendor may rotate the key,
872
+ * and a stale copy in OpenClaw fails exactly like a missing one.
873
+ */
874
+ function syncMintedKey(vendor) {
875
+ const { keychain, profileId } = vendor.credential;
876
+ if (process.platform !== "darwin") {
877
+ console.log(c.red(`✗ Reading ${vendor.cli.bin}'s stored key is only wired up for the macOS keychain so far. On this OS, link it yourself: \`openclaw models auth paste-api-key --provider ${vendor.provider} --profile-id ${profileId}\` and paste the key it minted.`));
878
+ return false;
879
+ }
880
+ // macOS may show its own keychain prompt here, asking whether to let
881
+ // `security` read this item -- that's the OS asking the operator, as it should.
882
+ const read = spawnSync("security", ["find-generic-password", "-s", keychain.service, "-a", keychain.account, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
883
+ const key = read.status === 0 ? extractMintedKey(read.stdout.trim(), keychain.field) : null;
884
+ if (!key) {
885
+ console.log(c.red(`✗ Couldn't find a usable ${vendor.cli.bin} key in the keychain (or access was declined). Run \`${vendor.cli.bin} ${vendor.cli.loginArgs.join(" ")}\` again, then re-run this.`));
886
+ return false;
887
+ }
888
+ const pasted = spawnSync(openclawCmd(), ["models", "auth", "paste-api-key", "--provider", vendor.provider, "--profile-id", profileId], { input: `${key}\n`, stdio: ["pipe", "ignore", "ignore"] });
889
+ if (pasted.status !== 0) {
890
+ console.log(c.red(`✗ OpenClaw didn't accept the key (exit ${pasted.status ?? "?"}). Run \`openclaw models auth paste-api-key --provider ${vendor.provider} --profile-id ${profileId}\` by hand to see why.`));
891
+ return false;
892
+ }
893
+ runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
894
+ return true;
895
+ }
896
+
897
+ /**
898
+ * Makes one vendor's credential usable by OpenClaw, installing/updating/
899
+ * logging in only what's actually missing. Returns { ok, email } -- email is
900
+ * the vendor CLI's own logged-in account when it reports one, used to
901
+ * default the worker's owner so the operator never retypes who they are.
902
+ */
903
+ async function ensureVendorAuth(rl, vendorKey) {
904
+ const vendor = SUBSCRIPTION_VENDORS[vendorKey];
905
+ const step = (text) => console.log(`\n${c.bold("→")} ${text}`);
906
+
907
+ step(`${vendor.cli.bin} CLI`);
908
+ if (!runQuiet(vendor.cli.bin, ["--version"]).ok) {
909
+ if (!vendor.cli.npmPackage) {
910
+ console.log(c.red(`✗ \`${vendor.cli.bin}\` isn't installed. ${vendor.cli.installHint}, then run this again.`));
911
+ return { ok: false };
912
+ }
913
+ if (!(await confirm(rl, `\`${vendor.cli.bin}\` isn't installed. Install ${vendor.cli.npmPackage} now?`))) return { ok: false };
914
+ if (!runInteractive("npm", ["install", "-g", vendor.cli.npmPackage])) {
915
+ console.log(c.red(`✗ Install failed. Run \`${vendor.cli.installHint}\` yourself to see why.`));
916
+ return { ok: false };
917
+ }
918
+ }
919
+ console.log(c.green(`✓ ${vendor.cli.bin} is installed.`));
920
+
921
+ let status = readLoginStatus(vendorKey);
922
+ if (!status.loggedIn) {
923
+ console.log(c.yellow(`You're not logged in to ${vendor.cli.bin} yet -- this opens its own login (a browser or a device code).`));
924
+ if (!(await confirm(rl, "Log in now?"))) return { ok: false };
925
+ runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
926
+ status = readLoginStatus(vendorKey);
927
+ if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}.`)); return { ok: false }; }
928
+ }
929
+ console.log(c.green(`✓ Logged in to ${vendor.cli.bin}${status.email ? ` as ${status.email}` : ""}${status.subscriptionType ? ` (${status.subscriptionType})` : ""}.`));
930
+
931
+ if (vendor.plugin) {
932
+ step("OpenClaw plugin");
933
+ const version = parseOpenclawVersion(runQuiet(openclawCmd(), ["--version"]).out);
934
+ if (!versionAtLeast(version, vendor.plugin.minOpenclaw)) {
935
+ console.log(c.yellow(`OpenClaw ${version ? version.join(".") : "(unknown version)"} is older than the ${vendor.plugin.minOpenclaw} this vendor's plugin needs.`));
936
+ if (!(await confirm(rl, "Update OpenClaw now (npm update -g openclaw)?"))) return { ok: false };
937
+ if (!runInteractive("npm", ["update", "-g", "openclaw"])) {
938
+ console.log(c.red("✗ Update failed. If npm reports EACCES, your global npm directory has root-owned files from an old sudo install: `sudo chown -R $(whoami) ~/.npm ~/.npm-global` fixes it."));
939
+ return { ok: false };
940
+ }
941
+ }
942
+ if (!runQuiet(openclawCmd(), ["plugins", "inspect", vendor.plugin.id]).ok) {
943
+ console.log(c.dim(`Installing ${vendor.plugin.spec}...`));
944
+ if (!runInteractive(openclawCmd(), ["plugins", "install", vendor.plugin.spec])) {
945
+ console.log(c.red(`✗ Plugin install failed. Run \`openclaw plugins install ${vendor.plugin.spec}\` yourself to see why.`));
946
+ return { ok: false };
947
+ }
948
+ runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
949
+ }
950
+ console.log(c.green(`✓ OpenClaw's ${vendor.plugin.id} plugin is ready.`));
951
+ }
952
+ if (vendor.credential.kind === "minted-key") {
953
+ step(`Linking your ${vendor.cli.bin} login to OpenClaw`);
954
+ console.log(c.dim(`Copies the key ${vendor.cli.bin}'s own login stored in your keychain into OpenClaw (over stdin, never shown). macOS may ask you to allow it.`));
955
+ if (!syncMintedKey(vendor)) return { ok: false };
956
+ console.log(c.green(`✓ OpenClaw is using your ${vendor.cli.bin} subscription key (profile ${vendor.credential.profileId}).`));
957
+ }
958
+ if (vendor.plugin?.providerConfig) {
959
+ // paste-api-key saves the key but not the provider's config entry; the
960
+ // plugin's own onboarding step writes that (lib/openclaw-config.mjs).
961
+ const applied = await ensureProviderConfig({ provider: vendor.provider, pluginId: vendor.plugin.id, ...vendor.plugin.providerConfig });
962
+ if (applied.error) {
963
+ console.log(c.red(`✗ OpenClaw has no ${vendor.provider} provider entry, and adding it failed: ${applied.error}. Run \`openclaw onboard\` and pick ${vendor.label} to add it.`));
964
+ return { ok: false };
965
+ }
966
+ if (applied.changed) console.log(c.green(`✓ Added the ${vendor.provider} provider to OpenClaw's config with the plugin's own setup step (backup: ${applied.backup}).`));
967
+ }
968
+ return { ok: true, email: status.email };
969
+ }
970
+
971
+ function catalogModelsFor(provider) {
972
+ return parseCatalogModels(runQuiet(openclawCmd(), ["models", "list", "--refresh"]).out, provider);
973
+ }
974
+
975
+ /** One real, one-token completion through OpenClaw -- the only proof a credential actually works. */
976
+ // Why the last probeWorker() call failed, in the vendor's words when it said.
977
+ let lastProbeFailure = null;
978
+ function probeWorker(provider, model) {
979
+ // The route a job takes: the ambient OpenClaw config and a state dir of
980
+ // its own, never --isolated. --isolated skips that config, and with it the
981
+ // Codex runtime ChatGPT-plan jobs run through: gpt-6-sol answered "ok"
982
+ // under --isolated while every job on it failed "not supported when using
983
+ // Codex with a ChatGPT account" (a real Senti run). Under the home
984
+ // directory, since the Podman sandbox only binds paths there.
985
+ const dir = fs.mkdtempSync(path.join(agentStateRoot(), "probe-"));
986
+ const stateDir = path.join(dir, "state"), cwd = path.join(dir, "ws");
987
+ fs.mkdirSync(stateDir); fs.mkdirSync(cwd);
988
+ try {
989
+ const result = spawnSync(openclawCmd(), ["agent", "exec", "Reply with exactly: ok", "--model", `${provider}/${model}`, "--no-auth-env-only",
990
+ "--json", "--cwd", cwd, "--state-dir", stateDir, "--timeout", "90"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd });
991
+ // Streams kept apart: OpenClaw logs a "run ... ended" line to stderr
992
+ // AFTER the JSON envelope, and the merged text doesn't parse.
993
+ const outcome = probeOutcome({ stdout: result.stdout ?? "", stderr: result.stderr ?? "" });
994
+ lastProbeFailure = outcome.ok ? null : outcome.reason;
995
+ if (outcome.ok) recordProbeSuccess(agentStateRoot(), `${provider}/${model}`);
996
+ return outcome.ok;
997
+ } finally {
998
+ reapProbeSandbox(stateDir);
999
+ fs.rmSync(dir, { recursive: true, force: true });
1000
+ }
1001
+ }
1002
+ function agentStateRoot() {
1003
+ const root = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
1004
+ fs.mkdirSync(root, { recursive: true });
1005
+ return root;
1006
+ }
1007
+ // The sandbox container OpenClaw starts for the call is never stopped when
1008
+ // it returns (mcp/server.mjs reaps each job's the same way, by the hash in
1009
+ // its state dir).
1010
+ function reapProbeSandbox(stateDir) {
1011
+ let hashes = [];
1012
+ try {
1013
+ hashes = fs.readdirSync(path.join(stateDir, "sandbox", "skills-workspaces"), { withFileTypes: true })
1014
+ .filter((d) => d.isDirectory() && /^workspace-[0-9a-f]{16,}$/.test(d.name)).map((d) => d.name.slice("workspace-".length));
1015
+ } catch { return; }
1016
+ for (const hash of hashes) {
1017
+ const names = runQuiet("podman", ["ps", "-a", "--filter", `name=${hash}`, "--format", "{{.Names}}"]).stdout.split("\n").map((s) => s.trim()).filter(Boolean);
1018
+ for (const name of names) runQuiet("podman", ["rm", "-f", "-v", name]);
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * OpenClaw's own provider login, for the vendors whose plugin wants one on
1024
+ * top of the vendor CLI's login (Claude doesn't: OpenClaw reuses the CLI
1025
+ * session directly). Only ever run when a probe or catalog lookup has
1026
+ * already shown it's needed -- never preemptively.
1027
+ */
1028
+ function openclawProviderLogin(vendor) {
1029
+ const provider = vendor.credential.loginProvider ?? vendor.provider;
1030
+ console.log(c.dim(`Linking OpenClaw to it (\`openclaw models auth login --provider ${provider}\`) -- follow its prompts:`));
1031
+ const ok = runInteractive(openclawCmd(), ["models", "auth", "login", "--provider", provider]);
1032
+ runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
1033
+ return ok;
1034
+ }
1035
+
1036
+ // --- `nomarmy agents`: every model a job can run on -------------------------
1037
+ //
1038
+ // One list in ~/.config/nomarmy/agents.yml (lib/agents.mjs): the local
1039
+ // model, api keys, and individual subscriptions. `add` walks through what
1040
+ // each kind needs -- a key registered with OpenClaw, or the vendor's own
1041
+ // login -- and proves it with a real test call before saving. Changes apply
1042
+ // to the next job; the MCP server re-reads the file when it changes.
1043
+
1044
+ function loadAgentsOrExit() {
1045
+ try { return loadAgents(globalConfigDir()); }
1046
+ catch (error) { failAgents(error); }
1047
+ }
1048
+
1049
+ function fileAgentsOrExit() {
1050
+ try { return readAgentsFile(globalConfigDir()); }
1051
+ catch (error) { failAgents(error); }
1052
+ }
1053
+
1054
+ function failAgents(error) {
1055
+ if (json) { out({ error: error.message, errors: error.errors ?? [], path: error.path ?? null }); process.exit(1); }
1056
+ console.error(c.red(error.errors?.length ? "That isn't a valid agents.yml:" : error.message));
1057
+ for (const line of error.errors ?? []) console.error(` - ${line}`);
1058
+ process.exit(1);
1059
+ }
1060
+
1061
+ function saveAgents(agents) {
1062
+ try { return writeAgentsFile(globalConfigDir(), agents); }
1063
+ catch (error) { failAgents(error); }
1064
+ }
1065
+
1066
+ /** Which roles, in any army layer this repo sees, point at `name`. */
1067
+ function rolesUsingAgent(name) {
1068
+ try {
1069
+ const { army } = loadArmy({ projectDir: repoDir });
1070
+ return Object.entries(army.roles).filter(([, role]) => role.agent === name).map(([role]) => role);
1071
+ } catch {
1072
+ return [];
1073
+ }
1074
+ }
1075
+
1076
+ function parseThinkingAnswer(answer, fallback) {
1077
+ const a = answer.trim().toLowerCase();
1078
+ if (!a) return fallback;
1079
+ if (THINKING_LEVELS.includes(a)) return a;
1080
+ return !(a === "n" || a === "no");
1081
+ }
1082
+
1083
+ /**
1084
+ * For each agent: the roles pointing at it in this repo's merged army
1085
+ * (with each role's model), and whether it's the General. Empty when the
1086
+ * army doesn't load -- `army show` reports why.
1087
+ */
1088
+ function agentAssignments() {
1089
+ const byAgent = {};
1090
+ try {
1091
+ const { army } = loadArmy({ projectDir: repoDir });
1092
+ if (army.general) (byAgent[army.general] ??= { general: true, roles: [] }).general = true;
1093
+ for (const [role, r] of Object.entries(army.roles)) {
1094
+ if (!r.agent) continue;
1095
+ (byAgent[r.agent] ??= { general: false, roles: [] }).roles.push({ role, model: r.model ?? null });
1096
+ }
1097
+ } catch { /* army problems are army show's to report */ }
1098
+ return byAgent;
1099
+ }
1100
+
1101
+ async function cmdAgentsList() {
1102
+ const loaded = loadAgentsOrExit();
1103
+ const assigned = agentAssignments();
1104
+ if (json) return out({ found: loaded.found, path: loaded.path, agents: loaded.agents, assignments: assigned });
1105
+ console.log(c.bold("🍪 nomArmy agents") + c.dim(` (${loaded.found ? loaded.path : `no agents.yml yet; it will live at ${loaded.path}`})`));
1106
+ const width = Math.max(...Object.keys(loaded.agents).map((n) => n.length), 5) + 2;
1107
+ const inFile = fileAgentsOrExit();
1108
+ for (const [name, agent] of Object.entries(loaded.agents)) {
1109
+ const extra = agent.kind === "api" ? c.dim(` key: ${agent.auth_env}`) : !Object.prototype.hasOwnProperty.call(inFile, name) ? c.dim(" built in") : "";
1110
+ console.log(` ${c.cyan(name.padEnd(width))} ${describeAgentLabel(agent)}${extra}`);
1111
+ const a = assigned[name];
1112
+ const uses = [
1113
+ ...(a?.general ? [c.bold("the General")] : []),
1114
+ ...(a?.roles ?? []).map((r) => `${r.role}${r.model ? ` (${r.model === "auto" ? "auto" : r.model})` : agent.model ? ` (${agent.model})` : ""}`),
1115
+ ];
1116
+ console.log(c.dim(` ${" ".repeat(width)} ${uses.length ? `used by: ${uses.join(", ")}` : "not used by any role in this repo"}`));
1117
+ }
1118
+ console.log(c.dim(`\nAdd one with \`nomarmy agents add\`. Give roles an agent with \`nomarmy army assign <role> <agent>\`, or dispatch with agent: "<name>".`));
1119
+ }
1120
+
1121
+ async function cmdAgentsRemove() {
1122
+ const name = argv[2];
1123
+ if (!name) throw new Error("Usage: nomarmy agents remove <name>");
1124
+ const agents = fileAgentsOrExit();
1125
+ if (!Object.prototype.hasOwnProperty.call(agents, name)) {
1126
+ throw new Error(name === "local" ? "`local` is built in and can't be removed." : `Unknown agent "${name}". Your agents: ${Object.keys(loadAgentsOrExit().agents).join(", ")}`);
1127
+ }
1128
+ const usedBy = rolesUsingAgent(name);
1129
+ if (!json) {
1130
+ if (usedBy.length) console.log(c.yellow(`Roles using "${name}" in this repo: ${usedBy.join(", ")}. They'll be refused until you reassign them.`));
1131
+ const rl = createInterface({ input, output });
1132
+ try { if (!(await confirm(rl, `Remove agent "${name}"?`, { defaultYes: false }))) { console.log(c.dim("Canceled; nothing changed.")); return; } }
1133
+ finally { rl.close(); }
1134
+ }
1135
+ const next = { ...agents };
1136
+ delete next[name];
1137
+ saveAgents(next);
1138
+ if (json) return out({ removed: true, name, rolesStillUsingIt: usedBy });
1139
+ console.log(c.green(`✓ Removed agent "${name}".`));
1140
+ }
1141
+
1142
+ // --- add ---
1143
+
1144
+ async function cmdAgentsAdd() {
1145
+ if (json) return cmdAgentsAddJson();
1146
+ if (!process.stdin.isTTY) throw new Error("nomarmy agents add needs an interactive terminal (subscription logins open a browser), or --json with explicit flags (see `nomarmy help`).");
1147
+ const agents = fileAgentsOrExit();
1148
+ const rl = createInterface({ input, output });
1149
+ try {
1150
+ console.log(c.bold("🍪 nomArmy agents add"));
1151
+ let kind = argv[2];
1152
+ if (!AGENT_KINDS.includes(kind)) {
1153
+ console.log("\n" + c.bold("What kind of agent?"));
1154
+ console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (free, private)")}`);
1155
+ console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
1156
+ console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
1157
+ kind = AGENT_KINDS[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1158
+ if (!kind) throw new Error("Not a valid choice.");
1159
+ }
1160
+ if (kind === "local") return await addLocalAgent(rl, agents);
1161
+ if (kind === "api") return await addApiAgent(rl, agents);
1162
+ return await addSubscriptionAgent(rl, agents);
1163
+ } finally {
1164
+ rl.close();
1165
+ }
1166
+ }
1167
+
1168
+ async function askAgentName(rl, agents, fallback) {
1169
+ const name = await askUntilValid(rl, `Agent name [${fallback}]: `, {
1170
+ allowEmpty: true, fallback, pattern: ID_RE,
1171
+ invalidMessage: "must be 1-64 characters of letters, numbers, dot, underscore or hyphen.",
1172
+ });
1173
+ if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
1174
+ if (Object.prototype.hasOwnProperty.call(agents, name) && !(await confirm(rl, `"${name}" already exists. Replace it?`, { defaultYes: false }))) return null;
1175
+ return name;
1176
+ }
1177
+
1178
+ function savedAgentMessage(name, written) {
1179
+ console.log(c.green(`\n✓ Saved agent "${name}": ${describeAgentLabel(written[name])}.`));
1180
+ console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job. It applies to the next job, no restart.`));
1181
+ }
1182
+
1183
+ async function addLocalAgent(rl, agents) {
1184
+ console.log(c.dim("`local` (the coder slot) is built in. Add another only to name the gpt slot (NOMARMY_WORKER_MODEL_FALLBACK)."));
1185
+ const slot = (await rl.question(c.bold("Slot, coder or gpt [gpt]: "))).trim() || "gpt";
1186
+ if (!["coder", "gpt"].includes(slot)) throw new Error("Slot must be coder or gpt.");
1187
+ const name = await askAgentName(rl, agents, slot === "gpt" ? "local-gpt" : "local");
1188
+ if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
1189
+ savedAgentMessage(name, saveAgents({ ...agents, [name]: { kind: "local", slot } }));
1190
+ }
1191
+
1192
+ async function addApiAgent(rl, agents) {
1193
+ console.log("\n" + c.bold("Which provider?"));
1194
+ API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
1195
+ const provider = API_PROVIDER_TYPES[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1196
+ if (!provider) throw new Error("Not a valid choice.");
1197
+ const info = KNOWN_PROVIDERS[provider] ?? {};
1198
+ const agent = { kind: "api", provider };
1199
+
1200
+ if (provider === "openclaw") {
1201
+ console.log(c.dim("Any provider OpenClaw can talk to. Find its id with `openclaw models list --all`, or `openclaw plugins search <name>` if it needs a plugin."));
1202
+ agent.openclaw_provider = await askUntilValid(rl, "OpenClaw provider id (e.g. deepseek, mistral, groq): ", {
1203
+ pattern: OPENCLAW_PROVIDER_ID_RE, invalidMessage: "must be an OpenClaw provider id (lowercase letters, digits, dot, underscore, hyphen).",
1204
+ });
1205
+ const plugin = (await rl.question(c.bold("Plugin to install first, if it isn't built in (e.g. clawhub:@openclaw/deepseek-provider; blank = none): "))).trim();
1206
+ if (plugin) agent.plugin = plugin;
1207
+ }
1208
+ const name = await askAgentName(rl, agents, agent.openclaw_provider ?? (provider === "xai" ? "grok" : provider));
1209
+ if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
1210
+
1211
+ const apiModel = (await rl.question(c.bold(`Default model, optional${info.defaultModel ? ` (e.g. ${info.defaultModel})` : ""}; blank = pick per role: `))).trim();
1212
+ if (apiModel) agent.model = apiModel;
1213
+ const envSuggestion = info.authEnvSuggestion ?? `NOMARMY_${(agent.openclaw_provider ?? name).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
1214
+ // The variable's NAME, never the key: the key itself goes to OpenClaw's
1215
+ // own store over stdin (registerProviderWithOpenClaw) and never into
1216
+ // agents.yml or any other file nomArmy writes.
1217
+ agent.auth_env = await askUntilValid(rl, `Environment variable NAME for the API key, not the key itself [${envSuggestion}]: `, {
1218
+ allowEmpty: true, fallback: envSuggestion, pattern: AUTH_ENV_NAME_RE,
1219
+ invalidMessage: "must look like an ENVIRONMENT VARIABLE NAME (uppercase letters, digits, underscores) -- not the key itself.",
1220
+ });
1221
+ if (["bedrock", "azure-openai", "openai-compatible"].includes(provider)) {
1222
+ agent.base_url = (await rl.question(c.bold(`Base URL${info.baseUrlHint ? ` (e.g. ${info.baseUrlHint})` : ""}: `))).trim();
1223
+ if (!agent.base_url) throw new Error("A base URL is required for this provider.");
1224
+ }
1225
+ agent.thinking = parseThinkingAnswer(await rl.question(c.bold("Thinking: Y = follow the job's level (default), n = off, or low/medium/high to always use that: ")), true);
1226
+ const cw = (await rl.question(c.bold("Context window in tokens (blank = look it up from OpenClaw's catalog): "))).trim();
1227
+ if (cw) agent.context_window = Number(cw);
1228
+
1229
+ let apiKey = null;
1230
+ if (!process.env[agent.auth_env]) {
1231
+ console.log(c.yellow(`\n${agent.auth_env} isn't set in this shell.`));
1232
+ if (await confirm(rl, "Enter the key now instead? It's used once to register with OpenClaw and never saved to a file.", { defaultYes: true })) {
1233
+ apiKey = await askSecret(rl, c.bold(`${agent.auth_env}: `));
1234
+ }
1235
+ }
1236
+
1237
+ const written = saveAgents({ ...agents, [name]: agent });
1238
+ const saved = written[name];
1239
+ console.log(c.green(`\n✓ Saved agent "${name}": ${describeAgentLabel(saved)}.`));
1240
+
1241
+ console.log(`\n${c.bold("→")} Registering the key with OpenClaw`);
1242
+ const registered = registerProviderWithOpenClaw({ ...apiAgentAsPoolEntry(name, saved), apiKeyOverride: apiKey });
1243
+ if (registered && saved.model) {
1244
+ console.log(`\n${c.bold("→")} Test call`);
1245
+ const provId = saved.provider === "openclaw" ? saved.openclaw_provider : ["bedrock", "azure-openai", "openai-compatible"].includes(saved.provider) ? name : saved.provider;
1246
+ if (probeWorker(provId, saved.model)) console.log(c.green(`✓ ${provId}/${saved.model} answered a real test prompt.`));
1247
+ else console.log(c.yellow(`⚠ A real test prompt to ${provId}/${saved.model} didn't come back. Check the model id with \`openclaw models list --provider ${provId}\`.`));
1248
+ }
1249
+ // Dispatch only treats an api agent as usable when its auth_env is set
1250
+ // in the MCP server's own environment, which comes from its registration
1251
+ // (see derivePoolAuthEnvPlaceholders in lib/connect.mjs), so a new api
1252
+ // agent needs one reconnect. Every later edit applies without one.
1253
+ if (commandExists("claude") && await confirm(rl, `Reconnect the MCP server so it can use "${name}" (needed once for a new api agent)?`, { defaultYes: true })) {
1254
+ connectClaude({ nomarmyRoot, run: (cmd, args2, opts = {}) => execFileSync(cmd, args2, { stdio: "ignore", ...opts }), extraEnv: { [saved.auth_env]: "registered" } });
1255
+ console.log(c.green("✓ Reconnected.") + c.dim(" Restart your coordinator session once so it picks up the new registration."));
1256
+ } else {
1257
+ console.log(c.dim(`Run \`nomarmy connect claude\` before dispatching to "${name}".`));
1258
+ }
1259
+ console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job.`));
1260
+ }
1261
+
1262
+ const SUBSCRIPTION_AGENT_DEFAULT_NAMES = { claude: "claude", codex: "codex", meta: "muse" };
1263
+
1264
+ async function addSubscriptionAgent(rl, agents) {
1265
+ console.log(c.dim("Connects ONE person's own subscription. Never pooled, never shared."));
1266
+ const vendorKeys = Object.keys(SUBSCRIPTION_VENDORS);
1267
+ let vendorKey = argv[3];
1268
+ if (!SUBSCRIPTION_VENDORS[vendorKey]) {
1269
+ console.log("\n" + c.bold("Which subscription?"));
1270
+ vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
1271
+ vendorKey = vendorKeys[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1272
+ if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
1273
+ }
1274
+ const vendor = SUBSCRIPTION_VENDORS[vendorKey];
1275
+
1276
+ // Checked before any login: an api agent on the same OpenClaw provider id
1277
+ // would share the one credential slot, and the save would be refused
1278
+ // anyway -- don't walk the operator through a browser flow first.
1279
+ const clash = Object.entries(agents).find(([, a]) => a.kind === "api" && (a.provider === "openclaw" ? a.openclaw_provider : a.provider) === vendor.provider);
1280
+ if (clash) {
1281
+ console.log(c.red(`\n✗ Api agent "${clash[0]}" already uses OpenClaw provider "${vendor.provider}", and OpenClaw holds one credential per provider. Remove it first (\`nomarmy agents remove ${clash[0]}\`), or keep using it instead.`));
1282
+ return;
1283
+ }
1284
+
1285
+ const auth = await ensureVendorAuth(rl, vendorKey);
1286
+ if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); return; }
1287
+
1288
+ console.log(`\n${c.bold("→")} Models`);
1289
+ const needsOpenclawLogin = vendor.credential.kind === "openclaw-login";
1290
+ let linkedOpenclaw = false;
1291
+ let models = catalogModelsFor(vendor.provider);
1292
+ if (!models.length && needsOpenclawLogin) {
1293
+ linkedOpenclaw = openclawProviderLogin(vendor);
1294
+ models = catalogModelsFor(vendor.provider);
1295
+ }
1296
+ // The agent is the account; the model is only a default. Roles pick
1297
+ // their own model (or "auto" for the General to choose per job).
1298
+ if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}`));
1299
+ else console.log(c.dim(`OpenClaw isn't listing ${vendor.provider} models yet.`));
1300
+ const pick = (await rl.question(c.bold(`Default model, optional${models.length ? " (a number or an id)" : ""}; blank = pick per role: `))).trim();
1301
+ const model = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick || null;
1302
+ if (pick && !model) throw new Error(`Not a valid choice: "${pick}".`);
1303
+ // The test call needs some model; it proves the login, not the choice.
1304
+ const probeModel = model ?? models[0] ?? vendor.defaultModel;
1305
+
1306
+ const knownOwners = [...new Set(Object.values(agents).filter((a) => a.kind === "subscription").map((a) => a.owner))];
1307
+ const ownerDefault = auth.email ?? (knownOwners.length === 1 ? knownOwners[0] : "");
1308
+ const owner = (await rl.question(c.bold(`Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `))).trim() || ownerDefault;
1309
+ if (!owner) throw new Error("An owner is required -- every job on this agent must name them in on_behalf_of.");
1310
+
1311
+ const name = await askAgentName(rl, agents, SUBSCRIPTION_AGENT_DEFAULT_NAMES[vendorKey] ?? vendorKey);
1312
+ if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
1313
+
1314
+ console.log(`\n${c.bold("→")} Test call`);
1315
+ let works = probeModel ? probeWorker(vendor.provider, probeModel) : false;
1316
+ if (!works && needsOpenclawLogin && !linkedOpenclaw && probeModel) {
1317
+ openclawProviderLogin(vendor);
1318
+ works = probeWorker(vendor.provider, probeModel);
1319
+ }
1320
+ if (works) console.log(c.green(`✓ ${vendor.provider}/${probeModel} answered a real test prompt.`));
1321
+ else {
1322
+ console.log(c.red(probeModel ? `✗ A real test prompt to ${vendor.provider}/${probeModel} didn't come back.` : "✗ No model to make a test call with."));
1323
+ if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
1324
+ }
1325
+ const written = saveAgents({ ...agents, [name]: { kind: "subscription", provider: vendor.provider, owner, ...(model ? { model } : {}) } });
1326
+ savedAgentMessage(name, written);
1327
+ console.log(c.dim(`Jobs on it need on_behalf_of: "${owner}".`));
1328
+ }
1329
+
1330
+ async function cmdAgentsAddJson() {
1331
+ const agents = fileAgentsOrExit();
1332
+ const name = value("name");
1333
+ const kind = value("kind") ?? (AGENT_KINDS.includes(argv[2]) ? argv[2] : null);
1334
+ if (!name || !kind) throw new Error(`--json requires --name <agent> and --kind <${AGENT_KINDS.join("|")}>, plus that kind's fields (see \`nomarmy help\`).`);
1335
+ if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
1336
+ const agent = { kind };
1337
+ const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
1338
+ if (kind === "local") {
1339
+ agent.slot = value("slot") ?? "coder";
1340
+ } else {
1341
+ for (const [field, flagName] of [["provider", "provider"], ["model", "model"], ["owner", "owner"], ["auth_env", "auth-env"], ["base_url", "base-url"], ["openclaw_provider", "openclaw-provider"], ["plugin", "plugin"]]) {
1342
+ if (value(flagName) !== null) agent[field] = value(flagName);
1343
+ }
1344
+ for (const [field, flagName] of [["max_concurrent", "max-concurrent"], ["context_window", "context-window"]]) {
1345
+ if (num(flagName) !== undefined) agent[field] = num(flagName);
1346
+ }
1347
+ const thinking = resolveThinkingFlag();
1348
+ if (thinking !== undefined) agent.thinking = thinking;
1349
+ }
1350
+ const written = saveAgents({ ...agents, [name]: agent });
1351
+ const saved = written[name];
1352
+ let registered = null, mcpUpdated = false;
1353
+ if (kind === "api" && flag("register")) registered = registerProviderWithOpenClaw(apiAgentAsPoolEntry(name, saved));
1354
+ if (kind === "api" && flag("update-mcp")) {
1355
+ connectClaude({ nomarmyRoot, run: (cmd, args2, opts = {}) => execFileSync(cmd, args2, { stdio: "ignore", ...opts }), extraEnv: { [saved.auth_env]: "registered" } });
1356
+ mcpUpdated = true;
1357
+ }
1358
+ return out({ written: agentsConfigPath(globalConfigDir()), name, agent: saved, ...(kind === "api" ? { registered, mcpUpdated } : {}) });
1359
+ }
1360
+
1361
+ // --- update ---
1362
+
1363
+ // Kind, provider and owner are fixed: changing any of them is a different
1364
+ // agent (a different model family, vendor, or person's login), so that's
1365
+ // `agents add`, not an edit. A subscription model change gets the same real
1366
+ // test call `add` makes (interactive always; --json only with --probe,
1367
+ // since it spends a real request on the subscription).
1368
+ async function cmdAgentsUpdate() {
1369
+ const name = argv[2];
1370
+ if (!name) throw new Error("Usage: nomarmy agents update <name> [--model <m>|--no-model] [--slot coder|gpt] [--auth-env <NAME>] [--base-url <url>] [--max-concurrent <n>] [--context-window <tokens>] [--thinking [low|medium|high]|--no-thinking] [--probe]");
1371
+ const agents = fileAgentsOrExit();
1372
+ const current = Object.prototype.hasOwnProperty.call(agents, name) ? agents[name] : name === "local" ? { ...BUILTIN_LOCAL_AGENT } : undefined;
1373
+ if (!current) throw new Error(`Unknown agent "${name}". Your agents: ${Object.keys(loadAgentsOrExit().agents).join(", ")}`);
1374
+
1375
+ const changes = {};
1376
+ let probe = false;
1377
+ if (json) {
1378
+ const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
1379
+ if (value("model") !== null) changes.model = value("model");
1380
+ // Back to no default model: every role (or job) then names its own.
1381
+ if (flag("no-model")) { if (value("model") !== null) throw new Error("Pass --model or --no-model, not both."); changes.model = undefined; }
1382
+ if (value("slot") !== null) changes.slot = value("slot");
1383
+ if (value("auth-env") !== null) changes.auth_env = value("auth-env");
1384
+ if (value("base-url") !== null) changes.base_url = value("base-url");
1385
+ if (num("max-concurrent") !== undefined) changes.max_concurrent = num("max-concurrent");
1386
+ if (num("context-window") !== undefined) changes.context_window = num("context-window");
1387
+ const thinking = resolveThinkingFlag();
1388
+ if (thinking !== undefined) changes.thinking = thinking;
1389
+ if (flag("owner") || value("owner") !== null || value("provider") !== null || value("kind") !== null) {
1390
+ throw new Error("Kind, provider and owner can't be changed -- that's a different agent. Use `nomarmy agents add`.");
1391
+ }
1392
+ probe = flag("probe") && current.kind === "subscription";
1393
+ if (!Object.keys(changes).length) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
1394
+ } else {
1395
+ if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or --json with explicit flags.");
1396
+ const rl = createInterface({ input, output });
1397
+ try {
1398
+ console.log(c.bold("🍪 nomArmy agents update") + c.dim(` (${name}: ${describeAgentLabel(current)})`));
1399
+ console.log(c.dim("Blank keeps the current value.\n"));
1400
+ if (current.kind === "local") {
1401
+ const slot = (await rl.question(c.bold(`Slot, coder or gpt [${current.slot}]: `))).trim();
1402
+ if (slot && slot !== current.slot) changes.slot = slot;
1403
+ } else {
1404
+ const provId = current.kind === "subscription" ? current.provider : current.provider === "openclaw" ? current.openclaw_provider : current.provider;
1405
+ const models = catalogModelsFor(provId);
1406
+ if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}${m === current.model ? c.dim(" (current)") : ""}`));
1407
+ const pick = (await rl.question(c.bold(`Default model${models.length ? " -- a number, or type an id" : ""} [${current.model ?? "none: each role picks"}] ("-" clears it): `))).trim();
1408
+ if (pick === "-") { if (current.model) changes.model = undefined; }
1409
+ else {
1410
+ const chosen = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick;
1411
+ if (pick && !chosen) throw new Error(`Not a valid choice: "${pick}".`);
1412
+ if (chosen && chosen !== current.model) changes.model = chosen;
1413
+ }
1414
+ if (current.kind === "api") {
1415
+ const env = await askUntilValid(rl, `API key environment variable NAME [${current.auth_env}]: `, {
1416
+ allowEmpty: true, fallback: current.auth_env, pattern: AUTH_ENV_NAME_RE, invalidMessage: "must look like an ENVIRONMENT VARIABLE NAME, not the key itself.",
1417
+ });
1418
+ if (env !== current.auth_env) changes.auth_env = env;
1419
+ }
1420
+ const mc = (await rl.question(c.bold(`max_concurrent [${current.max_concurrent}]: `))).trim();
1421
+ if (mc) changes.max_concurrent = Number(mc);
1422
+ const label = current.thinking === false ? "off" : current.thinking === true ? "on, follows the job" : `fixed at "${current.thinking}"`;
1423
+ const thinking = parseThinkingAnswer(await rl.question(c.bold(`Thinking: y = follow the job, n = off, or low/medium/high [current: ${label}]: `)), current.thinking);
1424
+ if (thinking !== current.thinking) changes.thinking = thinking;
1425
+ if (changes.model && current.kind === "subscription") {
1426
+ console.log(`\n${c.bold("→")} Test call`);
1427
+ if (probeWorker(current.provider, changes.model)) console.log(c.green(`✓ ${current.provider}/${changes.model} answered a real test prompt.`));
1428
+ else {
1429
+ console.log(c.red(`✗ A real test prompt to ${current.provider}/${changes.model} didn't come back.`));
1430
+ if (!(await confirm(rl, "Save the change anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
1431
+ }
1432
+ }
1433
+ }
1434
+ } finally {
1435
+ rl.close();
1436
+ }
1437
+ if (!Object.keys(changes).length) { console.log(c.dim("\nNothing changed.")); return; }
1438
+ }
1439
+
1440
+ if (probe && changes.model && !probeWorker(current.provider, changes.model)) {
1441
+ out({ error: `a real test prompt to ${current.provider}/${changes.model} didn't come back -- nothing was written` });
1442
+ process.exit(1);
1443
+ }
1444
+ const next = { ...current, ...changes };
1445
+ for (const [k, v] of Object.entries(next)) if (v === undefined) delete next[k];
1446
+ const written = saveAgents({ ...agents, [name]: next });
1447
+ if (json) return out({ updated: true, name, agent: written[name], changed: Object.keys(changes) });
1448
+ console.log(c.green(`\n✓ Updated "${name}": ${describeAgentLabel(written[name])}.`));
1449
+ if (changes.auth_env) console.log(c.yellow(`The key's variable changed to ${changes.auth_env}: run \`nomarmy connect claude\` so the MCP server sees it.`));
1450
+ else console.log(c.dim("Applies to the next job, no restart."));
1451
+ }
1452
+
1453
+ async function cmdAgents() {
1454
+ const sub = argv[1] ?? "list";
1455
+ if (sub === "list") return cmdAgentsList();
1456
+ if (sub === "add") return cmdAgentsAdd();
1457
+ if (sub === "update") return cmdAgentsUpdate();
1458
+ if (sub === "remove") return cmdAgentsRemove();
1459
+ throw new Error(`Unknown agents subcommand "${sub}". Use: nomarmy agents <list|add|update|remove>`);
1460
+ }
1461
+
1462
+ function git(args) {
1463
+ try { return execFileSync("git", args, { cwd: nomarmyRoot, encoding: "utf8" }).trim(); }
1464
+ catch (error) { throw new Error(`git ${args.join(" ")} failed: ${error.stderr ? String(error.stderr).trim() : error.message}`); }
1465
+ }
1466
+
1467
+ /**
1468
+ * `nomarmy update`: pull and apply the latest nomArmy code -- NOT a model
1469
+ * swap, see `nomarmy model` for that. Real motivation: the MCP server Claude
1470
+ * Code actually runs is a COPY (installMcpCopy, in lib/connect.mjs, copies
1471
+ * mcp/server.mjs + lib/ + package.json into
1472
+ * ~/.local/share/nomarmy-local-worker and registers that path), not this
1473
+ * checkout -- a bare `git pull` here changes nothing Claude Code is running
1474
+ * until that copy step reruns.
1475
+ */
1476
+ async function cmdUpdate() {
1477
+ const say = (s) => { if (!json) console.log(s); };
1478
+ // Installed from npm: there's no checkout to pull. npm updates the
1479
+ // package; connect resyncs the copy each coordinator runs.
1480
+ if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) {
1481
+ const how = "npm install -g nomarmy@alpha && nomarmy connect";
1482
+ if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
1483
+ console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
1484
+ return;
1485
+ }
1486
+ const status = git(["status", "--porcelain"]);
1487
+ if (status) {
1488
+ if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
1489
+ console.log(c.red("Working tree is not clean -- refusing to pull over local changes:"));
1490
+ console.log(status);
1491
+ process.exit(1);
1492
+ }
1493
+
1494
+ git(["fetch"]);
1495
+ const local = git(["rev-parse", "HEAD"]);
1496
+ const remote = git(["rev-parse", "@{u}"]);
1497
+ const base = git(["merge-base", "HEAD", "@{u}"]);
1498
+ if (local === remote) {
1499
+ if (json) return out({ updated: false, reason: "already up to date" });
1500
+ console.log(c.green("✓ Already up to date."));
1501
+ return;
1502
+ }
1503
+ if (base !== local) {
1504
+ if (json) { out({ error: "local branch has diverged from upstream; not a clean fast-forward", local, remote, base }); process.exit(1); }
1505
+ console.log(c.red("Local branch has diverged from upstream -- not a clean fast-forward. Resolve by hand (rebase or merge), then rerun.")); process.exit(1);
1506
+ }
1507
+
1508
+ say(c.bold("🍪 nomArmy update\n"));
1509
+ say("Pulling...");
1510
+ git(["merge", "--ff-only", "@{u}"]);
1511
+ say(c.green(`✓ Pulled to ${git(["rev-parse", "--short", "HEAD"])}.`));
1512
+
1513
+ say("\nInstalling dependencies...");
1514
+ execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: nomarmyRoot, stdio: json ? "ignore" : "inherit" });
1515
+
1516
+ const resynced = [];
1517
+ const runInherit = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
1518
+ if (commandExists("claude")) {
1519
+ say("\nRe-syncing the Claude Code MCP install...");
1520
+ connectClaude({ nomarmyRoot, run: runInherit });
1521
+ resynced.push("claude");
1522
+ }
1523
+ if (commandExists("codex")) {
1524
+ say("\nRe-syncing the Codex MCP install...");
1525
+ connectCodex({ nomarmyRoot, run: runInherit });
1526
+ resynced.push("codex");
1527
+ }
1528
+ // Cursor has no CLI/PATH binary to probe with commandExists -- "already
1529
+ // connected" is read from its own config file instead.
1530
+ if (cursorAlreadyConnected()) {
1531
+ say("\nRe-syncing the Cursor MCP install...");
1532
+ connectCursor({ nomarmyRoot, run: runInherit });
1533
+ resynced.push("cursor");
1534
+ }
1535
+
1536
+ if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
1537
+ console.log(c.yellow("\nThe MCP server is a per-session child process: every open Claude Code / Codex / Cursor session needs a restart to pick this up, not just this one."));
1538
+ }
1539
+
1540
+ function commandExists(cmd) {
1541
+ try { execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }); return true; }
1542
+ catch { return false; }
1543
+ }
1544
+
1545
+ /** A dependency-free multi-select: numbered checklist, comma-separated
1546
+ * answer, matching the plain-readline style already used elsewhere in this
1547
+ * CLI (setup's numbered "Choice [1]:" prompts) rather than pulling in an
1548
+ * arrow-key TUI library for one prompt. */
1549
+ async function promptMultiSelect(options, question) {
1550
+ const rl = createInterface({ input, output });
1551
+ try {
1552
+ console.log(c.bold(question));
1553
+ options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
1554
+ const answer = (await rl.question(c.bold('Choice(s) [comma-separated numbers, or "all"]: '))).trim().toLowerCase();
1555
+ if (!answer) return [];
1556
+ if (answer === "all") return [...options];
1557
+ const picked = new Set();
1558
+ for (const token of answer.split(",").map((s) => s.trim()).filter(Boolean)) {
1559
+ const idx = Number(token);
1560
+ if (Number.isInteger(idx) && idx >= 1 && idx <= options.length) picked.add(options[idx - 1]);
1561
+ }
1562
+ return [...picked];
1563
+ } finally {
1564
+ rl.close();
1565
+ }
1566
+ }
1567
+
1568
+ /**
1569
+ * `nomarmy connect [claude] [codex] [cursor]`: (re-)register the MCP server
1570
+ * with one or more coordinators on its own, without a full `update`. Useful
1571
+ * standalone -- e.g. a coordinator installed *after* nomArmy already was --
1572
+ * not only as an update step. With no target named and not --json, prompts
1573
+ * an interactive multi-select instead of requiring one call per target.
1574
+ */
1575
+ async function cmdConnect() {
1576
+ // Positional, but never a fixed argv index: every other command in this
1577
+ // CLI is position-independent with respect to global flags (flag()/value()
1578
+ // scan the whole argv), and `nomarmy connect --json claude` once broke
1579
+ // that promise by reading argv[1] directly -- --json landed in target's
1580
+ // slot instead. Multiple bare tokens are now allowed too, for multi-select.
1581
+ const requested = argv.slice(1).filter((a) => !a.startsWith("--"));
1582
+ let targets;
1583
+ if (requested.length > 0) {
1584
+ const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
1585
+ if (unknown.length) throw new Error(`unknown target(s) ${unknown.join(", ")}. Known: ${KNOWN_TARGETS.join(", ")}.`);
1586
+ targets = [...new Set(requested)];
1587
+ } else if (json) {
1588
+ throw new Error(`nomarmy connect --json needs at least one target: ${KNOWN_TARGETS.join(", ")}.`);
1589
+ } else {
1590
+ targets = await promptMultiSelect(KNOWN_TARGETS, "🍪 Which coordinator(s) should nomArmy register with?");
1591
+ if (targets.length === 0) { console.log("Nothing selected."); return; }
1592
+ }
1593
+
1594
+ const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
1595
+ const results = [];
1596
+ for (const target of targets) {
1597
+ // Cursor is a JSON file, not a CLI on PATH -- nothing to probe there.
1598
+ if (target !== "cursor" && !commandExists(target)) {
1599
+ const error = `${target} was not found on PATH.`;
1600
+ results.push({ target, connected: false, error });
1601
+ if (!json) console.log(c.red(`✗ ${target}: ${error}`));
1602
+ continue;
1603
+ }
1604
+ try {
1605
+ if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
1606
+ const result = connectTarget(target, { nomarmyRoot, run });
1607
+ results.push({ target, connected: true, ...result });
1608
+ if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
1609
+ if (!json && result?.commands?.installed?.length) console.log(c.green(`✓ Playbooks: ${result.commands.installed.join(", ")}`) + c.dim(` in ${result.commands.dir} (restart ${target} to pick up a new one)`));
1610
+ if (!json && result?.notifier?.status === "built") console.log(c.green("✓ Notifications: nomArmy.app, with nomArmy's icon") + c.dim(" (macOS asks once whether to allow it)"));
1611
+ if (!json && result?.notifier?.status === "failed") console.log(c.yellow(`⚠ Couldn't build nomArmy.app (${result.notifier.reason}); notifications still work, with Script Editor's icon. Xcode's command-line tools provide swiftc: xcode-select --install`));
1612
+ if (!json && result?.statusLine === "installed") console.log(c.green("✓ Claude Code status line: nomArmy's") + c.dim(" (shows running jobs and the active run; restart Claude Code to see it)"));
1613
+ if (!json && result?.statusLine === "kept-yours") console.log(c.dim("Kept your own Claude Code status line. To add nomArmy's to it, have your command also run `nomarmy statusline`."));
1614
+ if (!json && result?.commands?.skipped?.length) console.log(c.yellow(`⚠ Left your own ${result.commands.skipped.join(", ")} in ${result.commands.dir} alone (not nomArmy's); nomArmy's version is in playbooks/.`));
1615
+ } catch (error) {
1616
+ results.push({ target, connected: false, error: error.message });
1617
+ if (!json) console.log(c.red(`✗ ${target}: ${error.message}`));
1618
+ }
1619
+ }
1620
+
1621
+ const failed = results.filter((r) => !r.connected);
1622
+ if (failed.length) process.exitCode = 1;
1623
+ if (json) return out({ results });
1624
+ }
1625
+
1626
+ /** Thin, mechanical wrappers around already-working scripts -- unlike connect's port to JS, these are real bash process/PID management with no awkward Node-calling-Node seam to fix, so spawning them is the right amount of wrapping, not under- or over-engineering it. */
1627
+ function runScript(name, args = []) {
1628
+ execFileSync("bash", [path.join(nomarmyRoot, "scripts", name), ...args], { cwd: nomarmyRoot, stdio: "inherit" });
1629
+ }
1630
+ async function cmdStart() { console.log(c.bold("🍪 Starting inference...\n")); runScript("start-inference.sh", argv.slice(1)); }
1631
+ async function cmdStop() { runScript("stop-inference.sh", argv.slice(1)); }
1632
+ function safeDu(dir) {
1633
+ try { return execFileSync("du", ["-sh", dir], { encoding: "utf8" }).trim().split(/\s+/)[0]; }
1634
+ catch { return "unknown size"; }
1635
+ }
1636
+
1637
+ /** The llama.cpp build + job/log records live here, separate from the small
1638
+ * MCP install dir uninstall.sh already removes -- see install-llama-cpp.sh
1639
+ * and start-inference.sh, which both default to this same path. */
1640
+ function agentsDirDefault() {
1641
+ return process.env.NOMARMY_INSTALL_ROOT
1642
+ || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-agents");
1643
+ }
1644
+
1645
+ async function confirmDestructive(question, force) {
1646
+ if (force) return true;
1647
+ const rl = createInterface({ input, output });
1648
+ try {
1649
+ const answer = (await rl.question(c.bold(question))).trim().toLowerCase();
1650
+ return answer === "y" || answer === "yes";
1651
+ } finally {
1652
+ rl.close();
1653
+ }
1654
+ }
1655
+
1656
+ async function maybeRemoveAgentsDir({ force }) {
1657
+ const dir = agentsDirDefault();
1658
+ if (!fs.existsSync(dir)) return null;
1659
+ const size = safeDu(dir);
1660
+ const ok = await confirmDestructive(
1661
+ `\nAlso remove ${dir} (${size}) -- job records, logs, and the built llama.cpp binary? A fresh install will need to rebuild it. [y/N] `,
1662
+ force,
1663
+ );
1664
+ if (!ok) { console.log(" Skipped -- left in place."); return false; }
1665
+ fs.rmSync(dir, { recursive: true, force: true });
1666
+ console.log(c.green(` Removed ${dir} (${size}).`));
1667
+ return true;
1668
+ }
1669
+
1670
+ /** Only the repo(s) nomArmy's OWN config declares -- never a blanket sweep
1671
+ * of ~/.cache/huggingface/hub, which is shared with any other tool using
1672
+ * huggingface_hub's standard cache. A model tested via a one-off
1673
+ * NOMARMY_MODEL_REPO override (never written to config/common.env, the way
1674
+ * most of tonight's model comparisons were run) is not tracked here and
1675
+ * needs manual cleanup -- an honest, bounded scope beats guessing at which
1676
+ * cache entries are "ours". */
1677
+ function resolveConfiguredModelRepos() {
1678
+ const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
1679
+ return repo ? [repo] : [];
1680
+ }
1681
+
1682
+ function hfCacheDirFor(repo) {
1683
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
1684
+ return path.join(home, ".cache", "huggingface", "hub", `models--${repo.replace(/\//g, "--")}`);
1685
+ }
1686
+
1687
+ async function maybeRemoveModelCaches({ force }) {
1688
+ const removed = [];
1689
+ for (const repo of resolveConfiguredModelRepos()) {
1690
+ const dir = hfCacheDirFor(repo);
1691
+ if (!fs.existsSync(dir)) continue;
1692
+ const size = safeDu(dir);
1693
+ const ok = await confirmDestructive(`Also remove cached model ${repo} (${size}) from ~/.cache/huggingface/hub? [y/N] `, force);
1694
+ if (!ok) { console.log(` Skipped ${repo} -- left in place.`); continue; }
1695
+ fs.rmSync(dir, { recursive: true, force: true });
1696
+ console.log(c.green(` Removed cached model ${repo} (${size}).`));
1697
+ removed.push(repo);
1698
+ }
1699
+ if (!removed.length) {
1700
+ console.log(" No configured model repo found cached locally, or it was skipped above. Note: models tested via a manual NOMARMY_MODEL_REPO override, never saved to config/common.env, are not tracked here and need manual cleanup.");
1701
+ }
1702
+ return removed;
1703
+ }
1704
+
1705
+ async function cmdUninstall() {
1706
+ const clearModels = flag("clear-models") || flag("all");
1707
+ const clearAgents = flag("clear-agents") || flag("all");
1708
+ const force = flag("force");
1709
+
1710
+ if ((clearModels || clearAgents) && json && !force) {
1711
+ throw new Error("--clear-models/--clear-agents with --json needs --force -- these delete real, possibly multi-GB local state, and nothing is removed without explicit confirmation.");
1712
+ }
1713
+
1714
+ if (!json) console.log(c.yellow("Removing the nomArmy local worker MCP installation...\n"));
1715
+ runScript("uninstall.sh");
1716
+
1717
+ const result = { uninstalled: true, agentsRemoved: null, modelsRemoved: [] };
1718
+ if (clearAgents) result.agentsRemoved = await maybeRemoveAgentsDir({ force });
1719
+ if (clearModels) result.modelsRemoved = await maybeRemoveModelCaches({ force });
1720
+
1721
+ if (json) return out(result);
1722
+ console.log(c.green("\n✓ Removed."));
1723
+ if (!clearAgents) console.log(" Job records, logs, and the built llama.cpp binary under ~/.local/share/nomarmy-local-agents were kept -- pass --clear-agents to also remove them.");
1724
+ if (!clearModels) console.log(" Downloaded model files under ~/.cache/huggingface/hub were kept -- pass --clear-models to also remove the one(s) nomArmy's config references.");
1725
+ }
1726
+
1727
+ async function cmdSizing() {
1728
+ const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
1729
+ const hardware = await detectHardware();
1730
+ const modelPath = findModel();
1731
+ const gguf = modelPath ? await readGGUFMetadata(modelPath) : { found: false };
1732
+ if (gguf.found) gguf.fileSizeBytes = totalSplitBytes(modelPath);
1733
+
1734
+ if (flag("check")) return sizingCheck(hardware, gguf);
1735
+
1736
+ // Optional: if NOMARMY_LLAMA_CACHE_TYPE_K/V are set (quantizing the KV
1737
+ // cache to fit more context), reflect that in the estimate instead of
1738
+ // silently assuming fp16. Unset by default -- nothing changes for anyone
1739
+ // who hasn't touched these.
1740
+ const bytesPerKvElement = bytesPerKvElementForCacheTypes(
1741
+ process.env.NOMARMY_LLAMA_CACHE_TYPE_K, process.env.NOMARMY_LLAMA_CACHE_TYPE_V);
1742
+
1743
+ // --noms N: size for an EXACT worker count instead of "more noms" (max
1744
+ // that fits) or "nominal" (fixed at 1). Bypasses the rest of the report
1745
+ // entirely -- scriptable, and works in --json too.
1746
+ const nomsFlag = value("noms");
1747
+ if (nomsFlag !== null) {
1748
+ const noms = Number(nomsFlag);
1749
+ if (!Number.isFinite(noms) || noms < 1) throw new Error(`--noms must be a positive number, got "${nomsFlag}".`);
1750
+ const custom = customRecommendation({ hardware, gguf, execution, noms, bytesPerKvElement });
1751
+ if (json) return out({ hardware, gguf: { found: gguf.found, path: gguf.path ?? null }, recommendation: custom });
1752
+ printHardwareAndModel(hardware, gguf);
1753
+ printCustomResult(custom);
1754
+ return;
1755
+ }
1756
+
1757
+ const res = recommend({ hardware, gguf, execution, bytesPerKvElement });
1758
+ if (json) {
1759
+ return out({ hardware, gguf: { found: gguf.found, path: gguf.path ?? null }, recommendation: res });
1760
+ }
1761
+
1762
+ if (res.kind === "cloud") {
1763
+ console.log(`Execution is '${execution}' - hosted inference, so local hardware does not bound this.\n`);
1764
+ console.log(` NOMARMY_MAX_WORKERS=${res.maxWorkers}`);
1765
+ console.log(`\nBounded by ${res.limitedBy}, not by this machine.`);
1766
+ printWarnings(res.warnings);
1767
+ return;
1768
+ }
1769
+
1770
+ printHardwareAndModel(hardware, gguf);
1771
+
1772
+ // Never present a configuration as "recommended" when the arithmetic says it
1773
+ // does not fit. The fallback is a floor to start from, not an endorsement.
1774
+ // fits is a tri-state (true/false/null): null means hardware was totally
1775
+ // unmeasurable, not that it fits -- `null !== false` must not fall through
1776
+ // to the "measured, fits" message, or an unmeasured machine gets the exact
1777
+ // same confident framing as a real recommendation.
1778
+ const doesNotFit = res.memory && res.memory.fits === false;
1779
+ const unmeasured = res.memory && res.memory.fits === null;
1780
+ console.log(doesNotFit
1781
+ ? `\nNOTHING FITS on this machine. Closest fallback (confidence: ${res.confidence}):\n`
1782
+ : unmeasured
1783
+ ? `\nHARDWARE COULD NOT BE MEASURED. This is an unverified floor, not a recommendation (confidence: ${res.confidence}):\n`
1784
+ : `\nMore noms (confidence: ${res.confidence}) -- as many as fit in memory:\n`);
1785
+ for (const [k, v] of Object.entries(res.env ?? {})) console.log(` ${k}=${v}`);
1786
+ console.log(`\n ${res.maxWorkers} nom(s) at ${K(res.contextPerNom)} each`
1787
+ + (res.contextTotal ? ` (${res.contextTotal} total across ${res.llamaParallel} slot(s))` : ""));
1788
+ if (res.limitedBy) console.log(` limited by: ${res.limitedBy}`);
1789
+
1790
+ // "More noms" answers what fits in memory; it has no model of inference
1791
+ // speed or worker contention at all. Every profile actually shipped in
1792
+ // config/profiles/*.env uses 1-2 workers regardless of how much more
1793
+ // would fit -- "nominal" makes that convention explicit rather than
1794
+ // leaving it as something you only learn by reading the README's
1795
+ // benchmarks. No third "fast" tier: worker count is the only
1796
+ // speed-relevant lever this project has real (measured) data for, and it
1797
+ // collapses to the same thing as nominal.
1798
+ if (res.nominal && !res.nominal.sameAsRecommended) {
1799
+ console.log(`\nNominal -- 1 nom, matching this project's own shipped profiles${res.nominal.fits ? "" : " (DOES NOT FIT either -- nothing on this machine does)"}:\n`);
1800
+ for (const [k, v] of Object.entries(res.nominal.env)) console.log(` ${k}=${v}`);
1801
+ }
1802
+
1803
+ if (res.alternatives?.length) {
1804
+ console.log("\nAlternatives:");
1805
+ for (const a of res.alternatives) {
1806
+ const label = a.label ?? `${a.maxWorkers} nom(s) @ ${K(a.contextPerNom)}`;
1807
+ console.log(` ${String(label).padEnd(26)}${a.fits === false ? "does not fit" : "fits"}`);
1808
+ }
1809
+ }
1810
+ printWarnings(res.warnings);
1811
+ if (res.assumptions?.length) {
1812
+ console.log("\nAssumptions:");
1813
+ for (const a of res.assumptions) console.log(` ${a}`);
1814
+ }
1815
+ // Context bounds text, so show what a nom at this context can be asked and
1816
+ // how much it can say back. Memory pressure is checked at job admission by
1817
+ // the MCP server, not here.
1818
+ const { deriveBudgets, describeBudgets } = await import("../lib/budget.mjs");
1819
+ console.log("\nWorker budgets at this context:");
1820
+ for (const line of describeBudgets(deriveBudgets({ contextPerNom: res.contextPerNom, source: "this recommendation" }))) console.log(` ${line}`);
1821
+ console.log("\nThis is a recommendation. Apply it by editing config/profiles/<profile>.env.");
1822
+
1823
+ // "More noms"/"nominal" are both already fully printed above; the one
1824
+ // thing this command couldn't answer without a re-run was "what about N
1825
+ // workers specifically". Skipped entirely for cloud (no local slots to
1826
+ // size) and whenever stdin isn't interactive -- readline resolves an
1827
+ // unanswerable question with "" on EOF, so this degrades safely under a
1828
+ // pipe or in CI rather than hanging.
1829
+ if (res.kind === "local") {
1830
+ const rl = createInterface({ input, output });
1831
+ let answer;
1832
+ try {
1833
+ answer = (await rl.question(c.bold("\nSize for a specific worker count instead? Enter a number, or press Enter to skip: "))).trim();
1834
+ } finally {
1835
+ rl.close();
1836
+ }
1837
+ if (answer) {
1838
+ const noms = Number(answer);
1839
+ if (!Number.isFinite(noms) || noms < 1) console.log(`Not a positive number: "${answer}". Skipped.`);
1840
+ else printCustomResult(customRecommendation({ hardware, gguf, execution, noms, bytesPerKvElement }));
1841
+ }
1842
+ }
1843
+ }
1844
+
1845
+ function printHardwareAndModel(hardware, gguf) {
1846
+ console.log(`Hardware: ${hardware.platform}/${hardware.arch}`
1847
+ + `, ${hardware.cpu?.logicalCores ?? "?"} logical cores`
1848
+ + `, ${gib(hardware.memory?.totalBytes)} RAM`
1849
+ + (hardware.gpu?.count ? `, ${hardware.gpu.count} GPU` : ", no NVIDIA GPU"));
1850
+ console.log(`Model: ${gguf.found
1851
+ ? `${path.basename(gguf.path)} (${gib(gguf.fileSizeBytes)})`
1852
+ : "not found - using assumed architecture"}`);
1853
+ }
1854
+
1855
+ function printCustomResult(res) {
1856
+ if (res.kind === "cloud") {
1857
+ console.log(`\nCustom -- ${res.requestedNoms} worker(s), hosted execution (no local memory ceiling):\n`);
1858
+ for (const [k, v] of Object.entries(res.env)) console.log(` ${k}=${v}`);
1859
+ return;
1860
+ }
1861
+ if (!res.fits) {
1862
+ console.log(`\nCustom -- ${res.requestedNoms} worker(s) DOES NOT FIT on this machine, even at the minimum context (${K(MIN_CONTEXT_PER_NOM)}).`);
1863
+ return;
1864
+ }
1865
+ console.log(`\nCustom -- ${res.requestedNoms} worker(s) as requested`
1866
+ + (res.steppedDownFrom ? `, context stepped down from ${K(res.steppedDownFrom)} to fit` : "") + ":\n");
1867
+ for (const [k, v] of Object.entries(res.env)) console.log(` ${k}=${v}`);
1868
+ console.log(`\n ${res.requestedNoms} nom(s) at ${K(res.contextPerNom)} each (${res.contextTotal} total across ${res.llamaParallel} slot(s))`);
1869
+ if (res.limitedBy) console.log(` limited by: ${res.limitedBy}`);
1870
+ }
1871
+
1872
+ function sizingCheck(hardware, gguf) {
1873
+ const contextTotal = Number(process.env.NOMARMY_LLAMA_CONTEXT) || null;
1874
+ const llamaParallel = Number(process.env.NOMARMY_LLAMA_PARALLEL) || null;
1875
+ const maxWorkers = Number(process.env.NOMARMY_MAX_WORKERS) || null;
1876
+ if (!contextTotal || !llamaParallel) {
1877
+ console.error("No profile is loaded. Source one first, for example:");
1878
+ console.error(" source scripts/lib.sh && load_profile macbook-pro && nomarmy sizing --check");
1879
+ process.exit(2);
1880
+ }
1881
+ const res = evaluateConfig({ hardware, gguf, contextTotal, llamaParallel, maxWorkers });
1882
+ if (json) return out(res);
1883
+ console.log(`Current profile: ${contextTotal} total context / ${llamaParallel} slot(s) / ${maxWorkers} worker(s)\n`);
1884
+ console.log(` context per nom: ${K(res.contextPerNom)}`);
1885
+ printWarnings(res.warnings);
1886
+ process.exit((res.warnings ?? []).some((w) => w.severity === "error") ? 1 : 0);
1887
+ }
1888
+
1889
+ // --- `nomarmy army` / `nomarmy config` ------------------------------------
1890
+ //
1891
+ // The army layers (see lib/army.mjs) are global config.yml, the repo's
1892
+ // .nomarmy.yml and its gitignored .nomarmy.local.yml. `--repo` picks the
1893
+ // repo, same as every other command here; it defaults to the cwd.
1894
+
1895
+ function armyLayerFlag(fallback = "global") {
1896
+ const chosen = ["global", "project", "local"].filter((l) => flag(l));
1897
+ if (chosen.length > 1) throw new Error(`Pick one of --global, --project or --local, not ${chosen.map((l) => `--${l}`).join(" and ")}.`);
1898
+ return chosen[0] ?? fallback;
1899
+ }
1900
+
1901
+ function loadArmyForCli() {
1902
+ const agents = loadAgentsOrExit().agents;
1903
+ const loaded = loadArmy({ projectDir: repoDir });
1904
+ return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel }) };
1905
+ }
1906
+
1907
+ // Claude Code adds settings.local.json to .gitignore for the same reason:
1908
+ // the local layer is personal, and a teammate's checkout must never pick it up.
1909
+ function ensureLocalLayerIgnored() {
1910
+ if (!fs.existsSync(path.join(repoDir, ".git"))) return;
1911
+ const ignorePath = path.join(repoDir, ".gitignore");
1912
+ const text = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : "";
1913
+ if (text.split(/\r?\n/).some((line) => line.trim() === LOCAL_CONFIG_FILENAME || line.trim() === `/${LOCAL_CONFIG_FILENAME}`)) return;
1914
+ fs.writeFileSync(ignorePath, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${LOCAL_CONFIG_FILENAME}\n`);
1915
+ if (!json) console.log(c.dim(`Added ${LOCAL_CONFIG_FILENAME} to .gitignore.`));
1916
+ }
1917
+
1918
+ function agentCell(name, runsOn, role = null) {
1919
+ if (!name) return c.yellow("(no agent)");
1920
+ const model = role?.modelIsAuto ? "auto (the General picks)" : role?.model;
1921
+ return `${name}${model ? ` ${c.bold(model)}` : ""}${runsOn ? c.dim(` ${runsOn}`) : ""}`;
1922
+ }
1923
+
1924
+ async function cmdArmyShow() {
1925
+ const { summary } = loadArmyForCli();
1926
+ if (json) return out(summary);
1927
+ const g = summary.general;
1928
+ console.log(c.bold("🪖 nomArmy") + c.dim(` (${repoDir})`));
1929
+ console.log(`\n${c.bold("General")} ${g.agent ? agentCell(g.agent, g.agentRunsOn) : ""}${g.setBy ? c.dim(` [${g.setBy}]`) : ""}`);
1930
+ console.log(c.dim(` ${g.who}`));
1931
+ for (const line of g.responsibilities) console.log(c.dim(` - ${line}`));
1932
+ if (g.problem) console.log(c.yellow(` ⚠ ${g.problem}`));
1933
+ if (summary.workflow) console.log(`\n${c.bold("Workflow")}\n${summary.workflow.split("\n").map((l) => ` ${l}`).join("\n")}`);
1934
+ const names = Object.keys(summary.roles);
1935
+ if (!names.length) {
1936
+ console.log(c.dim(`\nNo roles yet. ${summary.howToDispatch}`));
1937
+ } else {
1938
+ const byPhase = new Map();
1939
+ for (const name of names) {
1940
+ const phase = summary.roles[name].phase ?? "unphased";
1941
+ if (!byPhase.has(phase)) byPhase.set(phase, []);
1942
+ byPhase.get(phase).push(name);
1943
+ }
1944
+ for (const phase of [...ARMY_PHASES, "unphased"].filter((p) => byPhase.has(p))) {
1945
+ console.log(`\n${c.bold(phase[0].toUpperCase() + phase.slice(1))}`);
1946
+ for (const name of byPhase.get(phase)) {
1947
+ const role = summary.roles[name];
1948
+ console.log(` ${c.cyan(name.padEnd(18))} ${agentCell(role.agent, role.agentRunsOn, role)}${role.setBy.agent ? c.dim(` [${role.setBy.agent}]`) : ""}`);
1949
+ if (role.description) console.log(c.dim(` ${role.description}`));
1950
+ if (role.problem && role.agent) console.log(c.red(` ✗ ${role.problem}`));
1951
+ if (role.overlapsGeneral) console.log(c.yellow(` ⚠ ${role.overlapsGeneral}`));
1952
+ }
1953
+ }
1954
+ }
1955
+ console.log(`\n${c.bold("Layers")} ${c.dim("(later ones win)")}`);
1956
+ for (const layer of summary.layers) {
1957
+ const state = layer.hasArmy ? c.green("● army section") : layer.exists ? c.dim("○ file exists, no army section") : c.dim("○ no file");
1958
+ console.log(` ${layer.layer.padEnd(8)} ${state.padEnd(40)} ${c.dim(layer.path)}`);
1959
+ }
1960
+ }
1961
+
1962
+ async function cmdArmyInit() {
1963
+ const layer = armyLayerFlag("global");
1964
+ const filePath = armyLayerPath(layer, { projectDir: repoDir });
1965
+ const existing = readArmyFile(filePath, { armyOnly: layer !== "project" });
1966
+ if (existing?.roles && Object.keys(existing.roles).length && !flag("force")) {
1967
+ throw new Error(`${filePath} already defines an army (${Object.keys(existing.roles).join(", ")}). Re-run with --force to replace it.`);
1968
+ }
1969
+ // Keep a General already defined in this layer; the roster is what init resets.
1970
+ updateArmyInFile(filePath, (army) => ({ ...structuredClone(DEFAULT_ARMY), ...(army.general ? { general: army.general } : {}) }));
1971
+ if (layer === "local") ensureLocalLayerIgnored();
1972
+ if (json) return out({ written: filePath, layer, roles: Object.keys(DEFAULT_ARMY.roles) });
1973
+ console.log(c.green(`✓ Wrote the default army to ${filePath} (${layer}).`));
1974
+ console.log(c.dim("Every role starts on the local model. Next: `nomarmy army general <agent>` (the agent your coordinator session runs on), then `nomarmy army assign <role> <agent>` for any role you want elsewhere."));
1975
+ }
1976
+
1977
+ async function cmdArmyAssign() {
1978
+ // Positionals only: argv also holds flags, and `--json` must never be read as a model.
1979
+ const positional = argv.slice(2);
1980
+ const firstFlag = positional.findIndex((a) => a.startsWith("--"));
1981
+ const [roleName, agentName, model] = firstFlag === -1 ? positional : positional.slice(0, firstFlag);
1982
+ if (!roleName || !agentName) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
1983
+ const layer = armyLayerFlag("global");
1984
+ const filePath = armyLayerPath(layer, { projectDir: repoDir });
1985
+ const target = parseTargetSpec(agentName, model);
1986
+ const check = flag("no-check") ? { status: "skipped" } : checkRoleModel(agentName, model);
1987
+ if (check.status === "failed") {
1988
+ const msg = `${agentName}/${model} ${check.detail} -- nothing was written. Pick a model from: ${check.listed.join(", ") || "(OpenClaw lists none for this agent)"}, or pass --no-check if you're sure.`;
1989
+ if (json) { out({ error: msg, check }); process.exit(1); }
1990
+ throw new Error(msg);
1991
+ }
1992
+ assignRoleInFile(filePath, roleName, target);
1993
+ if (layer === "local") ensureLocalLayerIgnored();
1994
+ const { summary } = loadArmyForCli();
1995
+ const role = summary.roles[roleName];
1996
+ if (json) return out({ written: filePath, layer, role: roleName, effective: role ?? null, modelCheck: check });
1997
+ if (check.status === "listed") console.log(c.dim(`${model} is in OpenClaw's catalog for ${agentName}, and a real test call worked.`));
1998
+ else if (check.status === "probed") console.log(c.dim(`${model} isn't in OpenClaw's catalog yet, but a real test call to it worked.`));
1999
+ else if (check.status === "unchecked") console.log(c.yellow(`⚠ Couldn't check ${model} (${check.detail}); the first job on this role will find out.`));
2000
+ console.log(c.green(`✓ ${roleName} → ${agentName}${model ? ` (${model === "auto" ? "model: the General picks per job" : model})` : ""} in ${filePath} (${layer}).`));
2001
+ if (role) {
2002
+ if (role.setBy.agent && role.setBy.agent !== layer) console.log(c.yellow(`Note: the ${role.setBy.agent} layer overrides this, so ${roleName} still runs on ${role.agent}.`));
2003
+ if (role.problem && role.agent) console.log(c.yellow(`⚠ ${role.problem}.`));
2004
+ if (role.overlapsGeneral) console.log(c.yellow(`⚠ ${roleName} ${role.overlapsGeneral}.`));
2005
+ if (!role.description) console.log(c.dim(`${roleName} has no description in any layer; the General will only see its name.`));
2006
+ }
2007
+ if (layer === "project") console.log(c.dim("This is committed with the repo; teammates need an agent with that same name in their own agents.yml."));
2008
+ }
2009
+
2010
+ /**
2011
+ * Whether `model` really runs on `agentName`, before a role is pointed at
2012
+ * it: listed in OpenClaw's freshly refreshed catalog, or -- since that
2013
+ * catalog lags new models (xai/grok-4.7 worked while unlisted) -- answering
2014
+ * one real test call. Caught live: gpt-6-sol is in the Codex CLI's own
2015
+ * model list but OpenClaw's openai provider answers "Unknown model".
2016
+ * Nothing to check for "auto", no model, or a local or unknown agent.
2017
+ */
2018
+ function checkRoleModel(agentName, model) {
2019
+ if (!model || model === "auto") return { status: "none" };
2020
+ let agent;
2021
+ try { agent = loadAgents(globalConfigDir()).agents[agentName]; } catch { return { status: "unchecked", detail: "agents.yml didn't load" }; }
2022
+ if (!agent || agent.kind === "local") return { status: "none" };
2023
+ const provider = agent.kind === "api" ? openclawProviderId(agent) : agent.provider;
2024
+ if (!json) console.log(c.dim(`Checking ${model} against OpenClaw's ${provider} models (a catalog refresh takes a few seconds)...`));
2025
+ const listing = runQuiet(openclawCmd(), ["models", "list", "--all", "--refresh"]);
2026
+ if (!listing.ok && !listing.out.trim()) return { status: "unchecked", detail: "openclaw isn't reachable" };
2027
+ const listed = parseCatalogModels(listing.out, provider);
2028
+ // Always one real call: a listed model isn't proof it runs. Muse was
2029
+ // listed (catalog refresh lists meta/muse-spark-1.3) while every job on
2030
+ // it failed "Unknown model", and an assignment checked only against the
2031
+ // list let a Senti review fail 10 seconds in.
2032
+ if (probeWorker(provider, model)) return { status: listed.includes(model) ? "listed" : "probed", listed };
2033
+ const why = lastProbeFailure ? ` (${lastProbeFailure})` : "";
2034
+ return { status: "failed", detail: `${listed.includes(model) ? "is listed in OpenClaw's catalog, but a real test call to it failed" : "isn't in OpenClaw's catalog and a real test call to it failed"}${why}`, listed };
2035
+ }
2036
+
2037
+ // Which agent the General is. Global or local only: it describes the
2038
+ // person's own coordinator session, which a committed project file can't know.
2039
+ async function cmdArmyGeneral() {
2040
+ const agentName = argv[2];
2041
+ if (!agentName) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
2042
+ const layer = armyLayerFlag("global");
2043
+ if (layer === "project") throw new Error("The General is your own coordinator session, so it's set in --global or --local, never in a committed project file.");
2044
+ const agents = loadAgentsOrExit().agents;
2045
+ if (!Object.prototype.hasOwnProperty.call(agents, agentName)) {
2046
+ throw new Error(`Unknown agent "${agentName}". Define it first with \`nomarmy agents add\` (the General's agent is only described, never dispatched to), or pick one of: ${Object.keys(agents).join(", ")}`);
2047
+ }
2048
+ const filePath = armyLayerPath(layer, { projectDir: repoDir });
2049
+ updateArmyInFile(filePath, (army) => ({ ...army, general: agentName }));
2050
+ if (layer === "local") ensureLocalLayerIgnored();
2051
+ const { summary } = loadArmyForCli();
2052
+ const overlaps = Object.entries(summary.roles).filter(([, r]) => r.overlapsGeneral);
2053
+ if (json) return out({ written: filePath, layer, general: agentName, overlaps: Object.fromEntries(overlaps.map(([n, r]) => [n, r.overlapsGeneral])) });
2054
+ console.log(c.green(`✓ The General is "${agentName}" (${describeAgentLabel(agents[agentName])}), in ${filePath} (${layer}).`));
2055
+ for (const [name, role] of overlaps) console.log(c.yellow(`⚠ ${name} ${role.overlapsGeneral}.`));
2056
+ }
2057
+
2058
+ async function cmdArmy() {
2059
+ const sub = argv[1] ?? "show";
2060
+ if (sub === "show") return cmdArmyShow();
2061
+ if (sub === "init") return cmdArmyInit();
2062
+ if (sub === "assign") return cmdArmyAssign();
2063
+ if (sub === "general") return cmdArmyGeneral();
2064
+ throw new Error(`Unknown army subcommand "${sub}". Use: nomarmy army <show|init|assign|general>`);
2065
+ }
2066
+
2067
+ async function cmdConfigPaths() {
2068
+ const agentsPath = agentsConfigPath(globalConfigDir());
2069
+ const army = ["global", "project", "local"].map((layer) => {
2070
+ const p = armyLayerPath(layer, { projectDir: repoDir });
2071
+ return { layer, path: p, exists: fs.existsSync(p) };
2072
+ });
2073
+ if (json) return out({ globalDir: globalConfigDir(), agents: { path: agentsPath, exists: fs.existsSync(agentsPath) }, army });
2074
+ console.log(c.bold("nomArmy config") + c.dim(` (global dir: ${globalConfigDir()})`));
2075
+ console.log(` ${fs.existsSync(agentsPath) ? c.green("●") : c.dim("○")} ${"agents".padEnd(8)} ${c.dim(agentsPath)}`);
2076
+ console.log(c.bold("\nArmy layers"));
2077
+ for (const a of army) console.log(` ${a.exists ? c.green("●") : c.dim("○")} ${a.layer.padEnd(8)} ${c.dim(a.path)}`);
2078
+ }
2079
+
2080
+ async function cmdConfig() {
2081
+ const sub = argv[1] ?? "paths";
2082
+ if (sub === "paths") return cmdConfigPaths();
2083
+ throw new Error(`Unknown config subcommand "${sub}". Use: nomarmy config paths`);
2084
+ }
2085
+
2086
+ // --- `nomarmy jobs [--watch]`: what's running, from any session -----------
2087
+ //
2088
+ // Reads the shared job directory, so it shows every session's jobs. A job
2089
+ // is running when its status says so AND its server process is alive (a
2090
+ // server that died mid-job leaves a stale "running" status behind).
2091
+
2092
+ function jobsRootDir() {
2093
+ return path.join(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"), "jobs");
2094
+ }
2095
+
2096
+ function readJsonSafe(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
2097
+
2098
+ function pidIsAlive(pid) {
2099
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2100
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
2101
+ }
2102
+
2103
+ function collectJobs({ recent = 8 } = {}) {
2104
+ const root = jobsRootDir();
2105
+ let names = [];
2106
+ try { names = fs.readdirSync(root); } catch { return { running: [], recent: [] }; }
2107
+ const jobs = names.map((name) => {
2108
+ const dir = path.join(root, name);
2109
+ const status = readJsonSafe(path.join(dir, "status.json")) ?? {};
2110
+ const meta = readJsonSafe(path.join(dir, "metadata.json"));
2111
+ const started = Date.parse(status.startedAt ?? meta?.startedAt ?? "") || fs.statSync(dir).mtimeMs;
2112
+ const running = status.state === "running" && pidIsAlive(status.serverPid);
2113
+ return {
2114
+ jobId: status.jobId ?? name, mode: status.mode ?? meta?.mode ?? null, running,
2115
+ phase: running ? status.phase : (meta?.outcome ?? (status.state === "running" ? "orphaned" : status.phase ?? "?")),
2116
+ agent: status.agent ?? meta?.worker?.provider ?? null, model: status.model ?? meta?.metrics?.worker_model ?? null,
2117
+ elapsedSeconds: Math.round(((running ? Date.now() : Date.parse(meta?.finishedAt ?? status.updatedAt ?? "") || Date.now()) - started) / 1000),
2118
+ lastTool: status.lastTool ? `${status.lastTool.tool}${status.lastTool.target ? ` ${String(status.lastTool.target).slice(0, 40)}` : ""}` : null,
2119
+ filesChanged: status.filesChangedLive ?? meta?.git?.filesChanged?.length ?? null,
2120
+ heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
2121
+ started, dir,
2122
+ };
2123
+ }).sort((a, b) => b.started - a.started);
2124
+ return { running: jobs.filter((j) => j.running), recent: jobs.filter((j) => !j.running).slice(0, recent) };
2125
+ }
2126
+
2127
+ const fmtSeconds = (n) => (n == null ? "-" : n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m${String(n % 60).padStart(2, "0")}s` : `${Math.floor(n / 3600)}h${String(Math.floor((n % 3600) / 60)).padStart(2, "0")}m`);
2128
+
2129
+ function renderJobs({ running, recent }) {
2130
+ const lines = [c.bold(`🍪 nomArmy jobs`) + c.dim(` ${new Date().toLocaleTimeString()} (${jobsRootDir()})`), ""];
2131
+ lines.push(c.bold(`Running (${running.length})`));
2132
+ if (!running.length) lines.push(c.dim(" nothing running"));
2133
+ for (const j of running) {
2134
+ const beat = j.heartbeatAgeSeconds == null ? c.dim("no heartbeat yet") : j.heartbeatAgeSeconds > 60 ? c.yellow(`heartbeat ${fmtSeconds(j.heartbeatAgeSeconds)} ago`) : c.dim(`heartbeat ${fmtSeconds(j.heartbeatAgeSeconds)} ago`);
2135
+ lines.push(` ${c.cyan(j.jobId)} ${j.agent ?? "local"}${j.model ? `/${j.model}` : ""} ${j.phase} ${fmtSeconds(j.elapsedSeconds)} ${beat}`);
2136
+ lines.push(c.dim(` last tool: ${j.lastTool ?? "-"} files changed: ${j.filesChanged ?? "-"} log: tail -f ${path.join(j.dir, "openclaw.stderr.log")}`));
2137
+ }
2138
+ lines.push("", c.bold("Recent"));
2139
+ for (const j of recent) lines.push(` ${j.jobId.padEnd(40)} ${String(j.phase).padEnd(20)} ${fmtSeconds(j.elapsedSeconds).padStart(7)} ${c.dim(`${j.agent ?? ""}${j.model ? `/${j.model}` : ""}`)}`);
2140
+ return lines.join("\n");
2141
+ }
2142
+
2143
+ /**
2144
+ * `nomarmy jobs --events`: one line per change -- a job started, changed
2145
+ * phase, or finished -- and nothing in between. Made for Claude Code's
2146
+ * background monitor: the General watches this stream and is woken on
2147
+ * each line, instead of polling local_worker_status (each poll costs its
2148
+ * seat usage). With --json, one JSON object per line.
2149
+ */
2150
+ async function streamJobEvents() {
2151
+ const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
2152
+ const seen = new Map();
2153
+ const emit = (event, job, detail = "") => {
2154
+ if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event, jobId: job.jobId, agent: job.agent, model: job.model, phase: job.phase, detail }));
2155
+ else console.log(`${new Date().toLocaleTimeString()} ${event.padEnd(9)} ${job.jobId} ${job.agent ?? "local"}${job.model ? `/${job.model}` : ""}${detail ? ` ${detail}` : ""}`);
2156
+ };
2157
+ process.on("SIGINT", () => process.exit(0));
2158
+ let first = true;
2159
+ for (;;) {
2160
+ const { running, recent } = collectJobs({ recent: 20 });
2161
+ const now = new Map([...running, ...recent].map((j) => [j.jobId, j]));
2162
+ for (const j of running) {
2163
+ const prev = seen.get(j.jobId);
2164
+ if (!prev) { if (!first) emit("started", j, j.phase); else emit("running", j, j.phase); }
2165
+ else if (prev.phase !== j.phase) emit("phase", j, `${prev.phase} -> ${j.phase}`);
2166
+ }
2167
+ for (const [id, prev] of seen) {
2168
+ const j = now.get(id);
2169
+ if (prev.running && j && !j.running) emit("finished", j, `${j.phase} after ${fmtSeconds(j.elapsedSeconds)}`);
2170
+ }
2171
+ seen.clear();
2172
+ for (const [id, j] of now) seen.set(id, j);
2173
+ first = false;
2174
+ await new Promise((r) => setTimeout(r, interval));
2175
+ }
2176
+ }
2177
+
2178
+ /**
2179
+ * `nomarmy jobs --prune [--older-than DAYS]`: remove runtime/ (per-job npm
2180
+ * cache, harness state such as Codex's, OpenClaw's transcript) from
2181
+ * finished jobs older than DAYS (default 2). Each job's record, logs,
2182
+ * report and any retained worktree stay. Job storage reached 1.9 GB and
2183
+ * then 2.4 GB again within a day of real runs, mostly this.
2184
+ */
2185
+ function pruneJobRuntimeCli() {
2186
+ const days = Math.max(0, Number(value("older-than", "2")) || 0);
2187
+ const { pruned, scratchCleared, freedBytes: bytes } = pruneJobRuntime({ stateRoot: agentStateRoot(), olderThanMs: days * 86400000 });
2188
+ if (json) return out({ pruned, scratchCleared, freedBytes: bytes, olderThanDays: days });
2189
+ const parts = [pruned ? `runtime data from ${pruned} finished job(s) older than ${days} day(s)` : null, scratchCleared ? `OpenClaw scratch files from ${scratchCleared} more recent one(s)` : null].filter(Boolean);
2190
+ console.log(parts.length ? c.green(`✓ Removed ${parts.join(" and ")}, freeing ${(bytes / 1024 ** 3).toFixed(2)} GB. Records and reports are kept.`) : c.dim(`Nothing to prune: no finished job older than ${days} day(s) still has runtime data.`));
2191
+ }
2192
+
2193
+ async function cmdJobs() {
2194
+ if (flag("events")) return streamJobEvents();
2195
+ if (flag("prune")) return pruneJobRuntimeCli();
2196
+ if (json) return out(collectJobs());
2197
+ if (!flag("watch")) return console.log(renderJobs(collectJobs()));
2198
+ // No `watch` on macOS, and a shell loop can't run through Claude Code's `!`.
2199
+ const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
2200
+ process.on("SIGINT", () => { process.stdout.write("\n"); process.exit(0); });
2201
+ for (;;) {
2202
+ process.stdout.write("\u001b[2J\u001b[H" + renderJobs(collectJobs()) + c.dim("\n\nCtrl-C to stop.") + "\n");
2203
+ await new Promise((r) => setTimeout(r, interval));
2204
+ }
2205
+ }
2206
+
2207
+ // `nomarmy health`: run the checks now (the MCP server also runs them every
2208
+ // 6 hours) and record them, which also refreshes the status line's warning.
2209
+ async function cmdHealth() {
2210
+ const { checkAndRecordHealth } = await import("../lib/health.mjs");
2211
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2212
+ const { result } = await checkAndRecordHealth({ projectDir: repoDir, stateRoot, configDir: globalConfigDir() });
2213
+ if (json) return out(result);
2214
+ console.log(c.bold("🍪 nomArmy health") + c.dim(` ${new Date(result.checkedAt).toLocaleString()}`));
2215
+ if (!result.issues.length) { console.log(c.green("\n✓ Nothing to fix.")); return; }
2216
+ const mark = { error: c.red("✗"), warn: c.yellow("⚠"), info: c.dim("·") };
2217
+ for (const i of result.issues) {
2218
+ console.log(`\n${mark[i.severity]} ${c.bold(i.title)}`);
2219
+ console.log(c.dim(` ${i.detail}`));
2220
+ if (i.fix) console.log(` fix: ${i.fix}`);
2221
+ }
2222
+ if (result.issues.some((i) => i.severity === "error")) process.exitCode = 1;
2223
+ }
2224
+
2225
+ async function cmdStatusline() {
2226
+ const { statusLineText } = await import("../lib/statusline.mjs");
2227
+ let session = {};
2228
+ if (!process.stdin.isTTY) { try { session = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { /* no session JSON */ } }
2229
+ process.stdout.write(`${statusLineText({ session })}\n`);
2230
+ }
2231
+
2232
+ const commands = { scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2233
+ // doctor command
2234
+ async function cmdDoctor() {
2235
+ // Import lazily to avoid circular dependencies
2236
+ const { runDoctor } = await import("../lib/doctor.mjs");
2237
+ await runDoctor({ json, exit: true });
2238
+ }
2239
+ commands.doctor = cmdDoctor;
2240
+ if (!command || flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
2241
+
2242
+ try {
2243
+ await commands[command]();
2244
+ } catch (err) {
2245
+ if (json) out({ error: err.message });
2246
+ else console.error(`nomarmy ${command}: ${err.message}`);
2247
+ process.exit(1);
2248
+ }