nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
package/bin/nomarmy.mjs CHANGED
@@ -2,10 +2,10 @@
2
2
  // nomArmy CLI. Every command proposes before it writes anything -- init,
3
3
  // setup, model and update all show exactly what would change and write only
4
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.
5
+ // (--write, --json with the required choices given up front). System-level
6
+ // setup (install.sh: OpenClaw, the sandbox, llama.cpp for a local model)
7
+ // runs only when asked: `nomarmy install`, or `nomarmy setup` after it has
8
+ // shown the exact command and the operator said yes.
9
9
  import fs from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
@@ -18,15 +18,48 @@ import { buildConfigProposal } from "../lib/propose.mjs";
18
18
  import { detectHardware } from "../lib/hardware.mjs";
19
19
  import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
20
20
  import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
21
- import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv } from "../lib/connect.mjs";
21
+ import { windowsFrontEnd, dropWindowsPath, markWindowsCoordinator, pickDistro, resolveWslNomarmy, mcpBridgeLaunch, writeWindowsSettings } from "../lib/wsl.mjs";
22
+ import { windowsPlan, windowsForward, windowsSetup, windowsDoctor } from "../lib/wsl-cli.mjs";
23
+ import { connectViaWsl, connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
24
+ import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
25
+ import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
26
+ import { requestJobStop } from "../lib/openclaw-run.mjs";
27
+ import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools } from "../lib/validators.mjs";
28
+ import { probeModel } from "../lib/model-probe.mjs";
22
29
  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, agentRunsToolsOnHost, 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";
30
+ import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
31
+ import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
25
32
  import { parseLlamaUrl } from "../lib/execution.mjs";
33
+ import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-steps.mjs";
34
+ import { readUsageSnapshots, refreshStaleOverLimitReadings } from "../lib/usage-limits.mjs";
35
+ import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
36
+ import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
37
+ import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
38
+ import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
39
+ import { liveLeases } from "../lib/slots.mjs";
26
40
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
27
- import { recordProbeSuccess } from "../lib/health.mjs";
41
+ import { linkCodex } from "../lib/codex-link.mjs";
42
+ import { recordProbeSuccess, parseOpenclawAuthProfiles, codexImportRecovery } from "../lib/health.mjs";
28
43
  import { pruneJobRuntime } from "../lib/prune.mjs";
29
- import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
44
+ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, openclawSignInFailure, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
45
+ import { PINNED_OPENCLAW_VERSION, repairOpenclaw, verifyOpenclaw, configuredSubscriptionVendors } from "../lib/openclaw-install.mjs";
46
+ import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
47
+ import { THINKING_LEVELS } from "../lib/thinking.mjs";
48
+ import { fileURLToPath } from "node:url";
49
+ // Inside WSL, only the distro's own tools (see lib/wsl.mjs).
50
+ dropWindowsPath();
51
+ // The Windows front end: whatever it forwards knows the coordinators are on Windows.
52
+ if (windowsFrontEnd()) markWindowsCoordinator();
53
+ // OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
54
+ ensureOpenClawOnPath();
55
+ // Windows starts the engine as `wsl.exe --exec <node> nomarmy.mjs`, with no
56
+ // login shell, so an nvm Node's directory isn't on PATH. Children that need
57
+ // `node` (install.sh, OpenClaw's `#!/usr/bin/env node`) get the one running.
58
+ {
59
+ const nodeDir = path.dirname(process.execPath);
60
+ const entries = (process.env.PATH ?? "").split(path.delimiter);
61
+ if (!entries.includes(nodeDir)) process.env.PATH = [nodeDir, ...entries.filter(Boolean)].join(path.delimiter);
62
+ }
30
63
 
31
64
  // Add a new coordinator: add its name here, teach commandExists/connectTarget
32
65
  // about it below (a JSON-file target like Cursor has no PATH binary to check
@@ -34,10 +67,10 @@ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalo
34
67
  // "already connected, so resync" detection if it has no CLI to probe.
35
68
  const KNOWN_TARGETS = ["claude", "codex", "cursor"];
36
69
 
37
- function connectTarget(target, { nomarmyRoot, run }) {
38
- if (target === "claude") return connectClaude({ nomarmyRoot, run });
39
- if (target === "codex") return connectCodex({ nomarmyRoot, run });
40
- if (target === "cursor") return connectCursor({ nomarmyRoot, run });
70
+ function connectTarget(target, { nomarmyRoot, run, scope = "user", projectDir = null }) {
71
+ if (target === "claude") return connectClaude({ nomarmyRoot, run, scope, projectDir });
72
+ if (target === "codex") return connectCodex({ nomarmyRoot, run, scope });
73
+ if (target === "cursor") return connectCursor({ nomarmyRoot, run, scope, projectDir });
41
74
  throw new Error(`unknown connect target: ${target}`);
42
75
  }
43
76
 
@@ -56,7 +89,7 @@ const repoDir = path.resolve(value("repo", process.cwd()));
56
89
  // through the job's requested reasoning); `--no-thinking` is always false.
57
90
  // Returns undefined when none of these flags were passed at all, so the
58
91
  // caller can tell "not touched" apart from "explicitly set".
59
- const THINKING_LEVELS = ["low", "medium", "high"];
92
+ // Every level OpenClaw accepts (lib/thinking.mjs).
60
93
  function resolveThinkingFlag() {
61
94
  const level = value("thinking");
62
95
  if (level && THINKING_LEVELS.includes(level)) return level;
@@ -89,13 +122,16 @@ Usage: nomarmy <command> [options]
89
122
  and write it after confirmation.
90
123
  --force overwrite an existing .nomarmy.yml
91
124
  --write with --json, write without prompting (needs a valid proposal)
92
- setup Detect this machine, recommend a profile (offering "more
93
- noms" vs "nominal" when they differ), choose a model, and
94
- write config/profiles/<name>.env (+ config/common.env).
95
- Prints the install.sh command; never runs it.
96
- --tier <more|nominal> with --json, skip the prompt
125
+ setup Show setup progress and run the next unfinished step.
126
+ --status [--json] print the checklist only
127
+ --choose choose hosted/local/remote/Bedrock
97
128
  --hosted skip hardware/model questions
98
- --llama-url <url> use a llama-server on another machine
129
+ --llama-url <url> use a shared llama-server
130
+ --json --profile-name <name> [--model <model>] [--tier <more|nominal>]
131
+ write a profile non-interactively
132
+ install Run the bundled installer for the chosen setup profile.
133
+ --profile <name> override NOMARMY_SETUP_PROFILE
134
+ --no-claude skip Claude Code registration
99
135
  model Change the configured model later, without the rest of
100
136
  setup's questions. Offers to also resync the MCP
101
137
  registration's worker-routing env vars, and to restart
@@ -107,8 +143,9 @@ Usage: nomarmy <command> [options]
107
143
  registration (never done silently)
108
144
  --restart-inference with --json, also stop/start local
109
145
  inference (never done silently)
110
- update Pull the latest nomArmy code and re-sync the installed
111
- MCP copy (fast-forward only; refuses on local changes).
146
+ update Update nomArmy and reconnect your coordinators: installs
147
+ npm's latest alpha, or for a git checkout pulls (fast-forward
148
+ only; refuses on local changes). Then restart open sessions.
112
149
  agents <list|add|update|remove>
113
150
  Every account a job can run on, in one list:
114
151
  ~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
@@ -138,9 +175,14 @@ Usage: nomarmy <command> [options]
138
175
  --model --auth-env [--base-url]
139
176
  [--openclaw-provider --plugin] [--register]
140
177
  [--update-mcp] (api); --provider --model
141
- --owner (subscription); and optionally
178
+ --owner (subscription). Codex JSON setup can
179
+ --link-openclaw after CLI login; removing a
180
+ capturing email profile non-interactively needs
181
+ --remove-email-profiles. Interactive Codex setup
182
+ offers removal (default yes), then imports the
183
+ CLI login. For any kind, optionally
142
184
  --max-concurrent --context-window
143
- --thinking [low|medium|high] --no-thinking
185
+ --thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
144
186
  update <name>
145
187
  change the model (picked from what OpenClaw
146
188
  lists; a subscription gets a real test call),
@@ -150,7 +192,7 @@ Usage: nomarmy <command> [options]
150
192
  --no-model clears the default model, so every
151
193
  role or job names its own.
152
194
  (--json with the matching flags; --probe to
153
- test-call a subscription's new model first)
195
+ test-call the agent; without a name, all agents)
154
196
  remove <name>
155
197
  remove one agent
156
198
  army <show|init|assign|general>
@@ -185,15 +227,32 @@ Usage: nomarmy <command> [options]
185
227
  which agent the General is, in --global
186
228
  (default) or --local
187
229
  config paths where agents.yml and the three army layers live
188
- jobs [--watch|--events|--prune] [--interval N] [--older-than DAYS]
230
+ config max-jobs [n]
231
+ how many api and subscription jobs run at once, across
232
+ every session (default 4); with n, sets it in limits.yml.
233
+ Warns when the Podman VM is too small for that many.
234
+ jobs [--watch|--events [--until-done] [--run <run-id>] [--repo <path>]|--prune|--wait <jobId> [<jobId> ...]|--stop <jobId> [--reason <text>]] [--interval N] [--older-than DAYS]
189
235
  what's running across every session (agent, model, phase,
190
236
  last tool call, files changed, heartbeat) and what just
191
237
  finished; --watch redraws every N seconds (default 3);
192
238
  --events prints one line per start, phase change and
193
- finish (for Claude Code's background monitor; --json for
194
- JSON lines); --prune removes the bulky runtime data
239
+ finish (--json for JSON lines). It's a stream: read it
240
+ with a monitor that wakes on each line. A background
241
+ command is only reported when it exits, so there use
242
+ --events --until-done --run <run-id> for a whole run,
243
+ or --wait <id> <id> ... for specific jobs. Unscoped
244
+ --until-done watches every job on this machine. The plain
245
+ stream ends on its own after 30 minutes with nothing
246
+ running (--idle-minutes N); --prune removes the bulky runtime data
195
247
  from finished jobs older than DAYS (default 2), keeping
196
- their records, reports and any retained worktree
248
+ their records, reports and any retained worktree;
249
+ --wait <jobId> [<jobId> ...] [--timeout <seconds>]
250
+ blocks until all finish; comma-separated ids also work
251
+ (default timeout 1800; --json is supported);
252
+ --events --repo <path> limits events to one repository;
253
+ --stop <jobId> stops a running job's worker (no report
254
+ recovery, no verification), keeping its worktree for
255
+ continue_from
197
256
  health check what's likely to break a run before it does:
198
257
  expiring logins, an outdated OpenClaw or plugin, roles
199
258
  that can't be dispatched, an unloadable agents.yml,
@@ -202,10 +261,42 @@ Usage: nomarmy <command> [options]
202
261
  statusline the one-line summary Claude Code's status line shows
203
262
  (installed by \`nomarmy connect claude\` when no status
204
263
  line is set); reads the session JSON on stdin
205
- connect [claude] [codex] [cursor]
264
+ connect [claude] [codex] [cursor] [--scope user|local|project]
206
265
  (Re-)register the MCP server with one or more coordinators.
207
266
  With no target and not --json, prompts an interactive
208
- multi-select instead.
267
+ multi-select instead. --scope user (default) registers it
268
+ for every project; local for this repository, only you;
269
+ project for this repository, committed for the team
270
+ (.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
271
+ Codex has only the user scope.
272
+ stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
273
+ [--repo <path|name>] [--all-repos] [--json]
274
+ What nomArmy's job records show for this repository (or
275
+ all): volume by role and model, code committed, time,
276
+ tokens and spend, how often a "done" report failed
277
+ independent verification, what didn't finish, reviewers,
278
+ and review flags. From verified records, never reports.
279
+ validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
280
+ Optional semantic checks from a model you configure with
281
+ your own key. Jev checks focused claims with TypeSafe;
282
+ Judge reviews diffs with one of your configured agents.
283
+ Setup asks for any consent it needs. \`add jev\` asks for the
284
+ key without echoing it (or reads --key-stdin), saves it
285
+ where only you can read it, and makes one test call. Its
286
+ answers only add review flags, and it sends excerpts of
287
+ your code to TypeSafe. \`add judge --agent <name> --model
288
+ <model>\` makes one of your agents a model judge: does the
289
+ diff meet each acceptance criterion, match the report, keep
290
+ its tests as strong? An agent whose tools run on this
291
+ machine needs --host-tools.
292
+ mcp Start nomArmy's MCP server on stdio with this machine's
293
+ settings. What a --scope project registration runs.
294
+ sandbox The Podman VM every sandbox shares (macOS, Windows): its
295
+ memory, disk and images. --memory <GiB> resizes it (stops,
296
+ sets, restarts; refused while jobs run); --prune removes
297
+ images no container uses (nomArmy rebuilds its own on
298
+ demand); --repair restores missing subordinate ID
299
+ ranges (refused while jobs run); --yes skips confirming
209
300
  start <profile> Start local inference (wraps scripts/start-inference.sh).
210
301
  stop <profile> Stop local inference (wraps scripts/stop-inference.sh).
211
302
  uninstall Remove the MCP registration and install directory.
@@ -272,7 +363,7 @@ function cmdScan() {
272
363
  const notes = evidence.notes?.items ?? [];
273
364
  if (notes.length) {
274
365
  console.log("\nNotes:");
275
- for (const n of notes) console.log(` ${n.message ?? n}`);
366
+ for (const n of notes) console.log(` ${typeof n === "string" ? n : n.message ?? JSON.stringify(n)}`);
276
367
  }
277
368
  console.log("\nThis is deterministic evidence only - nothing here was executed.");
278
369
  console.log("Describe the environment in .nomarmy.yml, then run 'nomarmy validate'.");
@@ -295,12 +386,12 @@ function scanCheck(evidence) {
295
386
  process.exit(1);
296
387
  }
297
388
  console.log(`Comparing ${path.basename(loaded.path)} against repository evidence\n`);
298
- if (drift.summary) console.log(`${drift.summary}\n`);
389
+ if (drift.summary) console.log(`Drift: ${drift.summary.missingFromConfig} missing from config, ${drift.summary.missingFromRepo} missing from repo (${drift.summary.total} total)\n`);
299
390
  for (const section of ["services", "ports", "environment", "commandKinds"]) {
300
391
  const d = drift[section];
301
392
  if (!d) continue;
302
- for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${m}`);
303
- for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${m}`);
393
+ for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
394
+ for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
304
395
  }
305
396
  process.exit(drift.ok ? 0 : 1);
306
397
  }
@@ -400,7 +491,9 @@ async function cmdInit() {
400
491
  // This file's own location, not --repo (the target repo being scanned) --
401
492
  // setup/model need to find THIS package's config/ and scripts/ as siblings
402
493
  // of bin/, the same way select-model.mjs resolves its own root.
403
- const nomarmyRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
494
+ // fileURLToPath, not URL.pathname: on Windows that gave "/C:/Users/Jason%20Pugh/...",
495
+ // a stray slash and an encoded space, and connect failed copying from C:\\C:\\...
496
+ const nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
404
497
 
405
498
  /** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
406
499
  function readEnvValue(filePath, key) {
@@ -409,15 +502,9 @@ function readEnvValue(filePath, key) {
409
502
  return m ? m[1].trim() : null;
410
503
  }
411
504
 
412
- /** 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. */
413
- function writeEnvLine(filePath, key, value) {
414
- const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
415
- const line = `${key}=${value}`;
416
- const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
417
- ? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
418
- : `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
419
- fs.writeFileSync(filePath, updated);
420
- }
505
+ // Your settings live in ~/.config/nomarmy/, over the package's config/
506
+ // defaults, so an update can't reset them (lib/user-config.mjs).
507
+ const setting = (key) => readSetting(key, { nomarmyRoot });
421
508
 
422
509
  // Each entry's repo/quant/alias is verified against this project's own real
423
510
  // usage (downloaded, loaded, dispatched against), not guessed from a model
@@ -436,6 +523,14 @@ function writeEnvLine(filePath, key, value) {
436
523
  * not left to fail only at the very end via schema validation with no
437
524
  * indication of which of several answers was the problem.
438
525
  */
526
+ /** A numbered choice, asked again until it's one of 1..count (an empty or mistyped answer used to end the whole flow). */
527
+ async function askChoice(rl, count) {
528
+ const answer = await askUntilValid(rl, "Choice: ", { pattern: new RegExp(`^([1-9]|[1-9][0-9])$`), invalidMessage: `Choose a number from 1 to ${count}.` });
529
+ if (Number(answer) >= 1 && Number(answer) <= count) return Number(answer) - 1;
530
+ console.log(c.red(` ✗ Choose a number from 1 to ${count}.`));
531
+ return askChoice(rl, count);
532
+ }
533
+
439
534
  async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
440
535
  for (;;) {
441
536
  const answer = (await rl.question(c.bold(prompt))).trim();
@@ -468,15 +563,15 @@ async function askSecret(rl, prompt) {
468
563
 
469
564
  const KNOWN_MODELS = {
470
565
  default: {
471
- label: "Qwen3-Coder-Next (shipped default; no thinking mode -- 0 failures across every case tested tonight)",
566
+ label: "Qwen3-Coder-Next · 48 GB download · the most reliable in our tests (recommended)",
472
567
  repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
473
568
  },
474
569
  "gpt-oss-20b": {
475
- 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)",
570
+ label: "gpt-oss-20b · 12 GB download · the fastest; keep reasoning at medium",
476
571
  repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
477
572
  },
478
573
  "qwen3.6-27b": {
479
- 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)",
574
+ label: "Qwen3.6-27B · 17 GB download · very reliable, slower; keep reasoning at medium",
480
575
  repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
481
576
  },
482
577
  };
@@ -509,35 +604,148 @@ async function chooseModel(rl) {
509
604
  return { kind: "search", thinking: thinkingAnswer === "y" || thinkingAnswer === "yes" };
510
605
  }
511
606
 
512
- /**
513
- * `nomarmy setup`: detect hardware, recommend a profile the same way
514
- * `nomarmy sizing` already does, let the user pick a model, then write the
515
- * result to config/profiles/<name>.env and (for the two curated model
516
- * choices) config/common.env. Stops there -- prints the exact `install.sh`
517
- * command rather than running it. install.sh builds llama.cpp, curl-pipes an
518
- * installer and touches sandbox/provider config; that is not a proportionate
519
- * thing for an opt-in flag on a CLI whose whole brand is "reports or
520
- * proposes" to cross, unlike the cheap, reversible, single-file writes this
521
- * command itself does.
522
- */
607
+ function setupProjectDir() {
608
+ let project = process.cwd();
609
+ while (!fs.existsSync(path.join(project, ".git"))) {
610
+ const parent = path.dirname(project);
611
+ if (parent === project) { project = null; break; }
612
+ project = parent;
613
+ }
614
+ return project;
615
+ }
616
+
617
+ /** The profile install.sh picks when given none (scripts/lib.sh load_profile). */
618
+ function defaultLocalProfile() {
619
+ if (process.platform === "darwin") return "macbook-pro";
620
+ return spawnSync("nvidia-smi", ["-L"], { stdio: "ignore", timeout: 5000 }).status === 0 ? "nvidia-linux" : "cpu-linux";
621
+ }
622
+
623
+ // Which profile this machine is set up for, the same way for `setup` and
624
+ // `install`: the one setup recorded, else the install marker's, else the
625
+ // execution mode's, else (a working local install) install.sh's default.
626
+ function setupProfileState() {
627
+ const common = userCommonPath();
628
+ const chosen = setting("NOMARMY_SETUP_PROFILE");
629
+ const probeCommand = (binary, args) => {
630
+ const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
631
+ return result.status === 0 ? result.stdout.trim() : "";
632
+ };
633
+ const profileFile = profilePathFor(chosen, { nomarmyRoot });
634
+ const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) || setting("NOMARMY_INSTALL_ROOT") || "$HOME/.local/share/nomarmy-local-agents").replace(/\$HOME|\$\{HOME\}/g, os.homedir());
635
+ let marker = null;
636
+ try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
637
+ const version = probeCommand("openclaw", ["--version"]);
638
+ // `mcp list` would connect to every server; `mcp get` only looks this one up.
639
+ const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
640
+ // An install from before setup recorded its profile: the marker's, else the
641
+ // execution mode's, else (a working local install) install.sh's default.
642
+ const execution = setting("NOMARMY_EXECUTION");
643
+ const profile = chosen ?? marker?.profile
644
+ ?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
645
+ ?? (registered ? defaultLocalProfile() : null);
646
+ return { common, profile, marker, version, registered };
647
+ }
648
+
649
+ function setupChecklist() {
650
+ const { common, profile, marker, version, registered } = setupProfileState();
651
+ const project = setupProjectDir();
652
+ return setupSteps({
653
+ mode: () => ({ profile, host: setting("NOMARMY_LLAMA_HOST"), port: setting("NOMARMY_LLAMA_PORT") }),
654
+ install: () => ({ marker, version, registered }),
655
+ agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
656
+ army: () => {
657
+ const global = readArmyFile(armyLayerPath("global"), { armyOnly: true });
658
+ const local = project ? readArmyFile(armyLayerPath("project", { projectDir: project })) : null;
659
+ const merged = mergeArmy([{ layer: "global", army: global }, { layer: "project", army: local }]).army;
660
+ // Repair the layer that would otherwise keep overriding a global init.
661
+ const repairLayer = profile === "hosted" && Object.values(local?.roles ?? {}).some((role) => role.agent === "local") ? "project" : null;
662
+ return { ...merged, repairLayer };
663
+ },
664
+ repo: () => ({ inside: Boolean(project), configured: Boolean(project && fs.existsSync(path.join(project, ".nomarmy.yml"))) }),
665
+ });
666
+ }
667
+
668
+ function runSetupChild(args) {
669
+ // Work from the repository root even when setup was started in a subdirectory;
670
+ // the child's cwd remains unchanged, as it does for every other step.
671
+ const project = setupProjectDir();
672
+ if (project && ["init", "army"].includes(args[0])) args = [...args, "--repo", project];
673
+ const result = spawnSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() });
674
+ return result.status ?? 1;
675
+ }
676
+
677
+ function cmdInstall() {
678
+ const profile = value("profile", null) ?? setupProfileState().profile;
679
+ if (!profile) throw new Error("Choose a profile first: nomarmy setup --choose (or install --profile <name>).");
680
+ if (!value("profile", null)) console.log(`Installing for profile ${profile} (from nomarmy setup; pass --profile to choose another).`);
681
+ const result = spawnSync("bash", [path.join(nomarmyRoot, "install.sh"), "--profile", profile, ...(flag("no-claude") ? ["--no-claude"] : [])], { stdio: "inherit", cwd: nomarmyRoot });
682
+ process.exitCode = result.status ?? 1;
683
+ }
684
+
523
685
  async function cmdSetup() {
686
+ if (windowsFrontEnd()) {
687
+ process.exitCode = await windowsSetup(argv, { ask: async (prompt) => {
688
+ const rl = createInterface({ input, output });
689
+ try { return await rl.question(prompt); } finally { rl.close(); }
690
+ } });
691
+ return;
692
+ }
693
+ if (flag("status") || (!flag("choose") && !flag("hosted") && !flag("llama-url") && !json)) {
694
+ if (flag("status") || !process.stdin.isTTY) {
695
+ const steps = setupChecklist();
696
+ return json ? out(steps) : console.log(formatSetupSteps(steps));
697
+ }
698
+ // What's ahead, before the first question: a practice run found setup
699
+ // felt heavy mostly because nothing said how much of it there was.
700
+ if (setupChecklist().find((step) => step.status === "todo")?.id === "mode") {
701
+ console.log(`${c.bold("🍪 nomArmy setup")}\n\nAbout 10 to 20 minutes, one step at a time; stop any time and ${c.cyan("nomarmy setup")} picks up where you left off.`);
702
+ console.log(c.dim(" 1. Where models run: your API and subscription agents, or a model on this machine"));
703
+ console.log(c.dim(" 2. Install: OpenClaw (runs the models) and Podman (the sandbox every job runs in; on macOS a small VM, about a 1 GB download)"));
704
+ console.log(c.dim(" 3. Agents: log in to your subscription or add an API key"));
705
+ console.log(c.dim(" 4. Roles, this repo's checks, and a final health check\n"));
706
+ }
707
+ process.exitCode = await runSetupPlaybook({
708
+ evaluate: setupChecklist, print: console.log, run: runSetupChild,
709
+ ask: async (prompt) => {
710
+ const rl = createInterface({ input, output });
711
+ try { return await rl.question(prompt); } finally { rl.close(); }
712
+ },
713
+ });
714
+ return;
715
+ }
716
+ if (flag("choose")) {
717
+ if (!process.stdin.isTTY) throw new Error("setup --choose needs an interactive terminal.");
718
+ const rl = createInterface({ input, output });
719
+ try {
720
+ console.log("1. hosted (API keys and subscriptions, most people)\n2. a local model on this machine\n3. a shared model server\n4. Bedrock");
721
+ const choice = await askUntilValid(rl, "Choice [1]: ", { pattern: /^[1-4]$/, invalidMessage: "Choose 1, 2, 3 or 4.", allowEmpty: true, fallback: "1" });
722
+ if (choice === "1") argv.push("--hosted");
723
+ if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
724
+ if (choice === "4") {
725
+ writeSetting("NOMARMY_EXECUTION", "bedrock");
726
+ writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
727
+ console.log("Next: nomarmy install");
728
+ return;
729
+ }
730
+ } finally { rl.close(); }
731
+ }
524
732
  const hosted = flag("hosted");
525
733
  const hasLlamaUrl = flag("llama-url");
526
734
  if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
527
735
 
528
736
  if (hosted || hasLlamaUrl) {
529
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
737
+ const commonPath = userCommonPath();
530
738
 
531
739
  if (hosted) {
532
740
  const next = [
533
- `${path.join(nomarmyRoot, "install.sh")} --profile hosted`,
741
+ "nomarmy install",
534
742
  "nomarmy agents add",
535
743
  "nomarmy army init --agent <name>",
536
744
  ];
537
- fs.mkdirSync(path.dirname(commonPath), { recursive: true });
538
745
  writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
746
+ writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
539
747
  if (json) return out({ written: commonPath, execution: "hosted", next });
540
- console.log(c.green(`✓ Wrote NOMARMY_EXECUTION=hosted to ${path.relative(nomarmyRoot, commonPath)}.`));
748
+ console.log(c.green(`✓ Jobs will run on your API and subscription agents (saved in ${tildePath(commonPath)}).`));
541
749
  console.log(c.dim("\nNext:"));
542
750
  for (const step of next) console.log(` ${c.bold(step)}`);
543
751
  return;
@@ -557,20 +765,21 @@ async function cmdSetup() {
557
765
  }
558
766
  fs.mkdirSync(path.dirname(commonPath), { recursive: true });
559
767
  writeEnvLine(commonPath, "NOMARMY_EXECUTION", "remote");
768
+ writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "remote");
560
769
  writeEnvLine(commonPath, "NOMARMY_LLAMA_HOST", llamaHost);
561
770
  writeEnvLine(commonPath, "NOMARMY_LLAMA_PORT", llamaPort);
562
- const next = `${path.join(nomarmyRoot, "install.sh")} --profile remote`;
771
+ const next = "nomarmy install";
563
772
  if (json) return out({ written: commonPath, execution: "remote", llamaHost, llamaPort, reachable, next });
564
773
  console.log(reachable
565
774
  ? c.green(`✓ llama-server is reachable at ${healthUrl}.`)
566
775
  : c.yellow(`⚠ llama-server is not reachable at ${healthUrl} right now; configuration was still written.`));
567
- console.log(c.green(`✓ Wrote the remote llama-server settings to ${path.relative(nomarmyRoot, commonPath)}.`));
776
+ console.log(c.green(`✓ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
568
777
  console.log(c.dim("\nNext:"));
569
778
  console.log(` ${c.bold(next)}`);
570
779
  return;
571
780
  }
572
781
 
573
- const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
782
+ const execution = flag("choose") ? "local" : value("execution", process.env.NOMARMY_EXECUTION || "local");
574
783
  const isCloud = execution !== "local";
575
784
  const hardware = isCloud ? null : await detectHardware();
576
785
  const modelPath = isCloud ? null : findModel();
@@ -593,10 +802,10 @@ async function cmdSetup() {
593
802
  console.log(isCloud
594
803
  ? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
595
804
  : `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
596
- console.log(`More noms ${c.dim(`(confidence: ${res.confidence})`)}: ${c.green(res.summary ?? JSON.stringify(res.env))}`);
597
- if (res.nominal && !res.nominal.sameAsRecommended) {
598
- 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.`)}`);
599
- }
805
+ // Plain words; the exact settings are shown before anything is written.
806
+ const plain = (env) => { const n = Number(env?.NOMARMY_MAX_WORKERS ?? env?.NOMARMY_LLAMA_PARALLEL ?? 1), ctx = Math.round(Number(env?.NOMARMY_LLAMA_CONTEXT ?? 0) / Math.max(1, Number(env?.NOMARMY_LLAMA_PARALLEL ?? 1)) / 1024); return `${n} local job${n === 1 ? "" : "s"} at a time${ctx ? `, ${ctx}K tokens of context each` : ""}`; };
807
+ console.log(`This machine fits ${c.green(plain(res.env))}${res.confidence === "low" ? c.dim(" (an estimate)") : ""}.`);
808
+ if (res.nominal && !res.nominal.fits) console.log(c.red("Even one local job doesn't fit in this machine's memory: choose hosted instead (nomarmy setup --choose)."));
600
809
  }
601
810
 
602
811
  // "More noms" fits as many noms as memory allows; "nominal" is 1 worker
@@ -614,8 +823,8 @@ async function cmdSetup() {
614
823
  if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
615
824
  } else {
616
825
  console.log(`\n${c.bold("Which sizing?")}`);
617
- console.log(` ${c.cyan("1.")} More noms -- as many as fit in memory`);
618
- console.log(` ${c.cyan("2.")} Nominal -- 1 nom, matching this project's own shipped profiles`);
826
+ console.log(` ${c.cyan("1.")} As many at once as fit in memory`);
827
+ console.log(` ${c.cyan("2.")} One at a time (lighter on the machine)`);
619
828
  const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
620
829
  sizingTier = choice === "2" ? "nominal" : "more";
621
830
  }
@@ -634,8 +843,8 @@ async function cmdSetup() {
634
843
  }
635
844
 
636
845
  const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
637
- const profilePath = path.join(nomarmyRoot, "config", "profiles", `${profileName}.env`);
638
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
846
+ const profilePath = userProfilePath(profileName);
847
+ const commonPath = userCommonPath();
639
848
 
640
849
  const profileWrites = { ...sizingEnv };
641
850
  if (!isCloud) {
@@ -648,10 +857,10 @@ async function cmdSetup() {
648
857
  }
649
858
 
650
859
  if (!json) {
651
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
860
+ console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
652
861
  for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
653
862
  if (model?.kind === "known") {
654
- console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
863
+ console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
655
864
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
656
865
  console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
657
866
  console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
@@ -659,13 +868,17 @@ async function cmdSetup() {
659
868
  console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
660
869
  }
661
870
  if (!nonInteractive) {
662
- const answer = (await rl.question(c.bold("\nWrite this configuration? [y/N] "))).trim().toLowerCase();
663
- if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
871
+ // Yes by default: someone who accepted every suggestion shouldn't lose it all at the last Enter.
872
+ const answer = (await rl.question(c.bold("\nWrite this configuration? [Y/n] "))).trim().toLowerCase();
873
+ if (answer === "n" || answer === "no") { console.log(c.dim("Canceled; nothing written.")); return; }
664
874
  }
665
875
  }
666
876
 
667
877
  fs.mkdirSync(path.dirname(profilePath), { recursive: true });
668
878
  for (const [k, v] of Object.entries(profileWrites)) writeEnvLine(profilePath, k, v);
879
+ writeEnvLine(commonPath, "NOMARMY_EXECUTION", execution);
880
+ if (execution === "local") writeEnvLine(commonPath, "NOMARMY_LLAMA_HOST", "127.0.0.1");
881
+ writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", execution === "bedrock" ? "bedrock" : profileName);
669
882
  if (model?.kind === "known" && model.repo) {
670
883
  writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
671
884
  writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
@@ -679,16 +892,16 @@ async function cmdSetup() {
679
892
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
680
893
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
681
894
  } else if (model?.kind === "search") {
682
- const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
895
+ const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
683
896
  if (searchedAlias) {
684
897
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
685
898
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
686
899
  }
687
900
  }
688
901
 
689
- const installCmd = `./install.sh --profile ${profileName}${isCloud ? "" : ""}`;
902
+ const installCmd = "nomarmy install";
690
903
  if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
691
- console.log(c.green(`\n✓ Wrote ${path.relative(nomarmyRoot, profilePath)}${model?.kind === "known" ? ` and ${path.relative(nomarmyRoot, commonPath)}` : ""}.`));
904
+ console.log(c.green(`\n✓ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
692
905
  console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
693
906
  console.log(` ${c.bold(installCmd)}\n`);
694
907
  } finally {
@@ -717,7 +930,7 @@ function restartInference() {
717
930
  runScript("start-inference.sh", []);
718
931
  }
719
932
  async function cmdModel() {
720
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
933
+ const commonPath = userCommonPath();
721
934
  if (json) {
722
935
  const which = value("model");
723
936
  if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
@@ -741,17 +954,17 @@ async function cmdModel() {
741
954
  const model = await chooseModel(rl);
742
955
  let alias;
743
956
  if (model.kind === "search") {
744
- console.log(c.green("\n✓ Done") + " -- config/common.env was already updated by the search above.");
745
- alias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
957
+ console.log(c.green("\n✓ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
958
+ alias = setting("NOMARMY_MODEL_ALIAS");
746
959
  } else {
747
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
960
+ console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
748
961
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
749
962
  const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
750
963
  if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
751
964
  writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
752
965
  writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
753
966
  writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
754
- console.log(c.green(`✓ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
967
+ console.log(c.green(`✓ Wrote ${tildePath(commonPath)}.`));
755
968
  alias = model.alias;
756
969
  }
757
970
  if (alias) {
@@ -975,23 +1188,24 @@ async function ensureVendorAuth(rl, vendorKey) {
975
1188
  let status = readLoginStatus(vendorKey);
976
1189
  if (!status.loggedIn) {
977
1190
  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).`));
978
- if (!(await confirm(rl, "Log in now?"))) return { ok: false };
979
- runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
1191
+ if (!(await confirm(rl, "Log in now?"))) { console.log(c.red(`✗ Sign-in canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
1192
+ const loginOk = runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
1193
+ if (!loginOk) { console.log(c.red(`✗ ${vendor.cli.bin} sign-in failed or was canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
980
1194
  status = readLoginStatus(vendorKey);
981
- if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}.`)); return { ok: false }; }
1195
+ if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
982
1196
  }
983
1197
  console.log(c.green(`✓ Logged in to ${vendor.cli.bin}${status.email ? ` as ${status.email}` : ""}${status.subscriptionType ? ` (${status.subscriptionType})` : ""}.`));
984
1198
 
985
1199
  if (vendor.plugin) {
986
1200
  step("OpenClaw plugin");
987
1201
  const version = parseOpenclawVersion(runQuiet(openclawCmd(), ["--version"]).out);
988
- if (!versionAtLeast(version, vendor.plugin.minOpenclaw)) {
989
- console.log(c.yellow(`OpenClaw ${version ? version.join(".") : "(unknown version)"} is older than the ${vendor.plugin.minOpenclaw} this vendor's plugin needs.`));
990
- if (!(await confirm(rl, "Update OpenClaw now (npm update -g openclaw)?"))) return { ok: false };
991
- if (!runInteractive("npm", ["update", "-g", "openclaw"])) {
992
- 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."));
993
- return { ok: false };
994
- }
1202
+ if (!versionAtLeast(version, PINNED_OPENCLAW_VERSION)) {
1203
+ const repaired = await repairOpenclaw({
1204
+ command: openclawCmd(),
1205
+ vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]),
1206
+ isTTY: Boolean(input.isTTY), ask: (prompt) => rl.question(prompt),
1207
+ });
1208
+ if (!repaired.ok) return { ok: false };
995
1209
  }
996
1210
  if (!runQuiet(openclawCmd(), ["plugins", "inspect", vendor.plugin.id]).ok) {
997
1211
  console.log(c.dim(`Installing ${vendor.plugin.spec}...`));
@@ -1001,6 +1215,12 @@ async function ensureVendorAuth(rl, vendorKey) {
1001
1215
  }
1002
1216
  runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
1003
1217
  }
1218
+ const checks = verifyOpenclaw({ command: openclawCmd(), vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]) });
1219
+ const failed = checks.filter((check) => !check.ok);
1220
+ if (failed.length) {
1221
+ for (const check of failed) console.log(c.red(`✗ ${check.message} Fix: ${check.fix}`));
1222
+ return { ok: false };
1223
+ }
1004
1224
  console.log(c.green(`✓ OpenClaw's ${vendor.plugin.id} plugin is ready.`));
1005
1225
  }
1006
1226
  if (vendor.credential.kind === "minted-key") {
@@ -1022,13 +1242,27 @@ async function ensureVendorAuth(rl, vendorKey) {
1022
1242
  return { ok: true, email: status.email };
1023
1243
  }
1024
1244
 
1245
+ function hasUsableOpenclawAuthProfile(provider) {
1246
+ // Same OpenClaw command and JSON shape used by health's login-expiry check.
1247
+ const result = runQuiet(openclawCmd(), ["models", "auth", "list", "--json"]);
1248
+ if (!result.ok) return false;
1249
+ const profiles = parseOpenclawAuthProfiles(result.stdout);
1250
+ return profiles?.some((profile) => {
1251
+ if (profile.provider !== provider) return false;
1252
+ if (profile.expiresAt == null) return true;
1253
+ const expires = Date.parse(profile.expiresAt);
1254
+ return Number.isFinite(expires) && expires > Date.now();
1255
+ }) ?? false;
1256
+ }
1257
+
1025
1258
  function catalogModelsFor(provider) {
1026
1259
  return parseCatalogModels(runQuiet(openclawCmd(), ["models", "list", "--refresh"]).out, provider);
1027
1260
  }
1028
1261
 
1029
- /** One real, one-token completion through OpenClaw -- the only proof a credential actually works. */
1262
+ /** One real, one-token completion through OpenClaw to test a model. */
1030
1263
  // Why the last probeWorker() call failed, in the vendor's words when it said.
1031
1264
  let lastProbeFailure = null;
1265
+ let lastProbeText = "";
1032
1266
  function probeWorker(provider, model) {
1033
1267
  // The route a job takes: the ambient OpenClaw config and a state dir of
1034
1268
  // its own, never --isolated. --isolated skips that config, and with it the
@@ -1044,7 +1278,10 @@ function probeWorker(provider, model) {
1044
1278
  "--json", "--cwd", cwd, "--state-dir", stateDir, "--timeout", "90"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd });
1045
1279
  // Streams kept apart: OpenClaw logs a "run ... ended" line to stderr
1046
1280
  // AFTER the JSON envelope, and the merged text doesn't parse.
1047
- const outcome = probeOutcome({ stdout: result.stdout ?? "", stderr: result.stderr ?? "" });
1281
+ const stdout = result.stdout ?? "";
1282
+ const stderr = result.stderr ?? "";
1283
+ const outcome = probeOutcome({ stdout, stderr });
1284
+ lastProbeText = `${stderr}\n${stdout}`;
1048
1285
  lastProbeFailure = outcome.ok ? null : outcome.reason;
1049
1286
  if (outcome.ok) recordProbeSuccess(agentStateRoot(), `${provider}/${model}`);
1050
1287
  return outcome.ok;
@@ -1076,10 +1313,15 @@ function reapProbeSandbox(stateDir) {
1076
1313
  /**
1077
1314
  * OpenClaw's own provider login, for the vendors whose plugin wants one on
1078
1315
  * top of the vendor CLI's login (Claude doesn't: OpenClaw reuses the CLI
1079
- * session directly). Only ever run when a probe or catalog lookup has
1080
- * already shown it's needed -- never preemptively.
1316
+ * session directly). Codex always refreshes its imported copy after CLI
1317
+ * login is confirmed; other providers link when catalog or auth checks fail.
1081
1318
  */
1082
- function openclawProviderLogin(vendor) {
1319
+ async function openclawProviderLogin(vendor, rl) {
1320
+ if (vendor === SUBSCRIPTION_VENDORS.codex) return linkCodex({
1321
+ run: runQuiet, command: openclawCmd(), isTTY: Boolean(input.isTTY),
1322
+ removeEmailProfiles: flag("remove-email-profiles"),
1323
+ confirm: (prompt, opts) => confirm(rl, prompt, opts), print: (message) => console.log(c.dim(message)),
1324
+ });
1083
1325
  const provider = vendor.credential.loginProvider ?? vendor.provider;
1084
1326
  console.log(c.dim(`Linking OpenClaw to it (\`openclaw models auth login --provider ${provider}\`) -- follow its prompts:`));
1085
1327
  const ok = runInteractive(openclawCmd(), ["models", "auth", "login", "--provider", provider]);
@@ -1092,8 +1334,9 @@ function openclawProviderLogin(vendor) {
1092
1334
  // One list in ~/.config/nomarmy/agents.yml (lib/agents.mjs): the local
1093
1335
  // model, api keys, and individual subscriptions. `add` walks through what
1094
1336
  // each kind needs -- a key registered with OpenClaw, or the vendor's own
1095
- // login -- and proves it with a real test call before saving. Changes apply
1096
- // to the next job; the MCP server re-reads the file when it changes.
1337
+ // login -- and tests the selected model before saving, with an explicit
1338
+ // opt-in to save after a failed model call. Changes apply to the next job;
1339
+ // the MCP server re-reads the file when it changes.
1097
1340
 
1098
1341
  function loadAgentsOrExit() {
1099
1342
  try { return loadAgents(globalConfigDir()); }
@@ -1205,11 +1448,10 @@ async function cmdAgentsAdd() {
1205
1448
  let kind = argv[2];
1206
1449
  if (!AGENT_KINDS.includes(kind)) {
1207
1450
  console.log("\n" + c.bold("What kind of agent?"));
1208
- console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (free, private)")}`);
1451
+ console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
1209
1452
  console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
1210
1453
  console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
1211
- kind = AGENT_KINDS[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1212
- if (!kind) throw new Error("Not a valid choice.");
1454
+ kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
1213
1455
  }
1214
1456
  if (kind === "local") return await addLocalAgent(rl, agents);
1215
1457
  if (kind === "api") return await addApiAgent(rl, agents);
@@ -1246,8 +1488,7 @@ async function addLocalAgent(rl, agents) {
1246
1488
  async function addApiAgent(rl, agents) {
1247
1489
  console.log("\n" + c.bold("Which provider?"));
1248
1490
  API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
1249
- const provider = API_PROVIDER_TYPES[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1250
- if (!provider) throw new Error("Not a valid choice.");
1491
+ const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
1251
1492
  const info = KNOWN_PROVIDERS[provider] ?? {};
1252
1493
  const agent = { kind: "api", provider };
1253
1494
 
@@ -1313,6 +1554,14 @@ async function addApiAgent(rl, agents) {
1313
1554
  console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job.`));
1314
1555
  }
1315
1556
 
1557
+ const OWNER_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
1558
+ function validOwnerEmail(email) { return typeof email === "string" && OWNER_EMAIL_RE.test(email); }
1559
+ function gitUserEmail() {
1560
+ const result = spawnSync("git", ["config", "user.email"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1561
+ const email = result.status === 0 ? result.stdout.trim() : "";
1562
+ return validOwnerEmail(email) ? email : "";
1563
+ }
1564
+
1316
1565
  const SUBSCRIPTION_AGENT_DEFAULT_NAMES = { claude: "claude", codex: "codex", meta: "muse" };
1317
1566
 
1318
1567
  async function addSubscriptionAgent(rl, agents) {
@@ -1322,8 +1571,7 @@ async function addSubscriptionAgent(rl, agents) {
1322
1571
  if (!SUBSCRIPTION_VENDORS[vendorKey]) {
1323
1572
  console.log("\n" + c.bold("Which subscription?"));
1324
1573
  vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
1325
- vendorKey = vendorKeys[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1326
- if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
1574
+ vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
1327
1575
  }
1328
1576
  const vendor = SUBSCRIPTION_VENDORS[vendorKey];
1329
1577
 
@@ -1337,16 +1585,34 @@ async function addSubscriptionAgent(rl, agents) {
1337
1585
  }
1338
1586
 
1339
1587
  const auth = await ensureVendorAuth(rl, vendorKey);
1340
- if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); return; }
1588
+ if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); process.exitCode = 1; return; }
1341
1589
 
1342
1590
  console.log(`\n${c.bold("→")} Models`);
1343
1591
  const needsOpenclawLogin = vendor.credential.kind === "openclaw-login";
1344
1592
  let linkedOpenclaw = false;
1593
+ if (vendorKey === "codex") {
1594
+ linkedOpenclaw = await openclawProviderLogin(vendor, rl);
1595
+ if (!linkedOpenclaw) {
1596
+ console.log(c.red("✗ Sign-in has no usable auth profile. Retry with `nomarmy agents add subscription codex`."));
1597
+ process.exitCode = 1; return;
1598
+ }
1599
+ }
1345
1600
  let models = catalogModelsFor(vendor.provider);
1346
- if (!models.length && needsOpenclawLogin) {
1347
- linkedOpenclaw = openclawProviderLogin(vendor);
1601
+ if (!models.length && needsOpenclawLogin && !linkedOpenclaw) {
1602
+ linkedOpenclaw = await openclawProviderLogin(vendor, rl);
1603
+ if (!linkedOpenclaw) { console.log(c.red(`✗ Sign-in failed or was canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); process.exitCode = 1; return; }
1348
1604
  models = catalogModelsFor(vendor.provider);
1349
1605
  }
1606
+ // Catalog entries can be cached, and a particular model can refuse a
1607
+ // working login. Check OpenClaw's auth profile before asking for details.
1608
+ if (needsOpenclawLogin && !hasUsableOpenclawAuthProfile(vendor.provider)) {
1609
+ if (linkedOpenclaw || !(await openclawProviderLogin(vendor, rl)) || !hasUsableOpenclawAuthProfile(vendor.provider)) {
1610
+ console.log(c.red(`✗ Sign-in has no usable auth profile. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`));
1611
+ process.exitCode = 1;
1612
+ return;
1613
+ }
1614
+ linkedOpenclaw = true;
1615
+ }
1350
1616
  // The agent is the account; the model is only a default. Roles pick
1351
1617
  // their own model (or "auto" for the General to choose per job).
1352
1618
  if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}`));
@@ -1354,27 +1620,38 @@ async function addSubscriptionAgent(rl, agents) {
1354
1620
  const pick = (await rl.question(c.bold(`Default model, optional${models.length ? " (a number or an id)" : ""}; blank = pick per role: `))).trim();
1355
1621
  const model = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick || null;
1356
1622
  if (pick && !model) throw new Error(`Not a valid choice: "${pick}".`);
1357
- // The test call needs some model; it proves the login, not the choice.
1623
+ // A usable profile is only a fast precondition. The test call is what
1624
+ // proves sign-in: an unexpired Codex import can still answer 401.
1358
1625
  const probeModel = model ?? models[0] ?? vendor.defaultModel;
1359
1626
 
1360
- const knownOwners = [...new Set(Object.values(agents).filter((a) => a.kind === "subscription").map((a) => a.owner))];
1361
- const ownerDefault = auth.email ?? (knownOwners.length === 1 ? knownOwners[0] : "");
1362
- const owner = (await rl.question(c.bold(`Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `))).trim() || ownerDefault;
1363
- if (!owner) throw new Error("An owner is required -- every job on this agent must name them in on_behalf_of.");
1627
+ const ownerDefault = validOwnerEmail(auth.email) ? auth.email : gitUserEmail();
1628
+ const owner = await askUntilValid(rl, `Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `, {
1629
+ allowEmpty: Boolean(ownerDefault), fallback: ownerDefault, pattern: OWNER_EMAIL_RE,
1630
+ invalidMessage: "enter an email address (one @, no spaces).",
1631
+ });
1364
1632
 
1365
1633
  const name = await askAgentName(rl, agents, SUBSCRIPTION_AGENT_DEFAULT_NAMES[vendorKey] ?? vendorKey);
1366
1634
  if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
1367
1635
 
1368
1636
  console.log(`\n${c.bold("→")} Test call`);
1369
- let works = probeModel ? probeWorker(vendor.provider, probeModel) : false;
1370
- if (!works && needsOpenclawLogin && !linkedOpenclaw && probeModel) {
1371
- openclawProviderLogin(vendor);
1372
- works = probeWorker(vendor.provider, probeModel);
1373
- }
1637
+ const probed = Boolean(probeModel);
1638
+ const works = probed ? probeWorker(vendor.provider, probeModel) : false;
1374
1639
  if (works) console.log(c.green(`✓ ${vendor.provider}/${probeModel} answered a real test prompt.`));
1375
- else {
1376
- 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."));
1377
- if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
1640
+ else if (needsOpenclawLogin && probed && openclawSignInFailure(`${lastProbeFailure ?? ""}\n${lastProbeText}`)) {
1641
+ const why = lastProbeFailure ? `: ${lastProbeFailure}` : "";
1642
+ console.log(c.red(`✗ Sign-in failed${why}.`));
1643
+ console.log(` fix: ${codexImportRecovery()}`);
1644
+ console.log(c.dim("Stopped; nothing was written."));
1645
+ process.exitCode = 1;
1646
+ return;
1647
+ } else {
1648
+ console.log(c.yellow(probeModel ? `⚠ The login works, but ${vendor.provider}/${probeModel} didn't answer a real test prompt.` : "⚠ The login works, but no model is available for a test call."));
1649
+ console.log(c.dim(`You can retry later with \`nomarmy agents update ${name} --probe\`.`));
1650
+ if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) {
1651
+ console.log(c.dim("Stopped; nothing was written."));
1652
+ process.exitCode = 1;
1653
+ return;
1654
+ }
1378
1655
  }
1379
1656
  const written = saveAgents({ ...agents, [name]: { kind: "subscription", provider: vendor.provider, owner, ...(model ? { model } : {}) } });
1380
1657
  savedAgentMessage(name, written);
@@ -1387,6 +1664,7 @@ async function cmdAgentsAddJson() {
1387
1664
  const kind = value("kind") ?? (AGENT_KINDS.includes(argv[2]) ? argv[2] : null);
1388
1665
  if (!name || !kind) throw new Error(`--json requires --name <agent> and --kind <${AGENT_KINDS.join("|")}>, plus that kind's fields (see \`nomarmy help\`).`);
1389
1666
  if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
1667
+ if (kind === "subscription" && value("owner") !== null && !validOwnerEmail(value("owner"))) throw new Error("--owner must be an email address (one @, no spaces).");
1390
1668
  const agent = { kind };
1391
1669
  const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
1392
1670
  if (kind === "local") {
@@ -1401,6 +1679,15 @@ async function cmdAgentsAddJson() {
1401
1679
  const thinking = resolveThinkingFlag();
1402
1680
  if (thinking !== undefined) agent.thinking = thinking;
1403
1681
  }
1682
+ if (kind === "subscription" && agent.provider === "openai" && (flag("link-openclaw") || flag("remove-email-profiles"))) {
1683
+ if (!readLoginStatus("codex").loggedIn) throw new Error("Confirm Codex CLI login first: codex login");
1684
+ const linked = await linkCodex({ run: runQuiet, command: openclawCmd(),
1685
+ removeEmailProfiles: flag("remove-email-profiles"), print: (message) => console.error(message) });
1686
+ if (!linked) throw new Error("Codex import failed; nothing was written.");
1687
+ if (!probeWorker("openai", agent.model ?? SUBSCRIPTION_VENDORS.codex.defaultModel)) {
1688
+ throw new Error(`Codex test call failed; nothing was written. Fix: ${codexImportRecovery()}`);
1689
+ }
1690
+ }
1404
1691
  const written = saveAgents({ ...agents, [name]: agent });
1405
1692
  const saved = written[name];
1406
1693
  let registered = null, mcpUpdated = false;
@@ -1412,6 +1699,43 @@ async function cmdAgentsAddJson() {
1412
1699
  return out({ written: agentsConfigPath(globalConfigDir()), name, agent: saved, ...(kind === "api" ? { registered, mcpUpdated } : {}) });
1413
1700
  }
1414
1701
 
1702
+ // A probe-only update checks credentials without changing agents.yml.
1703
+ function probeConfiguredAgent(name, agent) {
1704
+ const provider = agentProviderId(agent);
1705
+ let model = agent.model;
1706
+ let source = "";
1707
+ if (!model && provider) {
1708
+ const role = agentAssignments()[name]?.roles.find((r) => r.model && r.model !== "auto");
1709
+ if (role) { model = role.model; source = `${role.role}'s model`; }
1710
+ if (!model) {
1711
+ const vendor = Object.values(SUBSCRIPTION_VENDORS).find((v) => v.provider === provider);
1712
+ if (vendor?.defaultModel) { model = vendor.defaultModel; source = "vendor default"; }
1713
+ }
1714
+ if (!model) {
1715
+ model = catalogModelsFor(provider)[0];
1716
+ if (model) source = "first catalog model";
1717
+ }
1718
+ }
1719
+ if (!provider || !model) {
1720
+ console.log(c.red(`✗ ${name} ${provider ?? agent.kind}/${model ?? "no model"} no model available; set one with \`nomarmy agents update ${name} --model <m>\``));
1721
+ process.exitCode = 1;
1722
+ return;
1723
+ }
1724
+ const selected = `${provider}/${model}${source ? ` (${source})` : ""}`;
1725
+ const start = performance.now();
1726
+ const ok = probeWorker(provider, model);
1727
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
1728
+ console.log(ok ? c.green(`✓ ${name} ${selected} answered in ${elapsed}s`)
1729
+ : c.red(`✗ ${name} ${selected} failed: ${lastProbeFailure ?? "no answer"}`));
1730
+ if (!ok) process.exitCode = 1;
1731
+ }
1732
+
1733
+ function cmdAgentsProbeAll() {
1734
+ const agents = fileAgentsOrExit();
1735
+ if (!Object.keys(agents).length) { console.log("No configured agents to probe."); return; }
1736
+ for (const [name, agent] of Object.entries(agents)) probeConfiguredAgent(name, agent);
1737
+ }
1738
+
1415
1739
  // --- update ---
1416
1740
 
1417
1741
  // Kind, provider and owner are fixed: changing any of them is a different
@@ -1420,7 +1744,8 @@ async function cmdAgentsAddJson() {
1420
1744
  // test call `add` makes (interactive always; --json only with --probe,
1421
1745
  // since it spends a real request on the subscription).
1422
1746
  async function cmdAgentsUpdate() {
1423
- const name = argv[2];
1747
+ const name = argv[2]?.startsWith("--") ? null : argv[2];
1748
+ if (!name && flag("probe")) return cmdAgentsProbeAll();
1424
1749
  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]");
1425
1750
  const agents = fileAgentsOrExit();
1426
1751
  const current = Object.prototype.hasOwnProperty.call(agents, name) ? agents[name] : name === "local" ? { ...BUILTIN_LOCAL_AGENT } : undefined;
@@ -1428,7 +1753,9 @@ async function cmdAgentsUpdate() {
1428
1753
 
1429
1754
  const changes = {};
1430
1755
  let probe = false;
1431
- if (json) {
1756
+ // Flags alone are enough; without any, it asks field by field.
1757
+ const flagged = ["model", "no-model", "slot", "auth-env", "base-url", "max-concurrent", "context-window", "thinking", "no-thinking", "probe"].some((f) => flag(f));
1758
+ if (json || flagged) {
1432
1759
  const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
1433
1760
  if (value("model") !== null) changes.model = value("model");
1434
1761
  // Back to no default model: every role (or job) then names its own.
@@ -1443,10 +1770,10 @@ async function cmdAgentsUpdate() {
1443
1770
  if (flag("owner") || value("owner") !== null || value("provider") !== null || value("kind") !== null) {
1444
1771
  throw new Error("Kind, provider and owner can't be changed -- that's a different agent. Use `nomarmy agents add`.");
1445
1772
  }
1446
- probe = flag("probe") && current.kind === "subscription";
1447
- if (!Object.keys(changes).length) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
1773
+ probe = flag("probe");
1774
+ if (!Object.keys(changes).length && !probe) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
1448
1775
  } else {
1449
- if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or --json with explicit flags.");
1776
+ if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or the flags to change (e.g. --max-concurrent 3).");
1450
1777
  const rl = createInterface({ input, output });
1451
1778
  try {
1452
1779
  console.log(c.bold("🍪 nomArmy agents update") + c.dim(` (${name}: ${describeAgentLabel(current)})`));
@@ -1491,7 +1818,8 @@ async function cmdAgentsUpdate() {
1491
1818
  if (!Object.keys(changes).length) { console.log(c.dim("\nNothing changed.")); return; }
1492
1819
  }
1493
1820
 
1494
- if (probe && changes.model && !probeWorker(current.provider, changes.model)) {
1821
+ if (probe && !Object.keys(changes).length) { probeConfiguredAgent(name, current); return; }
1822
+ if (probe && changes.model && !probeWorker(agentProviderId(current), changes.model)) {
1495
1823
  out({ error: `a real test prompt to ${current.provider}/${changes.model} didn't come back -- nothing was written` });
1496
1824
  process.exit(1);
1497
1825
  }
@@ -1529,14 +1857,9 @@ function git(args) {
1529
1857
  */
1530
1858
  async function cmdUpdate() {
1531
1859
  const say = (s) => { if (!json) console.log(s); };
1532
- // Installed from npm: there's no checkout to pull. npm updates the
1533
- // package; connect resyncs the copy each coordinator runs.
1534
- if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) {
1535
- const how = "npm install -g nomarmy@alpha && nomarmy connect";
1536
- if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
1537
- console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
1538
- return;
1539
- }
1860
+ // Installed from npm: npm updates the package, then connect resyncs the
1861
+ // copy each coordinator runs.
1862
+ if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) return updateFromNpm();
1540
1863
  const status = git(["status", "--porcelain"]);
1541
1864
  if (status) {
1542
1865
  if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
@@ -1550,8 +1873,19 @@ async function cmdUpdate() {
1550
1873
  const remote = git(["rev-parse", "@{u}"]);
1551
1874
  const base = git(["merge-base", "HEAD", "@{u}"]);
1552
1875
  if (local === remote) {
1553
- if (json) return out({ updated: false, reason: "already up to date" });
1554
- console.log(c.green("✓ Already up to date."));
1876
+ // Nothing to pull, but the copy coordinators run can still be behind
1877
+ // this checkout (commits made or pulled here without a reconnect).
1878
+ if (!copyIsStale(defaultInstallDir(), nomarmyRoot)) {
1879
+ if (json) return out({ updated: false, reason: "already up to date" });
1880
+ console.log(c.green("✓ Already up to date, and your coordinators run this checkout."));
1881
+ printSessionRestarts({ quietWhenNone: true });
1882
+ return;
1883
+ }
1884
+ say(c.bold("🍪 nomArmy update\n"));
1885
+ say("Nothing to pull, but your coordinators run an older copy of this checkout.");
1886
+ const resynced = reconnectCoordinators();
1887
+ if (json) return out({ updated: false, resynced, sha: local });
1888
+ printSessionRestarts();
1555
1889
  return;
1556
1890
  }
1557
1891
  if (base !== local) {
@@ -1567,28 +1901,81 @@ async function cmdUpdate() {
1567
1901
  say("\nInstalling dependencies...");
1568
1902
  execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: nomarmyRoot, stdio: json ? "ignore" : "inherit" });
1569
1903
 
1570
- const resynced = [];
1571
- const runInherit = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
1572
- if (commandExists("claude")) {
1573
- say("\nRe-syncing the Claude Code MCP install...");
1574
- connectClaude({ nomarmyRoot, run: runInherit });
1575
- resynced.push("claude");
1904
+ const resynced = reconnectCoordinators();
1905
+
1906
+ if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
1907
+ 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."));
1908
+ }
1909
+
1910
+ // The coordinators nomArmy is registered with. Cursor has no CLI to probe,
1911
+ // so it counts when its own config already lists nomArmy.
1912
+ // Reconnect every connected coordinator through a child process, so it runs
1913
+ // the code now on disk (just pulled or installed) rather than the old code
1914
+ // this process loaded. Returns the targets reconnected.
1915
+ // Each open session keeps the nomArmy it started with: name the ones that
1916
+ // started before the installed copy, rather than a blanket "restart".
1917
+ function printSessionRestarts({ quietWhenNone = false } = {}) {
1918
+ let list = null;
1919
+ try {
1920
+ const installedAt = fs.statSync(path.join(defaultInstallDir(), "source.json")).mtimeMs;
1921
+ const procs = listProcesses();
1922
+ if (procs) list = staleSessions(procs, { installedAt });
1923
+ } catch { /* no installed copy yet, or ps unavailable */ }
1924
+ if (list === null) {
1925
+ if (!quietWhenNone) console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
1926
+ return;
1576
1927
  }
1577
- if (commandExists("codex")) {
1578
- say("\nRe-syncing the Codex MCP install...");
1579
- connectCodex({ nomarmyRoot, run: runInherit });
1580
- resynced.push("codex");
1928
+ if (!list.length) { if (!quietWhenNone) console.log(c.green("\n✓ No open session runs an older nomArmy.")); return; }
1929
+ console.log(c.yellow(`\n${list.length} open session(s) still run an older nomArmy, until each is restarted:`));
1930
+ for (const line of formatStaleSessions(list)) console.log(line);
1931
+ console.log(c.dim("In Claude Code: /exit, then claude --resume (or /mcp → nomarmy-local-worker → Reconnect). Close any you no longer use."));
1932
+ }
1933
+
1934
+ function reconnectCoordinators() {
1935
+ const targets = connectedTargets();
1936
+ // Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
1937
+ // refreshed copy is all they need; re-registering them at user scope would
1938
+ // add nomArmy to every project.
1939
+ if (!targets.length) {
1940
+ execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", "--copy-only", ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
1941
+ return [];
1581
1942
  }
1582
- // Cursor has no CLI/PATH binary to probe with commandExists -- "already
1583
- // connected" is read from its own config file instead.
1584
- if (cursorAlreadyConnected()) {
1585
- say("\nRe-syncing the Cursor MCP install...");
1586
- connectCursor({ nomarmyRoot, run: runInherit });
1587
- resynced.push("cursor");
1943
+ if (targets.length) {
1944
+ if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
1945
+ execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
1588
1946
  }
1947
+ return targets;
1948
+ }
1589
1949
 
1590
- if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
1591
- 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."));
1950
+ function connectedTargets() {
1951
+ return [commandExists("claude") && claudeUserScoped() && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
1952
+ }
1953
+
1954
+ async function updateFromNpm() {
1955
+ const current = readPackageVersion(nomarmyRoot);
1956
+ let latest = null;
1957
+ try { latest = execFileSync("npm", ["view", "nomarmy", "dist-tags.alpha"], { encoding: "utf8", timeout: 20000 }).trim(); } catch { /* offline */ }
1958
+ if (!latest) {
1959
+ const fix = "npm install -g nomarmy@alpha && nomarmy connect claude";
1960
+ if (json) { out({ error: "could not read nomarmy's latest release from npm", fix }); process.exit(1); }
1961
+ console.log(c.red("Couldn't reach npm to find nomArmy's latest release.") + ` Update by hand:\n ${fix}`);
1962
+ process.exit(1);
1963
+ }
1964
+ const { copyVersion } = readInstallVersions(defaultInstallDir());
1965
+ const upgrade = compareVersions(current, latest) < 0;
1966
+ const staleCopy = !copyVersion || compareVersions(copyVersion, upgrade ? latest : current) < 0;
1967
+ if (!upgrade && !staleCopy) {
1968
+ if (json) return out({ updated: false, version: current, reason: "already up to date" });
1969
+ console.log(c.green(`✓ nomArmy ${current} is the latest, and your coordinators run it.`));
1970
+ return;
1971
+ }
1972
+ if (upgrade) {
1973
+ if (!json) console.log(c.bold(`🍪 Updating nomArmy ${current} → ${latest}\n`));
1974
+ execFileSync("npm", ["install", "-g", `nomarmy@${latest}`, "--no-audit", "--no-fund"], { stdio: json ? "ignore" : "inherit" });
1975
+ }
1976
+ const targets = reconnectCoordinators();
1977
+ if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
1978
+ printSessionRestarts();
1592
1979
  }
1593
1980
 
1594
1981
  function commandExists(cmd) {
@@ -1632,7 +2019,23 @@ async function cmdConnect() {
1632
2019
  // scan the whole argv), and `nomarmy connect --json claude` once broke
1633
2020
  // that promise by reading argv[1] directly -- --json landed in target's
1634
2021
  // slot instead. Multiple bare tokens are now allowed too, for multi-select.
1635
- const requested = argv.slice(1).filter((a) => !a.startsWith("--"));
2022
+ const flagValues = new Set(["--scope", "--repo"].map((name) => argv.indexOf(name)).filter((i) => i >= 0).map((i) => i + 1));
2023
+ const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
2024
+ const scope = value("scope", "user");
2025
+ if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
2026
+ const nativeWindows = windowsFrontEnd();
2027
+ if (nativeWindows && scope !== "user") throw new Error("per-repo registration on Windows isn't supported yet; use the default --scope user");
2028
+ if (nativeWindows && flag("copy-only")) throw new Error("Windows runs nomArmy inside WSL; run nomarmy connect <target> instead of --copy-only");
2029
+ let projectDir = null;
2030
+ if (scope !== "user") {
2031
+ try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
2032
+ catch { throw new Error(`--scope ${scope} registers nomArmy for one repository, and ${repoDir} isn't inside a git repository. Run it from the repository (or pass --repo <dir>).`); }
2033
+ }
2034
+ if (flag("copy-only")) {
2035
+ // `nomarmy update` with only per-repo registrations: refresh the copy they run.
2036
+ installMcpCopy({ nomarmyRoot, installDir: defaultInstallDir(), run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts }) });
2037
+ return json ? out({ refreshed: defaultInstallDir() }) : console.log(c.green(`✓ Refreshed the nomArmy copy in ${defaultInstallDir()}.`));
2038
+ }
1636
2039
  let targets;
1637
2040
  if (requested.length > 0) {
1638
2041
  const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
@@ -1657,9 +2060,23 @@ async function cmdConnect() {
1657
2060
  }
1658
2061
  try {
1659
2062
  if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
1660
- const result = connectTarget(target, { nomarmyRoot, run });
2063
+ if (nativeWindows) {
2064
+ const capture = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
2065
+ const distro = pickDistro({ run: capture });
2066
+ const resolved = resolveWslNomarmy({ distro, run: capture });
2067
+ const launch = mcpBridgeLaunch({ distro, ...resolved });
2068
+ const result = connectViaWsl({ target, distro, launch, run, nomarmyRoot });
2069
+ writeWindowsSettings({ distro, ...resolved });
2070
+ results.push({ target, connected: true, ...result });
2071
+ if (!json) console.log(c.green(`✓ Registered nomArmy with ${target}, running inside WSL (${distro})`));
2072
+ continue;
2073
+ }
2074
+ const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
1661
2075
  results.push({ target, connected: true, ...result });
1662
- if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
2076
+ if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}${scope === "user" ? "." : scope === "local" ? ` for ${projectDir} only (not committed).` : ` in ${path.relative(projectDir, result.configPath ?? path.join(projectDir, ".mcp.json"))}, for everyone who clones this repository.`}`));
2077
+ if (!json && scope === "local" && result?.excluded?.length) console.log(c.dim(` Kept out of git (.git/info/exclude): ${result.excluded.join(", ")}`));
2078
+ if (!json && scope === "project") console.log(c.dim(` Commit it along with the playbook in ${path.relative(projectDir, result.commands.dir)}. Each teammate needs nomArmy installed and set up (npm install -g nomarmy@alpha, then nomarmy setup); the registration runs \`nomarmy mcp\`, which uses their own settings.`));
2079
+ if (!json && scope !== "user" && target === "claude" && result?.userScoped) console.log(c.yellow(` nomArmy is also registered for all your projects (user scope). To use it only where you register it per repository: claude mcp remove nomarmy-local-worker -s user`));
1663
2080
  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)`));
1664
2081
  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)"));
1665
2082
  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`));
@@ -1681,6 +2098,82 @@ async function cmdConnect() {
1681
2098
  function runScript(name, args = []) {
1682
2099
  execFileSync("bash", [path.join(nomarmyRoot, "scripts", name), ...args], { cwd: nomarmyRoot, stdio: "inherit" });
1683
2100
  }
2101
+ // `nomarmy sandbox`: see lib/sandbox-vm.mjs.
2102
+ async function cmdSandbox() {
2103
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2104
+ const podman = (args, opts = {}) => spawnSync("podman", args, { encoding: "utf8", ...opts });
2105
+ const machine = process.platform === "linux" ? null : pickMachine(podman(["machine", "inspect"]).stdout);
2106
+ let images = null;
2107
+ try {
2108
+ const rows = JSON.parse(podman(["system", "df", "--format", "json"]).stdout || "[]");
2109
+ const row = rows.find((r) => /image/i.test(r.Type ?? ""));
2110
+ if (row) images = { count: row.Total ?? null, size: row.Size ?? null, reclaimable: row.Reclaimable ?? null };
2111
+ } catch { /* podman missing or old */ }
2112
+ const runningJobs = liveLeases(path.join(stateRoot, "leases")).length;
2113
+ const memoryGib = value("memory");
2114
+
2115
+ if (!memoryGib && !flag("prune") && !flag("repair")) {
2116
+ if (json) return out({ platform: process.platform, machine, images, runningJobs, minimumMb: MIN_PODMAN_VM_MB });
2117
+ console.log(c.bold("🍪 nomArmy sandbox"));
2118
+ if (process.platform === "linux") console.log("\nPodman runs natively on Linux: there's no VM to size.");
2119
+ else if (!machine) console.log(c.yellow("\nNo Podman machine found. Run: podman machine init && podman machine start"));
2120
+ else {
2121
+ const low = machine.memoryMb && machine.memoryMb < MIN_PODMAN_VM_MB;
2122
+ console.log(`\nVM ${machine.name} (${machine.state}): ${machine.cpus} CPUs, ${low ? c.red(`${machine.memoryMb / 1024} GiB memory`) : `${machine.memoryMb / 1024} GiB memory`}, ${machine.diskGb} GB disk`);
2123
+ if (low) console.log(c.yellow(` Too small: worker commands get cut off below ${MIN_PODMAN_VM_MB / 1024} GiB. Fix: nomarmy sandbox --memory 8`));
2124
+ const { maxJobs, jobsThatFit, vmGibFor } = await import("../lib/limits.mjs");
2125
+ const limit = maxJobs().value, fit = jobsThatFit(machine.memoryMb);
2126
+ if (fit !== null) console.log(limit > fit
2127
+ ? c.yellow(` Fits about ${fit} sandboxes at once, but up to ${limit} api and subscription jobs may run. Fix: nomarmy sandbox --memory ${vmGibFor(limit)}, or nomarmy config max-jobs ${fit}`)
2128
+ : c.dim(` Fits about ${fit} sandboxes at once; up to ${limit} api and subscription jobs may run (nomarmy config max-jobs).`));
2129
+ }
2130
+ if (images) console.log(`Images: ${images.count}, ${images.size}${images.reclaimable ? `, ${images.reclaimable} reclaimable (nomarmy sandbox --prune)` : ""}`);
2131
+ console.log(c.dim(runningJobs ? `${runningJobs} nomArmy job(s) running.` : "No nomArmy jobs running."));
2132
+ return;
2133
+ }
2134
+
2135
+ const ask = async (question) => {
2136
+ if (flag("yes")) return true;
2137
+ if (!process.stdin.isTTY) throw new Error(`${question} Re-run with --yes to confirm without a terminal.`);
2138
+ const rl = createInterface({ input, output });
2139
+ try { return await confirm(rl, question, { defaultYes: false }); } finally { rl.close(); }
2140
+ };
2141
+ const runPodman = (args) => {
2142
+ console.log(c.dim(`$ podman ${args.join(" ")}`));
2143
+ const r = podman(args, { stdio: "inherit" });
2144
+ if (r.status !== 0) throw new Error(`podman ${args.join(" ")} failed (exit ${r.status ?? "none"}).`);
2145
+ };
2146
+
2147
+ if (flag("repair")) {
2148
+ // Restore the subordinate ID ranges a Podman machine ships with, only
2149
+ // where they're missing, then let Podman re-read them.
2150
+ if (process.platform === "linux") throw new Error("On Linux, add a range for your user to /etc/subuid and /etc/subgid (e.g. `sudo usermod --add-subuids 100000-1099999 --add-subgids 100000-1099999 $USER`), then run `podman system migrate`.");
2151
+ if (!machine) throw new Error("No Podman machine found. Run: podman machine init && podman machine start");
2152
+ // podman system migrate stops every container, a running job's sandbox included.
2153
+ if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; the repair restarts Podman's containers and would kill their sandboxes. Run it when they're done.`);
2154
+ const script = 'set -e; restored=0; for f in /etc/subuid /etc/subgid; do if ! grep -q "^$(id -un):" "$f" 2>/dev/null; then echo "$(id -un):100000:1000000" | sudo tee -a "$f" >/dev/null; echo "restored $f"; restored=1; else echo "$f already has a range"; fi; done; if [ "$restored" = 1 ]; then podman system migrate; else echo "nothing to repair"; fi';
2155
+ if (!(await ask(`Restore missing subordinate ID ranges in the ${machine.name} VM and run podman system migrate?`))) { console.log(c.dim("Nothing changed.")); return; }
2156
+ const r = spawnSync("podman", ["machine", "ssh", machine.name, script], { stdio: "inherit" });
2157
+ if (r.status !== 0) throw new Error(`the repair failed (exit ${r.status ?? "none"}).`);
2158
+ console.log(c.green("✓ Done. Check with: nomarmy doctor"));
2159
+ return;
2160
+ }
2161
+
2162
+ if (memoryGib) {
2163
+ const plan = planResize({ gib: memoryGib, machine, hostMemoryMb: os.totalmem() / 1024 / 1024, runningJobs });
2164
+ if (!plan.ok) throw new Error(plan.problems.join("; "));
2165
+ for (const w of plan.warnings) console.log(c.yellow(`⚠ ${w}`));
2166
+ if (!(await ask(`Restart the Podman VM with ${memoryGib} GiB (from ${machine.memoryMb / 1024})?`))) { console.log(c.dim("Nothing changed.")); return; }
2167
+ for (const args of plan.commands) runPodman(args);
2168
+ console.log(c.green(`✓ The Podman VM now has ${memoryGib} GiB.`));
2169
+ }
2170
+ if (flag("prune")) {
2171
+ if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; prune when they're done.`);
2172
+ if (!(await ask(`Remove every image no container uses${images?.reclaimable ? ` (about ${images.reclaimable})` : ""}? nomArmy rebuilds its own when a job needs them.`))) { console.log(c.dim("Nothing removed.")); return; }
2173
+ runPodman(["image", "prune", "--all", "--force"]);
2174
+ }
2175
+ }
2176
+
1684
2177
  async function cmdStart() { console.log(c.bold("🍪 Starting inference...\n")); runScript("start-inference.sh", argv.slice(1)); }
1685
2178
  async function cmdStop() { runScript("stop-inference.sh", argv.slice(1)); }
1686
2179
  function safeDu(dir) {
@@ -1729,7 +2222,7 @@ async function maybeRemoveAgentsDir({ force }) {
1729
2222
  * needs manual cleanup -- an honest, bounded scope beats guessing at which
1730
2223
  * cache entries are "ours". */
1731
2224
  function resolveConfiguredModelRepos() {
1732
- const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
2225
+ const repo = setting("NOMARMY_MODEL_REPO");
1733
2226
  return repo ? [repo] : [];
1734
2227
  }
1735
2228
 
@@ -1952,10 +2445,17 @@ function armyLayerFlag(fallback = "global") {
1952
2445
  return chosen[0] ?? fallback;
1953
2446
  }
1954
2447
 
1955
- function loadArmyForCli() {
2448
+ function loadArmyForCli({ globalOnly = false, usageRefresh = null } = {}) {
1956
2449
  const agents = loadAgentsOrExit().agents;
1957
- const loaded = loadArmy({ projectDir: repoDir });
1958
- return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel }) };
2450
+ const loaded = globalOnly
2451
+ ? (() => {
2452
+ const filePath = armyLayerPath("global", { projectDir: repoDir });
2453
+ const army = readArmyFile(filePath, { armyOnly: true });
2454
+ return { ...mergeArmy([{ layer: "global", army }]), layers: [{ layer: "global", path: filePath, exists: fs.existsSync(filePath), hasArmy: Boolean(army) }] };
2455
+ })()
2456
+ : loadArmy({ projectDir: repoDir });
2457
+ const usageSnapshots = usageRefresh?.snapshots ?? readUsageSnapshots(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"));
2458
+ return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel, usageSnapshots, agentProviderId, usageRefreshError: usageRefresh?.error ?? null, usageRefreshFailed: usageRefresh?.failedProviders ?? null }) };
1959
2459
  }
1960
2460
 
1961
2461
  // Claude Code adds settings.local.json to .gitignore for the same reason:
@@ -1975,15 +2475,36 @@ function agentCell(name, runsOn, role = null) {
1975
2475
  return `${name}${model ? ` ${c.bold(model)}` : ""}${runsOn ? c.dim(` ${runsOn}`) : ""}`;
1976
2476
  }
1977
2477
 
2478
+ /** An agent's usage-limit reading (lib/usage-limits.mjs), colored by level. */
2479
+ function usageLine(usage, indent) {
2480
+ const text = `${indent}usage: ${usage.text}`;
2481
+ return usage.level === "over" ? c.red(`${text} (at the limit)`) : usage.level === "high" ? c.yellow(text) : c.dim(text);
2482
+ }
2483
+
2484
+ function repositoryHere(dir) {
2485
+ let current = path.resolve(dir);
2486
+ while (true) {
2487
+ if (fs.existsSync(path.join(current, ".git"))) return true;
2488
+ const parent = path.dirname(current);
2489
+ if (parent === current) return false;
2490
+ current = parent;
2491
+ }
2492
+ }
2493
+
1978
2494
  async function cmdArmyShow() {
1979
- const { summary } = loadArmyForCli();
2495
+ const inRepository = repositoryHere(repoDir);
2496
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2497
+ const usageRefresh = await refreshStaleOverLimitReadings(stateRoot);
2498
+ const { summary } = loadArmyForCli({ globalOnly: !inRepository, usageRefresh });
1980
2499
  if (json) return out(summary);
1981
2500
  const g = summary.general;
1982
- console.log(c.bold("🪖 nomArmy") + c.dim(` (${repoDir})`));
2501
+ console.log(c.bold("🪖 nomArmy") + (inRepository ? c.dim(` (${repoDir})`) : ""));
2502
+ if (!inRepository) console.log(c.dim("no repository here: showing global settings"));
1983
2503
  console.log(`\n${c.bold("General")} ${g.agent ? agentCell(g.agent, g.agentRunsOn) : ""}${g.setBy ? c.dim(` [${g.setBy}]`) : ""}`);
1984
2504
  console.log(c.dim(` ${g.who}`));
1985
2505
  for (const line of g.responsibilities) console.log(c.dim(` - ${line}`));
1986
2506
  if (g.problem) console.log(c.yellow(` ⚠ ${g.problem}`));
2507
+ if (g.usage) console.log(usageLine(g.usage, " "));
1987
2508
  if (summary.workflow) console.log(`\n${c.bold("Workflow")}\n${summary.workflow.split("\n").map((l) => ` ${l}`).join("\n")}`);
1988
2509
  const names = Object.keys(summary.roles);
1989
2510
  if (!names.length) {
@@ -2003,11 +2524,12 @@ async function cmdArmyShow() {
2003
2524
  if (role.description) console.log(c.dim(` ${role.description}`));
2004
2525
  if (role.problem && role.agent) console.log(c.red(` ✗ ${role.problem}`));
2005
2526
  if (role.overlapsGeneral) console.log(c.yellow(` ⚠ ${role.overlapsGeneral}`));
2527
+ if (role.usage) console.log(usageLine(role.usage, " "));
2006
2528
  }
2007
2529
  }
2008
2530
  }
2009
2531
  console.log(`\n${c.bold("Layers")} ${c.dim("(later ones win)")}`);
2010
- for (const layer of summary.layers) {
2532
+ for (const layer of summary.layers.filter((entry) => inRepository || entry.layer === "global")) {
2011
2533
  const state = layer.hasArmy ? c.green("● army section") : layer.exists ? c.dim("○ file exists, no army section") : c.dim("○ no file");
2012
2534
  console.log(` ${layer.layer.padEnd(8)} ${state.padEnd(40)} ${c.dim(layer.path)}`);
2013
2535
  }
@@ -2057,14 +2579,31 @@ async function cmdArmyInit() {
2057
2579
  }
2058
2580
  }
2059
2581
 
2582
+ function armyPositionals() {
2583
+ const result = [];
2584
+ const args = argv.slice(2);
2585
+ for (let i = 0; i < args.length; i++) {
2586
+ const arg = args[i];
2587
+ if (["--repo", "--agent", "--model"].includes(arg)) {
2588
+ if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
2589
+ i++;
2590
+ } else if (["--global", "--project", "--local", "--json", "--no-check"].includes(arg)) {
2591
+ continue;
2592
+ } else if (arg.startsWith("--")) {
2593
+ throw new Error(`Unknown army option ${arg}`);
2594
+ } else result.push(arg);
2595
+ }
2596
+ return result;
2597
+ }
2598
+
2060
2599
  async function cmdArmyAssign() {
2061
- // Positionals only: argv also holds flags, and `--json` must never be read as a model.
2062
- const positional = argv.slice(2);
2063
- const firstFlag = positional.findIndex((a) => a.startsWith("--"));
2064
- const [roleName, agentName, model] = firstFlag === -1 ? positional : positional.slice(0, firstFlag);
2065
- if (!roleName || !agentName) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
2600
+ const positional = armyPositionals();
2601
+ const [roleName, agentName, model] = positional;
2602
+ if (!roleName || !agentName || positional.length > 3) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
2066
2603
  const layer = armyLayerFlag("global");
2067
2604
  const filePath = armyLayerPath(layer, { projectDir: repoDir });
2605
+ const defined = Object.prototype.hasOwnProperty.call(loadArmy({ projectDir: repoDir }).army.roles, roleName);
2606
+ if (!defined) throw new Error(`Role "${roleName}" is not defined in any army roster. Run nomarmy army init first.`);
2068
2607
  const target = parseTargetSpec(agentName, model);
2069
2608
  const check = flag("no-check") ? { status: "skipped" } : checkRoleModel(agentName, model);
2070
2609
  if (check.status === "failed") {
@@ -2120,8 +2659,9 @@ function checkRoleModel(agentName, model) {
2120
2659
  // Which agent the General is. Global or local only: it describes the
2121
2660
  // person's own coordinator session, which a committed project file can't know.
2122
2661
  async function cmdArmyGeneral() {
2123
- const agentName = argv[2];
2124
- if (!agentName) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
2662
+ const positional = armyPositionals();
2663
+ const [agentName] = positional;
2664
+ if (!agentName || positional.length !== 1) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
2125
2665
  const layer = armyLayerFlag("global");
2126
2666
  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.");
2127
2667
  const agents = loadAgentsOrExit().agents;
@@ -2156,14 +2696,38 @@ async function cmdConfigPaths() {
2156
2696
  if (json) return out({ globalDir: globalConfigDir(), agents: { path: agentsPath, exists: fs.existsSync(agentsPath) }, army });
2157
2697
  console.log(c.bold("nomArmy config") + c.dim(` (global dir: ${globalConfigDir()})`));
2158
2698
  console.log(` ${fs.existsSync(agentsPath) ? c.green("●") : c.dim("○")} ${"agents".padEnd(8)} ${c.dim(agentsPath)}`);
2699
+ const { limitsPath } = await import("../lib/limits.mjs");
2700
+ console.log(` ${fs.existsSync(limitsPath()) ? c.green("●") : c.dim("○")} ${"limits".padEnd(8)} ${c.dim(limitsPath())}`);
2159
2701
  console.log(c.bold("\nArmy layers"));
2160
2702
  for (const a of army) console.log(` ${a.exists ? c.green("●") : c.dim("○")} ${a.layer.padEnd(8)} ${c.dim(a.path)}`);
2161
2703
  }
2162
2704
 
2705
+ // `nomarmy config max-jobs [n]`: api and subscription jobs at once, machine-wide.
2706
+ async function cmdConfigMaxJobs() {
2707
+ const { maxJobs, setMaxJobs, jobsThatFit, vmGibFor } = await import("../lib/limits.mjs");
2708
+ const given = argv[2];
2709
+ if (given !== undefined) {
2710
+ if (!/^\d+$/.test(given)) throw new Error(`max-jobs must be a whole number, got "${given}"`);
2711
+ setMaxJobs(Number(given));
2712
+ }
2713
+ const limit = maxJobs();
2714
+ const machine = process.platform === "linux" ? null : pickMachine(spawnSync("podman", ["machine", "inspect"], { encoding: "utf8" }).stdout);
2715
+ const fit = jobsThatFit(machine?.memoryMb);
2716
+ if (json) return out({ maxJobs: limit.value, source: limit.source, path: limit.path, problem: limit.problem, podmanVmMemoryMb: machine?.memoryMb ?? null, jobsThatFit: fit });
2717
+ const from = { file: `set in ${limit.path}`, env: "from NOMARMY_MAX_POOL_WORKERS in this shell (limits.yml doesn't set it)", default: "the default" }[limit.source];
2718
+ if (limit.problem) console.log(c.yellow(`⚠ ${limit.problem}`));
2719
+ console.log(`${given !== undefined ? c.green("✓ ") : ""}Up to ${c.bold(String(limit.value))} api and subscription jobs at once, across every session (${from}).`);
2720
+ console.log(c.dim("Each agent's max_concurrent in agents.yml also applies, and local-model jobs have their own limit."));
2721
+ if (given !== undefined) console.log(c.dim("Applies to the next job in every session on this version, no restart."));
2722
+ if (fit !== null && limit.value > fit) console.log(c.yellow(`⚠ The Podman VM (${machine.memoryMb / 1024} GiB) fits about ${fit} sandboxes at once; more get refused for memory or cut off. Fix: nomarmy sandbox --memory ${vmGibFor(limit.value)}`));
2723
+ if (given === undefined) console.log(c.dim("Change it with `nomarmy config max-jobs <n>` (1 to 32)."));
2724
+ }
2725
+
2163
2726
  async function cmdConfig() {
2164
2727
  const sub = argv[1] ?? "paths";
2165
2728
  if (sub === "paths") return cmdConfigPaths();
2166
- throw new Error(`Unknown config subcommand "${sub}". Use: nomarmy config paths`);
2729
+ if (sub === "max-jobs") return cmdConfigMaxJobs();
2730
+ throw new Error(`Unknown config subcommand "${sub}". Use: nomarmy config <paths|max-jobs [n]>`);
2167
2731
  }
2168
2732
 
2169
2733
  // --- `nomarmy jobs [--watch]`: what's running, from any session -----------
@@ -2183,7 +2747,7 @@ function pidIsAlive(pid) {
2183
2747
  try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
2184
2748
  }
2185
2749
 
2186
- function collectJobs({ recent = 8 } = {}) {
2750
+ function collectJobs({ recent = 8, runId = null, projectDir = null } = {}) {
2187
2751
  const root = jobsRootDir();
2188
2752
  let names = [];
2189
2753
  try { names = fs.readdirSync(root); } catch { return { running: [], recent: [] }; }
@@ -2191,6 +2755,10 @@ function collectJobs({ recent = 8 } = {}) {
2191
2755
  const dir = path.join(root, name);
2192
2756
  const status = readJsonSafe(path.join(dir, "status.json")) ?? {};
2193
2757
  const meta = readJsonSafe(path.join(dir, "metadata.json"));
2758
+ const lease = readJsonSafe(path.join(agentStateRoot(), "leases", `${name}.json`));
2759
+ const jobRunId = meta?.labels?.runId ?? lease?.runId ?? null;
2760
+ const jobProjectDir = meta?.projectDir ?? lease?.repo ?? null;
2761
+ if ((runId && jobRunId !== runId) || (projectDir && (!jobProjectDir || path.resolve(jobProjectDir) !== projectDir))) return null;
2194
2762
  const started = Date.parse(status.startedAt ?? meta?.startedAt ?? "") || fs.statSync(dir).mtimeMs;
2195
2763
  const running = status.state === "running" && pidIsAlive(status.serverPid);
2196
2764
  return {
@@ -2203,7 +2771,7 @@ function collectJobs({ recent = 8 } = {}) {
2203
2771
  heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
2204
2772
  started, dir,
2205
2773
  };
2206
- }).sort((a, b) => b.started - a.started);
2774
+ }).filter(Boolean).sort((a, b) => b.started - a.started);
2207
2775
  return { running: jobs.filter((j) => j.running), recent: jobs.filter((j) => !j.running).slice(0, recent) };
2208
2776
  }
2209
2777
 
@@ -2232,6 +2800,19 @@ function renderJobs({ running, recent }) {
2232
2800
  */
2233
2801
  async function streamJobEvents() {
2234
2802
  const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
2803
+ // A stream nobody reads must still end: a General that ran this as a
2804
+ // background command (reported only on exit) was never told jobs had
2805
+ // finished, and eight of these streams were left running for days.
2806
+ const untilDone = flag("until-done");
2807
+ const runId = value("run");
2808
+ const projectDir = flag("repo") ? repoDir : null;
2809
+ if (!runId && !projectDir) {
2810
+ const detail = "watching every job on this machine; use --wait <ids> or --run <id> to scope the watch";
2811
+ if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "scope", detail }));
2812
+ else console.log(detail);
2813
+ }
2814
+ const idleLimitMs = Math.max(1, Number(value("idle-minutes", "30")) || 30) * 60000;
2815
+ let idleSinceMs = Date.now(), sawRunning = false;
2235
2816
  const seen = new Map();
2236
2817
  const emit = (event, job, detail = "") => {
2237
2818
  if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event, jobId: job.jobId, agent: job.agent, model: job.model, phase: job.phase, detail }));
@@ -2240,7 +2821,7 @@ async function streamJobEvents() {
2240
2821
  process.on("SIGINT", () => process.exit(0));
2241
2822
  let first = true;
2242
2823
  for (;;) {
2243
- const { running, recent } = collectJobs({ recent: 20 });
2824
+ const { running, recent } = collectJobs({ recent: 20, runId, projectDir });
2244
2825
  const now = new Map([...running, ...recent].map((j) => [j.jobId, j]));
2245
2826
  for (const j of running) {
2246
2827
  const prev = seen.get(j.jobId);
@@ -2251,13 +2832,85 @@ async function streamJobEvents() {
2251
2832
  const j = now.get(id);
2252
2833
  if (prev.running && j && !j.running) emit("finished", j, `${j.phase} after ${fmtSeconds(j.elapsedSeconds)}`);
2253
2834
  }
2835
+ // A finished record can briefly lack its run label after its lease is removed.
2836
+ // Keep the running snapshot until the stamped record becomes visible.
2837
+ const pending = (runId || projectDir) ? [...seen].filter(([id, j]) => j.running && !now.has(id)) : [];
2254
2838
  seen.clear();
2255
- for (const [id, j] of now) seen.set(id, j);
2839
+ for (const [id, j] of [...now, ...pending]) seen.set(id, j);
2256
2840
  first = false;
2841
+ if (running.length || pending.length) { sawRunning = true; idleSinceMs = Date.now(); }
2842
+ else if (untilDone && sawRunning) {
2843
+ if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "done", detail: "every job seen running has finished" }));
2844
+ else console.log(`${new Date().toLocaleTimeString()} done every job seen running has finished`);
2845
+ return;
2846
+ } else if (Date.now() - idleSinceMs >= (untilDone ? Math.min(idleLimitMs, 120000) : idleLimitMs)) {
2847
+ const why = untilDone ? "no job was running to wait for" : `nothing has run for ${Math.round(idleLimitMs / 60000)} minutes`;
2848
+ if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "idle", detail: why }));
2849
+ else console.log(`${new Date().toLocaleTimeString()} idle ${why}; exiting`);
2850
+ return;
2851
+ }
2257
2852
  await new Promise((r) => setTimeout(r, interval));
2258
2853
  }
2259
2854
  }
2260
2855
 
2856
+ const commitSha = (commit) => (typeof commit === "string" ? commit : typeof commit?.sha === "string" ? commit.sha : null);
2857
+
2858
+ /** Wait for selected jobs in the shared, cross-session state directory. */
2859
+ async function waitForJobCli() {
2860
+ const waitIndex = argv.indexOf("--wait");
2861
+ const requested = [];
2862
+ for (let i = waitIndex + 1; i < argv.length && !argv[i].startsWith("--"); i++) requested.push(...argv[i].split(","));
2863
+ const jobIds = [...new Set(requested)];
2864
+ const timeoutSeconds = Number(value("timeout", "1800"));
2865
+ const errorOut = (message) => {
2866
+ if (json) out({ error: message });
2867
+ else console.error(`nomarmy jobs: ${message}`);
2868
+ process.exitCode = 2;
2869
+ };
2870
+ if (!jobIds.length) return errorOut("--wait needs a job id");
2871
+ if (jobIds.some((id) => !id || path.basename(id) !== id || id === "." || id === "..")) return errorOut("--wait needs valid job ids");
2872
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) return errorOut("--timeout must be a non-negative number of seconds");
2873
+ for (const jobId of jobIds) {
2874
+ if (!fs.existsSync(path.join(jobsRootDir(), jobId)) && !fs.existsSync(path.join(agentStateRoot(), "leases", `${jobId}.json`))) {
2875
+ return errorOut(`unknown job id: ${jobId}`);
2876
+ }
2877
+ }
2878
+ const deadline = Date.now() + timeoutSeconds * 1000;
2879
+ const results = await Promise.all(jobIds.map(async (jobId) => {
2880
+ const jobDir = path.join(jobsRootDir(), jobId);
2881
+ for (;;) {
2882
+ const status = readJsonSafe(path.join(jobDir, "status.json")) ?? {};
2883
+ const meta = readJsonSafe(path.join(jobDir, "metadata.json")) ?? {};
2884
+ if (status.state === "finished" || meta.outcome) {
2885
+ const issues = Array.isArray(meta.issues) ? meta.issues : Array.isArray(status.issues) ? status.issues : [];
2886
+ const result = {
2887
+ jobId,
2888
+ outcome: meta.outcome ?? status.outcome ?? null,
2889
+ coordinatorStatus: meta.coordinatorStatus ?? status.coordinatorStatus ?? null,
2890
+ branch: meta.branch ?? status.branch ?? null,
2891
+ commit: commitSha(meta.commit) ?? commitSha(status.commit),
2892
+ issues,
2893
+ };
2894
+ if (json) out(result);
2895
+ else {
2896
+ const firstIssue = issues[0];
2897
+ const issueText = firstIssue == null ? null : typeof firstIssue === "string" ? firstIssue : firstIssue.message ?? JSON.stringify(firstIssue);
2898
+ console.log([result.jobId, result.outcome ?? "unknown", result.coordinatorStatus ?? "unknown",
2899
+ result.branch ? `branch=${result.branch}` : null, result.commit ? `commit=${result.commit}` : null,
2900
+ issueText ? `issue=${issueText}` : null].filter(Boolean).join(" "));
2901
+ }
2902
+ return result.coordinatorStatus === "complete" ? 0 : 1;
2903
+ }
2904
+ if (Date.now() >= deadline) {
2905
+ errorOut(`timed out waiting for job ${jobId}`);
2906
+ return 2;
2907
+ }
2908
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(1, deadline - Date.now()))));
2909
+ }
2910
+ }));
2911
+ process.exitCode = Math.max(...results);
2912
+ }
2913
+
2261
2914
  /**
2262
2915
  * `nomarmy jobs --prune [--older-than DAYS]`: remove runtime/ (per-job npm
2263
2916
  * cache, harness state such as Codex's, OpenClaw's transcript) from
@@ -2274,6 +2927,14 @@ function pruneJobRuntimeCli() {
2274
2927
  }
2275
2928
 
2276
2929
  async function cmdJobs() {
2930
+ if (flag("wait")) return waitForJobCli();
2931
+ if (flag("stop")) {
2932
+ const r = requestJobStop({ jobsRoot: jobsRootDir(), jobId: value("stop"), reason: value("reason") });
2933
+ if (json) return out(r);
2934
+ console.log(r.ok ? c.green(`✓ ${r.message}`) : c.red(`✗ ${r.message}`));
2935
+ if (!r.ok) process.exitCode = 1;
2936
+ return;
2937
+ }
2277
2938
  if (flag("events")) return streamJobEvents();
2278
2939
  if (flag("prune")) return pruneJobRuntimeCli();
2279
2940
  if (json) return out(collectJobs());
@@ -2301,6 +2962,7 @@ async function cmdHealth() {
2301
2962
  const { checkAndRecordHealth } = await import("../lib/health.mjs");
2302
2963
  const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2303
2964
  const { result } = await checkAndRecordHealth({ projectDir: repoDir, stateRoot, configDir: globalConfigDir(), env: installEnv() });
2965
+ if (result.issues.some((i) => i.severity === "error")) process.exitCode = 1;
2304
2966
  if (json) return out(result);
2305
2967
  console.log(c.bold("🍪 nomArmy health") + c.dim(` ${new Date(result.checkedAt).toLocaleString()}`));
2306
2968
  if (!result.issues.length) { console.log(c.green("\n✓ Nothing to fix.")); return; }
@@ -2320,15 +2982,249 @@ async function cmdStatusline() {
2320
2982
  process.stdout.write(`${statusLineText({ session })}\n`);
2321
2983
  }
2322
2984
 
2323
- 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) };
2985
+ // `stats --share`: markdown for a PR description or README; `--badge [path]`:
2986
+ // an SVG badge to commit, with the README line for it (lib/share.mjs).
2987
+ async function shareStats(stats) {
2988
+ const { shareMarkdown, badgeSvg, badgeMarkdown } = await import("../lib/share.mjs");
2989
+ const scope = value("run") ? "this feature run" : value("since") ? `since ${value("since")}` : null;
2990
+ if (flag("share")) console.log(shareMarkdown(stats, { scope }));
2991
+ if (flag("badge")) {
2992
+ const given = value("badge");
2993
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
2994
+ const root = top.status === 0 ? top.stdout.trim() : process.cwd();
2995
+ const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
2996
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2997
+ fs.writeFileSync(file, badgeSvg(stats));
2998
+ const rel = path.relative(root, file).split(path.sep).join("/");
2999
+ console.log(`${flag("share") ? "\n" : ""}${c.green("✓")} Wrote ${rel}. Commit it and add this to your README:\n\n ${badgeMarkdown(rel)}\n`);
3000
+ console.log(c.dim("Re-run nomarmy stats --badge after more jobs to refresh the numbers."));
3001
+ }
3002
+ }
3003
+
3004
+ function cmdStats() {
3005
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
3006
+ const records = loadJobRecords(path.join(stateRoot, "jobs"));
3007
+ let repo = null;
3008
+ if (value("repo")) repo = resolveRepo(records, value("repo"));
3009
+ else if (!flag("all-repos")) {
3010
+ try { repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
3011
+ catch { throw new Error(`${repoDir} isn't inside a git repository; run nomarmy stats from one, or pass --repo <name> or --all-repos`); }
3012
+ }
3013
+ let agentFor = () => null;
3014
+ try { agentFor = agentLookup(loadAgents(globalConfigDir()).agents, agentProviderId); } catch { /* no agents.yml: commands name <agent> */ }
3015
+ const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), runId: value("run"), agentFor, allSuggestions: flag("all-suggestions") });
3016
+ if (json) return out(stats);
3017
+ if (flag("share") || flag("badge")) return shareStats(stats);
3018
+ console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
3019
+ }
3020
+
3021
+ // Read one line without echoing it: stty -echo around the read, restored
3022
+ // even if the read fails. Windows has no stty, so it says the input shows.
3023
+ async function readHiddenLine(prompt) {
3024
+ const hide = process.stdin.isTTY && process.platform !== "win32";
3025
+ if (!hide) console.log(c.yellow("(your input will be visible as you type)"));
3026
+ const rl = createInterface({ input, output });
3027
+ try {
3028
+ if (hide) spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] });
3029
+ return (await rl.question(prompt)).trim();
3030
+ } finally {
3031
+ if (hide) { spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] }); process.stdout.write("\n"); }
3032
+ rl.close();
3033
+ }
3034
+ }
3035
+
3036
+ async function readStdin() {
3037
+ const chunks = [];
3038
+ for await (const chunk of process.stdin) chunks.push(chunk);
3039
+ return Buffer.concat(chunks).toString("utf8").trim();
3040
+ }
3041
+
3042
+ // One tiny System One request, to prove the key and the route work.
3043
+ async function testJev(settings) {
3044
+ const { answers } = await askJev({ key: settings.key, model: settings.model, state: { text: "The build finished and all 12 tests passed." },
3045
+ questions: { passed: { type: "noul", instructions: "Does the text say the tests passed?", criteria: { true: "It says the tests passed", false: "It doesn't" } } } });
3046
+ return typeof answers.passed?.noul === "number";
3047
+ }
3048
+
3049
+ async function cmdValidators() {
3050
+ const [sub = "list", name] = argv.slice(1).filter((a) => !a.startsWith("--"));
3051
+ if (sub === "list") {
3052
+ let config = {};
3053
+ try { config = loadValidators(); } catch (error) { if (json) return out({ error: error.message }); console.log(c.red(error.message)); process.exitCode = 1; return; }
3054
+ const judge = config.judge ? { enabled: config.judge.enabled, agent: config.judge.agent, model: config.judge.model, checks: config.judge.checks, hostTools: config.judge.host_tools } : null;
3055
+ const jev = config.jev ? { enabled: config.jev.enabled, checks: config.jev.checks, model: config.jev.model, key: config.jev.key_env ? `env ${config.jev.key_env}` : config.jev.key_file, keyReadable: Boolean(jevSettings()) } : null;
3056
+ if (json) return out({ path: validatorsPath(), jev, judge });
3057
+ if (!jev && !judge) { console.log("No validators configured. Add one with: nomarmy validators add jev, or nomarmy validators add judge --agent <name> --model <model>"); return; }
3058
+ if (jev) console.log(`Jev: ${jev.enabled ? c.green("on") : "off"} (${jev.model}); checks: ${jev.checks.join(", ")}; key: ${jev.key}${jev.keyReadable ? "" : c.red(" (not readable)")}`);
3059
+ if (judge) console.log(`Judge: ${judge.enabled ? c.green("on") : "off"} (${judge.agent}/${judge.model}); checks: ${judge.checks.join(", ")}${judge.hostTools ? c.yellow("; host tools allowed") : ""}`);
3060
+ return;
3061
+ }
3062
+ if (name === "judge") return cmdValidatorsJudge(sub);
3063
+ if (name !== "jev") throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>");
3064
+ if (sub === "add") {
3065
+ if (!json) {
3066
+ console.log(c.bold("🍪 Jev (TypeSafe) for nomArmy's semantic checks\n"));
3067
+ console.log("It checks that a scout's cited lines support its finding, and that a worker's report matches its diff.");
3068
+ console.log("Its answers only add review flags; they never pass a check or allow a commit.");
3069
+ console.log(c.yellow("It sends excerpts of your code (findings, cited lines, diffs, worker reports) to TypeSafe.\n"));
3070
+ }
3071
+ const key = flag("key-stdin") ? await readStdin() : await readHiddenLine("TypeSafe API key (not shown): ");
3072
+ const saved = saveJevKey(key);
3073
+ let ok = false, why = null;
3074
+ try { ok = await testJev(jevSettings()); } catch (error) { why = error.message; }
3075
+ if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why });
3076
+ console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}.`));
3077
+ console.log(ok ? c.green("✓ Test call answered. New jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call failed: ${why ?? "no answer"}. Check the key, then: nomarmy validators test jev`));
3078
+ if (!ok) process.exitCode = 1;
3079
+ return;
3080
+ }
3081
+ if (sub === "test") {
3082
+ const settings = jevSettings();
3083
+ if (!settings) throw new Error("Jev isn't configured, or its key isn't readable. Add it with: nomarmy validators add jev");
3084
+ let ok = false, why = null;
3085
+ try { ok = await testJev(settings); } catch (error) { why = error.message; }
3086
+ if (json) return out({ test: ok ? "pass" : "fail", reason: why });
3087
+ console.log(ok ? c.green("✓ Jev answered.") : c.red(`✗ Jev test call failed: ${why ?? "no answer"}`));
3088
+ if (!ok) process.exitCode = 1;
3089
+ return;
3090
+ }
3091
+ if (sub === "remove") {
3092
+ const result = removeJev();
3093
+ if (json) return out(result);
3094
+ console.log(c.green(`✓ Jev is off${result.removedKey ? ", and its saved key is deleted" : ""}.`));
3095
+ return;
3096
+ }
3097
+ throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev>");
3098
+ }
3099
+
3100
+ async function cmdValidatorsJudge(sub) {
3101
+ const agents = loadAgents(globalConfigDir()).agents;
3102
+ const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
3103
+ const probe = async (settings) => probeModel({ provider: settings.provider, model: settings.model, stateRoot: process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents") });
3104
+ if (sub === "add") {
3105
+ let agent = value("agent"), model = value("model"), dominantBuilderVendor = null;
3106
+ let rl = null;
3107
+ const question = async (prompt) => {
3108
+ rl ??= createInterface({ input, output });
3109
+ return rl.question(c.bold(prompt));
3110
+ };
3111
+ if (!agent) {
3112
+ if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
3113
+ const roles = loadArmy({ projectDir: repoDir }).army.roles;
3114
+ const guided = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
3115
+ dominantBuilderVendor = guided.dominantBuilderVendor;
3116
+ if (!guided.choices.length) throw new Error("No api or subscription agents are configured in agents.yml.");
3117
+ agent = (await chooseJudgeAgent({ choices: guided.choices, ask: question, write: (line) => console.log(line) })).name;
3118
+ }
3119
+ if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
3120
+ if (!model) {
3121
+ if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
3122
+ const listed = catalogModelsFor(agentProviderId(agents[agent]));
3123
+ if (listed.length) {
3124
+ console.log(`Models for ${agent}:`);
3125
+ listed.forEach((item, index) => console.log(` ${index + 1}. ${item}`));
3126
+ }
3127
+ const fallback = agents[agent].model ?? "";
3128
+ const answer = String(await question(`Model id${fallback ? ` [${fallback}]` : ""}: `)).trim();
3129
+ model = /^\d+$/.test(answer) && listed[Number(answer) - 1] ? listed[Number(answer) - 1] : answer || fallback;
3130
+ if (!model) { rl?.close(); throw new Error("A model id is required."); }
3131
+ }
3132
+ let hostTools = flag("host-tools");
3133
+ if (agentRunsToolsOnHost(agents[agent]) && !hostTools) {
3134
+ if (!process.stdin.isTTY || json) {
3135
+ const quote = (arg) => /^[A-Za-z0-9_./:-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
3136
+ const rerun = ["nomarmy", ...argv, "--host-tools"].map(quote).join(" ");
3137
+ throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Refusing without explicit consent. Re-run: ${rerun}`);
3138
+ }
3139
+ const allowed = await confirmJudgeHostTools({ agent, ask: question, write: (line) => console.log(line) });
3140
+ if (!allowed) { rl?.close(); console.log(c.dim("Canceled; nothing written.")); return; }
3141
+ hostTools = true;
3142
+ }
3143
+ rl?.close();
3144
+ const saved = saveJudge({ agent, model, hostTools });
3145
+ const settings = resolve();
3146
+ if (settings?.problem) throw new Error(settings.problem);
3147
+ const test = await probe(settings);
3148
+ if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
3149
+ console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
3150
+ if (dominantBuilderVendor === null) {
3151
+ try {
3152
+ const roles = loadArmy({ projectDir: repoDir }).army.roles;
3153
+ dominantBuilderVendor = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost }).dominantBuilderVendor;
3154
+ } catch { /* no readable army means there is no builder comparison */ }
3155
+ }
3156
+ if (dominantBuilderVendor && agentProviderId(agents[agent]) === dominantBuilderVendor) console.log(c.yellow("Note: this judge uses the same vendor as most build roles, so its verdicts are not independent of those builders."));
3157
+ console.log(test.ok ? c.green("✓ Test call answered. New implement jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call ${test.refused ? "refused" : "didn't answer"}: ${test.reason ?? "no answer"}`));
3158
+ if (!test.ok) process.exitCode = 1;
3159
+ return;
3160
+ }
3161
+ if (sub === "test") {
3162
+ const settings = resolve();
3163
+ if (!settings) throw new Error("No judge configured. Add one with: nomarmy validators add judge --agent <name> --model <model>");
3164
+ if (settings.problem) throw new Error(settings.problem);
3165
+ const test = await probe(settings);
3166
+ if (json) return out({ test: test.ok ? "pass" : "fail", reason: test.reason });
3167
+ console.log(test.ok ? c.green(`✓ ${settings.agent}/${settings.model} answered.`) : c.red(`✗ ${test.reason ?? "no answer"}`));
3168
+ if (!test.ok) process.exitCode = 1;
3169
+ return;
3170
+ }
3171
+ if (sub === "remove") {
3172
+ removeJudge();
3173
+ return json ? out({ removed: true }) : console.log(c.green("✓ The judge is off."));
3174
+ }
3175
+ throw new Error("Usage: nomarmy validators <add judge --agent <name> --model <model> [--host-tools]|test judge|remove judge>");
3176
+ }
3177
+
3178
+ // `nomarmy mcp`: what a --scope project registration runs. Nothing goes to
3179
+ // stdout but the server's own protocol.
3180
+ function cmdMcp() {
3181
+ const { serverPath, env } = portableServerLaunch({ nomarmyRoot });
3182
+ const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env });
3183
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
3184
+ child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
3185
+ }
3186
+
3187
+ const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2324
3188
  // doctor command
2325
3189
  async function cmdDoctor() {
3190
+ if (windowsFrontEnd()) {
3191
+ process.exitCode = windowsDoctor({ json, argv });
3192
+ return;
3193
+ }
2326
3194
  // Import lazily to avoid circular dependencies
2327
3195
  const { runDoctor } = await import("../lib/doctor.mjs");
2328
- await runDoctor({ json, exit: true, env: installEnv() });
3196
+ const agents = loadAgentsOrExit().agents;
3197
+ const vendors = configuredSubscriptionVendors(agents);
3198
+ let armySummary = null;
3199
+ try { armySummary = describeArmy(loadArmy({ projectDir: repoDir }), { agents }); } catch { /* other doctor checks still run */ }
3200
+ let checks;
3201
+ if (flag("fix")) {
3202
+ const repaired = await repairOpenclaw({
3203
+ command: openclawCmd(), vendors, yes: flag("yes"), isTTY: Boolean(input.isTTY),
3204
+ print: json ? console.error : console.log,
3205
+ ask: async (prompt) => {
3206
+ const rl = createInterface({ input, output: json ? process.stderr : output });
3207
+ try { return await rl.question(prompt); } finally { rl.close(); }
3208
+ },
3209
+ });
3210
+ checks = repaired.checks;
3211
+ if (!repaired.ok && !checks.some((check) => !check.ok)) checks.push({ id: "openclaw-repair", ok: false, message: "OpenClaw repair was declined or failed.", fix: "nomarmy doctor --fix --yes" });
3212
+ } else {
3213
+ checks = verifyOpenclaw({ command: openclawCmd(), vendors });
3214
+ }
3215
+ await runDoctor({ json, exit: true, env: installEnv(), additionalChecks: checks,
3216
+ runtime: { agents, armySummary, openclawCmd: openclawCmd() } });
2329
3217
  }
2330
3218
  commands.doctor = cmdDoctor;
2331
- if (!command || flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
3219
+ if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
3220
+ process.exit(windowsForward(argv));
3221
+ }
3222
+ if (!command && !flag("help")) {
3223
+ // New users typed `nomarmy` and got the whole command reference.
3224
+ console.log(`${c.bold("nomArmy")}: bounded coding workers with independently verified results.\n\n New here? ${c.cyan("nomarmy setup")} walks you through it, one step at a time\n All commands: ${c.cyan("nomarmy help")}\n Docs: https://github.com/rayson-tech/nomarmy`);
3225
+ process.exit(0);
3226
+ }
3227
+ if (flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
2332
3228
 
2333
3229
  try {
2334
3230
  await commands[command]();