nomarmy 0.1.0-alpha.19 → 0.1.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nomarmy.mjs CHANGED
@@ -38,9 +38,10 @@ import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePat
38
38
  import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
39
39
  import { liveLeases } from "../lib/slots.mjs";
40
40
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
41
- import { recordProbeSuccess } from "../lib/health.mjs";
41
+ import { recordProbeSuccess, parseOpenclawAuthProfiles } from "../lib/health.mjs";
42
42
  import { pruneJobRuntime } from "../lib/prune.mjs";
43
43
  import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
44
+ import { PINNED_OPENCLAW_VERSION, repairOpenclaw, verifyOpenclaw, configuredSubscriptionVendors } from "../lib/openclaw-install.mjs";
44
45
  import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
45
46
  import { THINKING_LEVELS } from "../lib/thinking.mjs";
46
47
  import { fileURLToPath } from "node:url";
@@ -224,7 +225,7 @@ Usage: nomarmy <command> [options]
224
225
  how many api and subscription jobs run at once, across
225
226
  every session (default 4); with n, sets it in limits.yml.
226
227
  Warns when the Podman VM is too small for that many.
227
- jobs [--watch|--events [--until-done]|--prune|--wait <jobId>|--stop <jobId> [--reason <text>]] [--interval N] [--older-than DAYS]
228
+ jobs [--watch|--events [--until-done] [--run <run-id>] [--repo <path>]|--prune|--wait <jobId> [<jobId> ...]|--stop <jobId> [--reason <text>]] [--interval N] [--older-than DAYS]
228
229
  what's running across every session (agent, model, phase,
229
230
  last tool call, files changed, heartbeat) and what just
230
231
  finished; --watch redraws every N seconds (default 3);
@@ -232,14 +233,17 @@ Usage: nomarmy <command> [options]
232
233
  finish (--json for JSON lines). It's a stream: read it
233
234
  with a monitor that wakes on each line. A background
234
235
  command is only reported when it exits, so there use
235
- --events --until-done, which exits once every job it saw
236
- running has finished (or --wait for one job). The plain
236
+ --events --until-done --run <run-id> for a whole run,
237
+ or --wait <id> <id> ... for specific jobs. Unscoped
238
+ --until-done watches every job on this machine. The plain
237
239
  stream ends on its own after 30 minutes with nothing
238
240
  running (--idle-minutes N); --prune removes the bulky runtime data
239
241
  from finished jobs older than DAYS (default 2), keeping
240
242
  their records, reports and any retained worktree;
241
- --wait <jobId> [--timeout <seconds>] blocks for one job
242
- to finish (default timeout 1800; --json is supported);
243
+ --wait <jobId> [<jobId> ...] [--timeout <seconds>]
244
+ blocks until all finish; comma-separated ids also work
245
+ (default timeout 1800; --json is supported);
246
+ --events --repo <path> limits events to one repository;
243
247
  --stop <jobId> stops a running job's worker (no report
244
248
  recovery, no verification), keeping its worktree for
245
249
  continue_from
@@ -353,7 +357,7 @@ function cmdScan() {
353
357
  const notes = evidence.notes?.items ?? [];
354
358
  if (notes.length) {
355
359
  console.log("\nNotes:");
356
- for (const n of notes) console.log(` ${n.message ?? n}`);
360
+ for (const n of notes) console.log(` ${typeof n === "string" ? n : n.message ?? JSON.stringify(n)}`);
357
361
  }
358
362
  console.log("\nThis is deterministic evidence only - nothing here was executed.");
359
363
  console.log("Describe the environment in .nomarmy.yml, then run 'nomarmy validate'.");
@@ -376,12 +380,12 @@ function scanCheck(evidence) {
376
380
  process.exit(1);
377
381
  }
378
382
  console.log(`Comparing ${path.basename(loaded.path)} against repository evidence\n`);
379
- if (drift.summary) console.log(`${drift.summary}\n`);
383
+ if (drift.summary) console.log(`Drift: ${drift.summary.missingFromConfig} missing from config, ${drift.summary.missingFromRepo} missing from repo (${drift.summary.total} total)\n`);
380
384
  for (const section of ["services", "ports", "environment", "commandKinds"]) {
381
385
  const d = drift[section];
382
386
  if (!d) continue;
383
- for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${m}`);
384
- for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${m}`);
387
+ for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
388
+ for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
385
389
  }
386
390
  process.exit(drift.ok ? 0 : 1);
387
391
  }
@@ -1178,23 +1182,24 @@ async function ensureVendorAuth(rl, vendorKey) {
1178
1182
  let status = readLoginStatus(vendorKey);
1179
1183
  if (!status.loggedIn) {
1180
1184
  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).`));
1181
- if (!(await confirm(rl, "Log in now?"))) return { ok: false };
1182
- runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
1185
+ if (!(await confirm(rl, "Log in now?"))) { console.log(c.red(`✗ Sign-in canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
1186
+ const loginOk = runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
1187
+ 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 }; }
1183
1188
  status = readLoginStatus(vendorKey);
1184
- if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}.`)); return { ok: false }; }
1189
+ 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 }; }
1185
1190
  }
1186
1191
  console.log(c.green(`✓ Logged in to ${vendor.cli.bin}${status.email ? ` as ${status.email}` : ""}${status.subscriptionType ? ` (${status.subscriptionType})` : ""}.`));
1187
1192
 
1188
1193
  if (vendor.plugin) {
1189
1194
  step("OpenClaw plugin");
1190
1195
  const version = parseOpenclawVersion(runQuiet(openclawCmd(), ["--version"]).out);
1191
- if (!versionAtLeast(version, vendor.plugin.minOpenclaw)) {
1192
- console.log(c.yellow(`OpenClaw ${version ? version.join(".") : "(unknown version)"} is older than the ${vendor.plugin.minOpenclaw} this vendor's plugin needs.`));
1193
- if (!(await confirm(rl, "Update OpenClaw now (npm update -g openclaw)?"))) return { ok: false };
1194
- if (!runInteractive("npm", ["update", "-g", "openclaw"])) {
1195
- 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."));
1196
- return { ok: false };
1197
- }
1196
+ if (!versionAtLeast(version, PINNED_OPENCLAW_VERSION)) {
1197
+ const repaired = await repairOpenclaw({
1198
+ command: openclawCmd(),
1199
+ vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]),
1200
+ isTTY: Boolean(input.isTTY), ask: (prompt) => rl.question(prompt),
1201
+ });
1202
+ if (!repaired.ok) return { ok: false };
1198
1203
  }
1199
1204
  if (!runQuiet(openclawCmd(), ["plugins", "inspect", vendor.plugin.id]).ok) {
1200
1205
  console.log(c.dim(`Installing ${vendor.plugin.spec}...`));
@@ -1204,6 +1209,12 @@ async function ensureVendorAuth(rl, vendorKey) {
1204
1209
  }
1205
1210
  runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
1206
1211
  }
1212
+ const checks = verifyOpenclaw({ command: openclawCmd(), vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]) });
1213
+ const failed = checks.filter((check) => !check.ok);
1214
+ if (failed.length) {
1215
+ for (const check of failed) console.log(c.red(`✗ ${check.message} Fix: ${check.fix}`));
1216
+ return { ok: false };
1217
+ }
1207
1218
  console.log(c.green(`✓ OpenClaw's ${vendor.plugin.id} plugin is ready.`));
1208
1219
  }
1209
1220
  if (vendor.credential.kind === "minted-key") {
@@ -1225,11 +1236,24 @@ async function ensureVendorAuth(rl, vendorKey) {
1225
1236
  return { ok: true, email: status.email };
1226
1237
  }
1227
1238
 
1239
+ function hasUsableOpenclawAuthProfile(provider) {
1240
+ // Same OpenClaw command and JSON shape used by health's login-expiry check.
1241
+ const result = runQuiet(openclawCmd(), ["models", "auth", "list", "--json"]);
1242
+ if (!result.ok) return false;
1243
+ const profiles = parseOpenclawAuthProfiles(result.stdout);
1244
+ return profiles?.some((profile) => {
1245
+ if (profile.provider !== provider) return false;
1246
+ if (profile.expiresAt == null) return true;
1247
+ const expires = Date.parse(profile.expiresAt);
1248
+ return Number.isFinite(expires) && expires > Date.now();
1249
+ }) ?? false;
1250
+ }
1251
+
1228
1252
  function catalogModelsFor(provider) {
1229
1253
  return parseCatalogModels(runQuiet(openclawCmd(), ["models", "list", "--refresh"]).out, provider);
1230
1254
  }
1231
1255
 
1232
- /** One real, one-token completion through OpenClaw -- the only proof a credential actually works. */
1256
+ /** One real, one-token completion through OpenClaw to test a model. */
1233
1257
  // Why the last probeWorker() call failed, in the vendor's words when it said.
1234
1258
  let lastProbeFailure = null;
1235
1259
  function probeWorker(provider, model) {
@@ -1279,8 +1303,8 @@ function reapProbeSandbox(stateDir) {
1279
1303
  /**
1280
1304
  * OpenClaw's own provider login, for the vendors whose plugin wants one on
1281
1305
  * top of the vendor CLI's login (Claude doesn't: OpenClaw reuses the CLI
1282
- * session directly). Only ever run when a probe or catalog lookup has
1283
- * already shown it's needed -- never preemptively.
1306
+ * session directly). Run when the catalog or auth-profile check shows
1307
+ * linking is needed, never preemptively.
1284
1308
  */
1285
1309
  function openclawProviderLogin(vendor) {
1286
1310
  const provider = vendor.credential.loginProvider ?? vendor.provider;
@@ -1295,8 +1319,9 @@ function openclawProviderLogin(vendor) {
1295
1319
  // One list in ~/.config/nomarmy/agents.yml (lib/agents.mjs): the local
1296
1320
  // model, api keys, and individual subscriptions. `add` walks through what
1297
1321
  // each kind needs -- a key registered with OpenClaw, or the vendor's own
1298
- // login -- and proves it with a real test call before saving. Changes apply
1299
- // to the next job; the MCP server re-reads the file when it changes.
1322
+ // login -- and tests the selected model before saving, with an explicit
1323
+ // opt-in to save after a failed model call. Changes apply to the next job;
1324
+ // the MCP server re-reads the file when it changes.
1300
1325
 
1301
1326
  function loadAgentsOrExit() {
1302
1327
  try { return loadAgents(globalConfigDir()); }
@@ -1514,6 +1539,14 @@ async function addApiAgent(rl, agents) {
1514
1539
  console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job.`));
1515
1540
  }
1516
1541
 
1542
+ const OWNER_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
1543
+ function validOwnerEmail(email) { return typeof email === "string" && OWNER_EMAIL_RE.test(email); }
1544
+ function gitUserEmail() {
1545
+ const result = spawnSync("git", ["config", "user.email"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1546
+ const email = result.status === 0 ? result.stdout.trim() : "";
1547
+ return validOwnerEmail(email) ? email : "";
1548
+ }
1549
+
1517
1550
  const SUBSCRIPTION_AGENT_DEFAULT_NAMES = { claude: "claude", codex: "codex", meta: "muse" };
1518
1551
 
1519
1552
  async function addSubscriptionAgent(rl, agents) {
@@ -1537,7 +1570,7 @@ async function addSubscriptionAgent(rl, agents) {
1537
1570
  }
1538
1571
 
1539
1572
  const auth = await ensureVendorAuth(rl, vendorKey);
1540
- if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); return; }
1573
+ if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); process.exitCode = 1; return; }
1541
1574
 
1542
1575
  console.log(`\n${c.bold("→")} Models`);
1543
1576
  const needsOpenclawLogin = vendor.credential.kind === "openclaw-login";
@@ -1545,8 +1578,19 @@ async function addSubscriptionAgent(rl, agents) {
1545
1578
  let models = catalogModelsFor(vendor.provider);
1546
1579
  if (!models.length && needsOpenclawLogin) {
1547
1580
  linkedOpenclaw = openclawProviderLogin(vendor);
1581
+ if (!linkedOpenclaw) { console.log(c.red(`✗ Sign-in failed or was canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); process.exitCode = 1; return; }
1548
1582
  models = catalogModelsFor(vendor.provider);
1549
1583
  }
1584
+ // Catalog entries can be cached, and a particular model can refuse a
1585
+ // working login. Check OpenClaw's auth profile before asking for details.
1586
+ if (needsOpenclawLogin && !hasUsableOpenclawAuthProfile(vendor.provider)) {
1587
+ if (linkedOpenclaw || !openclawProviderLogin(vendor) || !hasUsableOpenclawAuthProfile(vendor.provider)) {
1588
+ console.log(c.red(`✗ Sign-in has no usable auth profile. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`));
1589
+ process.exitCode = 1;
1590
+ return;
1591
+ }
1592
+ linkedOpenclaw = true;
1593
+ }
1550
1594
  // The agent is the account; the model is only a default. Roles pick
1551
1595
  // their own model (or "auto" for the General to choose per job).
1552
1596
  if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}`));
@@ -1554,27 +1598,29 @@ async function addSubscriptionAgent(rl, agents) {
1554
1598
  const pick = (await rl.question(c.bold(`Default model, optional${models.length ? " (a number or an id)" : ""}; blank = pick per role: `))).trim();
1555
1599
  const model = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick || null;
1556
1600
  if (pick && !model) throw new Error(`Not a valid choice: "${pick}".`);
1557
- // The test call needs some model; it proves the login, not the choice.
1601
+ // The test call needs some model; it checks the model, not the login.
1558
1602
  const probeModel = model ?? models[0] ?? vendor.defaultModel;
1559
1603
 
1560
- const knownOwners = [...new Set(Object.values(agents).filter((a) => a.kind === "subscription").map((a) => a.owner))];
1561
- const ownerDefault = auth.email ?? (knownOwners.length === 1 ? knownOwners[0] : "");
1562
- const owner = (await rl.question(c.bold(`Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `))).trim() || ownerDefault;
1563
- if (!owner) throw new Error("An owner is required -- every job on this agent must name them in on_behalf_of.");
1604
+ const ownerDefault = validOwnerEmail(auth.email) ? auth.email : gitUserEmail();
1605
+ const owner = await askUntilValid(rl, `Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `, {
1606
+ allowEmpty: Boolean(ownerDefault), fallback: ownerDefault, pattern: OWNER_EMAIL_RE,
1607
+ invalidMessage: "enter an email address (one @, no spaces).",
1608
+ });
1564
1609
 
1565
1610
  const name = await askAgentName(rl, agents, SUBSCRIPTION_AGENT_DEFAULT_NAMES[vendorKey] ?? vendorKey);
1566
1611
  if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
1567
1612
 
1568
1613
  console.log(`\n${c.bold("→")} Test call`);
1569
- let works = probeModel ? probeWorker(vendor.provider, probeModel) : false;
1570
- if (!works && needsOpenclawLogin && !linkedOpenclaw && probeModel) {
1571
- openclawProviderLogin(vendor);
1572
- works = probeWorker(vendor.provider, probeModel);
1573
- }
1614
+ const works = probeModel ? probeWorker(vendor.provider, probeModel) : false;
1574
1615
  if (works) console.log(c.green(`✓ ${vendor.provider}/${probeModel} answered a real test prompt.`));
1575
1616
  else {
1576
- 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."));
1577
- if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
1617
+ 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."));
1618
+ console.log(c.dim(`You can retry later with \`nomarmy agents update ${name} --probe\`.`));
1619
+ if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) {
1620
+ console.log(c.dim("Stopped; nothing was written."));
1621
+ process.exitCode = 1;
1622
+ return;
1623
+ }
1578
1624
  }
1579
1625
  const written = saveAgents({ ...agents, [name]: { kind: "subscription", provider: vendor.provider, owner, ...(model ? { model } : {}) } });
1580
1626
  savedAgentMessage(name, written);
@@ -1587,6 +1633,7 @@ async function cmdAgentsAddJson() {
1587
1633
  const kind = value("kind") ?? (AGENT_KINDS.includes(argv[2]) ? argv[2] : null);
1588
1634
  if (!name || !kind) throw new Error(`--json requires --name <agent> and --kind <${AGENT_KINDS.join("|")}>, plus that kind's fields (see \`nomarmy help\`).`);
1589
1635
  if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
1636
+ if (kind === "subscription" && value("owner") !== null && !validOwnerEmail(value("owner"))) throw new Error("--owner must be an email address (one @, no spaces).");
1590
1637
  const agent = { kind };
1591
1638
  const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
1592
1639
  if (kind === "local") {
@@ -1612,6 +1659,43 @@ async function cmdAgentsAddJson() {
1612
1659
  return out({ written: agentsConfigPath(globalConfigDir()), name, agent: saved, ...(kind === "api" ? { registered, mcpUpdated } : {}) });
1613
1660
  }
1614
1661
 
1662
+ // A probe-only update checks credentials without changing agents.yml.
1663
+ function probeConfiguredAgent(name, agent) {
1664
+ const provider = agentProviderId(agent);
1665
+ let model = agent.model;
1666
+ let source = "";
1667
+ if (!model && provider) {
1668
+ const role = agentAssignments()[name]?.roles.find((r) => r.model && r.model !== "auto");
1669
+ if (role) { model = role.model; source = `${role.role}'s model`; }
1670
+ if (!model) {
1671
+ const vendor = Object.values(SUBSCRIPTION_VENDORS).find((v) => v.provider === provider);
1672
+ if (vendor?.defaultModel) { model = vendor.defaultModel; source = "vendor default"; }
1673
+ }
1674
+ if (!model) {
1675
+ model = catalogModelsFor(provider)[0];
1676
+ if (model) source = "first catalog model";
1677
+ }
1678
+ }
1679
+ if (!provider || !model) {
1680
+ console.log(c.red(`✗ ${name} ${provider ?? agent.kind}/${model ?? "no model"} no model available; set one with \`nomarmy agents update ${name} --model <m>\``));
1681
+ process.exitCode = 1;
1682
+ return;
1683
+ }
1684
+ const selected = `${provider}/${model}${source ? ` (${source})` : ""}`;
1685
+ const start = performance.now();
1686
+ const ok = probeWorker(provider, model);
1687
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
1688
+ console.log(ok ? c.green(`✓ ${name} ${selected} answered in ${elapsed}s`)
1689
+ : c.red(`✗ ${name} ${selected} failed: ${lastProbeFailure ?? "no answer"}`));
1690
+ if (!ok) process.exitCode = 1;
1691
+ }
1692
+
1693
+ function cmdAgentsProbeAll() {
1694
+ const agents = fileAgentsOrExit();
1695
+ if (!Object.keys(agents).length) { console.log("No configured agents to probe."); return; }
1696
+ for (const [name, agent] of Object.entries(agents)) probeConfiguredAgent(name, agent);
1697
+ }
1698
+
1615
1699
  // --- update ---
1616
1700
 
1617
1701
  // Kind, provider and owner are fixed: changing any of them is a different
@@ -1620,7 +1704,8 @@ async function cmdAgentsAddJson() {
1620
1704
  // test call `add` makes (interactive always; --json only with --probe,
1621
1705
  // since it spends a real request on the subscription).
1622
1706
  async function cmdAgentsUpdate() {
1623
- const name = argv[2];
1707
+ const name = argv[2]?.startsWith("--") ? null : argv[2];
1708
+ if (!name && flag("probe")) return cmdAgentsProbeAll();
1624
1709
  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]");
1625
1710
  const agents = fileAgentsOrExit();
1626
1711
  const current = Object.prototype.hasOwnProperty.call(agents, name) ? agents[name] : name === "local" ? { ...BUILTIN_LOCAL_AGENT } : undefined;
@@ -1646,7 +1731,7 @@ async function cmdAgentsUpdate() {
1646
1731
  throw new Error("Kind, provider and owner can't be changed -- that's a different agent. Use `nomarmy agents add`.");
1647
1732
  }
1648
1733
  probe = flag("probe") && current.kind === "subscription";
1649
- if (!Object.keys(changes).length) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
1734
+ if (!Object.keys(changes).length && !probe) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
1650
1735
  } else {
1651
1736
  if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or the flags to change (e.g. --max-concurrent 3).");
1652
1737
  const rl = createInterface({ input, output });
@@ -1693,6 +1778,7 @@ async function cmdAgentsUpdate() {
1693
1778
  if (!Object.keys(changes).length) { console.log(c.dim("\nNothing changed.")); return; }
1694
1779
  }
1695
1780
 
1781
+ if (probe && !Object.keys(changes).length) { probeConfiguredAgent(name, current); return; }
1696
1782
  if (probe && changes.model && !probeWorker(current.provider, changes.model)) {
1697
1783
  out({ error: `a real test prompt to ${current.provider}/${changes.model} didn't come back -- nothing was written` });
1698
1784
  process.exit(1);
@@ -2319,9 +2405,15 @@ function armyLayerFlag(fallback = "global") {
2319
2405
  return chosen[0] ?? fallback;
2320
2406
  }
2321
2407
 
2322
- function loadArmyForCli() {
2408
+ function loadArmyForCli({ globalOnly = false } = {}) {
2323
2409
  const agents = loadAgentsOrExit().agents;
2324
- const loaded = loadArmy({ projectDir: repoDir });
2410
+ const loaded = globalOnly
2411
+ ? (() => {
2412
+ const filePath = armyLayerPath("global", { projectDir: repoDir });
2413
+ const army = readArmyFile(filePath, { armyOnly: true });
2414
+ return { ...mergeArmy([{ layer: "global", army }]), layers: [{ layer: "global", path: filePath, exists: fs.existsSync(filePath), hasArmy: Boolean(army) }] };
2415
+ })()
2416
+ : loadArmy({ projectDir: repoDir });
2325
2417
  const usageSnapshots = readUsageSnapshots(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"));
2326
2418
  return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel, usageSnapshots, agentProviderId }) };
2327
2419
  }
@@ -2349,11 +2441,23 @@ function usageLine(usage, indent) {
2349
2441
  return usage.level === "over" ? c.red(`${text} (at the limit)`) : usage.level === "high" ? c.yellow(text) : c.dim(text);
2350
2442
  }
2351
2443
 
2444
+ function repositoryHere(dir) {
2445
+ let current = path.resolve(dir);
2446
+ while (true) {
2447
+ if (fs.existsSync(path.join(current, ".git"))) return true;
2448
+ const parent = path.dirname(current);
2449
+ if (parent === current) return false;
2450
+ current = parent;
2451
+ }
2452
+ }
2453
+
2352
2454
  async function cmdArmyShow() {
2353
- const { summary } = loadArmyForCli();
2455
+ const inRepository = repositoryHere(repoDir);
2456
+ const { summary } = loadArmyForCli({ globalOnly: !inRepository });
2354
2457
  if (json) return out(summary);
2355
2458
  const g = summary.general;
2356
- console.log(c.bold("🪖 nomArmy") + c.dim(` (${repoDir})`));
2459
+ console.log(c.bold("🪖 nomArmy") + (inRepository ? c.dim(` (${repoDir})`) : ""));
2460
+ if (!inRepository) console.log(c.dim("no repository here: showing global settings"));
2357
2461
  console.log(`\n${c.bold("General")} ${g.agent ? agentCell(g.agent, g.agentRunsOn) : ""}${g.setBy ? c.dim(` [${g.setBy}]`) : ""}`);
2358
2462
  console.log(c.dim(` ${g.who}`));
2359
2463
  for (const line of g.responsibilities) console.log(c.dim(` - ${line}`));
@@ -2383,7 +2487,7 @@ async function cmdArmyShow() {
2383
2487
  }
2384
2488
  }
2385
2489
  console.log(`\n${c.bold("Layers")} ${c.dim("(later ones win)")}`);
2386
- for (const layer of summary.layers) {
2490
+ for (const layer of summary.layers.filter((entry) => inRepository || entry.layer === "global")) {
2387
2491
  const state = layer.hasArmy ? c.green("● army section") : layer.exists ? c.dim("○ file exists, no army section") : c.dim("○ no file");
2388
2492
  console.log(` ${layer.layer.padEnd(8)} ${state.padEnd(40)} ${c.dim(layer.path)}`);
2389
2493
  }
@@ -2433,14 +2537,31 @@ async function cmdArmyInit() {
2433
2537
  }
2434
2538
  }
2435
2539
 
2540
+ function armyPositionals() {
2541
+ const result = [];
2542
+ const args = argv.slice(2);
2543
+ for (let i = 0; i < args.length; i++) {
2544
+ const arg = args[i];
2545
+ if (["--repo", "--agent", "--model"].includes(arg)) {
2546
+ if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
2547
+ i++;
2548
+ } else if (["--global", "--project", "--local", "--json", "--no-check"].includes(arg)) {
2549
+ continue;
2550
+ } else if (arg.startsWith("--")) {
2551
+ throw new Error(`Unknown army option ${arg}`);
2552
+ } else result.push(arg);
2553
+ }
2554
+ return result;
2555
+ }
2556
+
2436
2557
  async function cmdArmyAssign() {
2437
- // Positionals only: argv also holds flags, and `--json` must never be read as a model.
2438
- const positional = argv.slice(2);
2439
- const firstFlag = positional.findIndex((a) => a.startsWith("--"));
2440
- const [roleName, agentName, model] = firstFlag === -1 ? positional : positional.slice(0, firstFlag);
2441
- if (!roleName || !agentName) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
2558
+ const positional = armyPositionals();
2559
+ const [roleName, agentName, model] = positional;
2560
+ if (!roleName || !agentName || positional.length > 3) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
2442
2561
  const layer = armyLayerFlag("global");
2443
2562
  const filePath = armyLayerPath(layer, { projectDir: repoDir });
2563
+ const defined = Object.prototype.hasOwnProperty.call(loadArmy({ projectDir: repoDir }).army.roles, roleName);
2564
+ if (!defined) throw new Error(`Role "${roleName}" is not defined in any army roster. Run nomarmy army init first.`);
2444
2565
  const target = parseTargetSpec(agentName, model);
2445
2566
  const check = flag("no-check") ? { status: "skipped" } : checkRoleModel(agentName, model);
2446
2567
  if (check.status === "failed") {
@@ -2496,8 +2617,9 @@ function checkRoleModel(agentName, model) {
2496
2617
  // Which agent the General is. Global or local only: it describes the
2497
2618
  // person's own coordinator session, which a committed project file can't know.
2498
2619
  async function cmdArmyGeneral() {
2499
- const agentName = argv[2];
2500
- if (!agentName) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
2620
+ const positional = armyPositionals();
2621
+ const [agentName] = positional;
2622
+ if (!agentName || positional.length !== 1) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
2501
2623
  const layer = armyLayerFlag("global");
2502
2624
  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.");
2503
2625
  const agents = loadAgentsOrExit().agents;
@@ -2583,7 +2705,7 @@ function pidIsAlive(pid) {
2583
2705
  try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
2584
2706
  }
2585
2707
 
2586
- function collectJobs({ recent = 8 } = {}) {
2708
+ function collectJobs({ recent = 8, runId = null, projectDir = null } = {}) {
2587
2709
  const root = jobsRootDir();
2588
2710
  let names = [];
2589
2711
  try { names = fs.readdirSync(root); } catch { return { running: [], recent: [] }; }
@@ -2591,6 +2713,10 @@ function collectJobs({ recent = 8 } = {}) {
2591
2713
  const dir = path.join(root, name);
2592
2714
  const status = readJsonSafe(path.join(dir, "status.json")) ?? {};
2593
2715
  const meta = readJsonSafe(path.join(dir, "metadata.json"));
2716
+ const lease = readJsonSafe(path.join(agentStateRoot(), "leases", `${name}.json`));
2717
+ const jobRunId = meta?.labels?.runId ?? lease?.runId ?? null;
2718
+ const jobProjectDir = meta?.projectDir ?? lease?.repo ?? null;
2719
+ if ((runId && jobRunId !== runId) || (projectDir && (!jobProjectDir || path.resolve(jobProjectDir) !== projectDir))) return null;
2594
2720
  const started = Date.parse(status.startedAt ?? meta?.startedAt ?? "") || fs.statSync(dir).mtimeMs;
2595
2721
  const running = status.state === "running" && pidIsAlive(status.serverPid);
2596
2722
  return {
@@ -2603,7 +2729,7 @@ function collectJobs({ recent = 8 } = {}) {
2603
2729
  heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
2604
2730
  started, dir,
2605
2731
  };
2606
- }).sort((a, b) => b.started - a.started);
2732
+ }).filter(Boolean).sort((a, b) => b.started - a.started);
2607
2733
  return { running: jobs.filter((j) => j.running), recent: jobs.filter((j) => !j.running).slice(0, recent) };
2608
2734
  }
2609
2735
 
@@ -2636,6 +2762,13 @@ async function streamJobEvents() {
2636
2762
  // background command (reported only on exit) was never told jobs had
2637
2763
  // finished, and eight of these streams were left running for days.
2638
2764
  const untilDone = flag("until-done");
2765
+ const runId = value("run");
2766
+ const projectDir = flag("repo") ? repoDir : null;
2767
+ if (!runId && !projectDir) {
2768
+ const detail = "watching every job on this machine; use --wait <ids> or --run <id> to scope the watch";
2769
+ if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "scope", detail }));
2770
+ else console.log(detail);
2771
+ }
2639
2772
  const idleLimitMs = Math.max(1, Number(value("idle-minutes", "30")) || 30) * 60000;
2640
2773
  let idleSinceMs = Date.now(), sawRunning = false;
2641
2774
  const seen = new Map();
@@ -2646,7 +2779,7 @@ async function streamJobEvents() {
2646
2779
  process.on("SIGINT", () => process.exit(0));
2647
2780
  let first = true;
2648
2781
  for (;;) {
2649
- const { running, recent } = collectJobs({ recent: 20 });
2782
+ const { running, recent } = collectJobs({ recent: 20, runId, projectDir });
2650
2783
  const now = new Map([...running, ...recent].map((j) => [j.jobId, j]));
2651
2784
  for (const j of running) {
2652
2785
  const prev = seen.get(j.jobId);
@@ -2657,10 +2790,13 @@ async function streamJobEvents() {
2657
2790
  const j = now.get(id);
2658
2791
  if (prev.running && j && !j.running) emit("finished", j, `${j.phase} after ${fmtSeconds(j.elapsedSeconds)}`);
2659
2792
  }
2793
+ // A finished record can briefly lack its run label after its lease is removed.
2794
+ // Keep the running snapshot until the stamped record becomes visible.
2795
+ const pending = (runId || projectDir) ? [...seen].filter(([id, j]) => j.running && !now.has(id)) : [];
2660
2796
  seen.clear();
2661
- for (const [id, j] of now) seen.set(id, j);
2797
+ for (const [id, j] of [...now, ...pending]) seen.set(id, j);
2662
2798
  first = false;
2663
- if (running.length) { sawRunning = true; idleSinceMs = Date.now(); }
2799
+ if (running.length || pending.length) { sawRunning = true; idleSinceMs = Date.now(); }
2664
2800
  else if (untilDone && sawRunning) {
2665
2801
  if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "done", detail: "every job seen running has finished" }));
2666
2802
  else console.log(`${new Date().toLocaleTimeString()} done every job seen running has finished`);
@@ -2677,65 +2813,60 @@ async function streamJobEvents() {
2677
2813
 
2678
2814
  const commitSha = (commit) => (typeof commit === "string" ? commit : typeof commit?.sha === "string" ? commit.sha : null);
2679
2815
 
2680
- /** Wait for one job in the shared, cross-session state directory. */
2816
+ /** Wait for selected jobs in the shared, cross-session state directory. */
2681
2817
  async function waitForJobCli() {
2682
- const requested = value("wait");
2683
- const jobId = requested ? path.basename(requested) : null;
2818
+ const waitIndex = argv.indexOf("--wait");
2819
+ const requested = [];
2820
+ for (let i = waitIndex + 1; i < argv.length && !argv[i].startsWith("--"); i++) requested.push(...argv[i].split(","));
2821
+ const jobIds = [...new Set(requested)];
2684
2822
  const timeoutSeconds = Number(value("timeout", "1800"));
2685
- if (!jobId || jobId !== requested) {
2686
- if (json) out({ error: "--wait needs a job id" });
2687
- else console.error("nomarmy jobs: --wait needs a job id");
2823
+ const errorOut = (message) => {
2824
+ if (json) out({ error: message });
2825
+ else console.error(`nomarmy jobs: ${message}`);
2688
2826
  process.exitCode = 2;
2689
- return;
2690
- }
2691
- if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
2692
- if (json) out({ error: "--timeout must be a non-negative number of seconds" });
2693
- else console.error("nomarmy jobs: --timeout must be a non-negative number of seconds");
2694
- process.exitCode = 2;
2695
- return;
2696
- }
2697
- const jobDir = path.join(jobsRootDir(), jobId);
2698
- const lease = path.join(agentStateRoot(), "leases", `${jobId}.json`);
2699
- if (!fs.existsSync(jobDir) && !fs.existsSync(lease)) {
2700
- if (json) out({ error: `unknown job id: ${jobId}` });
2701
- else console.error(`nomarmy jobs: unknown job id: ${jobId}`);
2702
- process.exitCode = 2;
2703
- return;
2827
+ };
2828
+ if (!jobIds.length) return errorOut("--wait needs a job id");
2829
+ if (jobIds.some((id) => !id || path.basename(id) !== id || id === "." || id === "..")) return errorOut("--wait needs valid job ids");
2830
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) return errorOut("--timeout must be a non-negative number of seconds");
2831
+ for (const jobId of jobIds) {
2832
+ if (!fs.existsSync(path.join(jobsRootDir(), jobId)) && !fs.existsSync(path.join(agentStateRoot(), "leases", `${jobId}.json`))) {
2833
+ return errorOut(`unknown job id: ${jobId}`);
2834
+ }
2704
2835
  }
2705
2836
  const deadline = Date.now() + timeoutSeconds * 1000;
2706
- for (;;) {
2707
- const status = readJsonSafe(path.join(jobDir, "status.json")) ?? {};
2708
- const meta = readJsonSafe(path.join(jobDir, "metadata.json")) ?? {};
2709
- if (status.state === "finished" || meta.outcome) {
2710
- const issues = Array.isArray(meta.issues) ? meta.issues : Array.isArray(status.issues) ? status.issues : [];
2711
- const result = {
2712
- jobId,
2713
- outcome: meta.outcome ?? status.outcome ?? null,
2714
- coordinatorStatus: meta.coordinatorStatus ?? status.coordinatorStatus ?? null,
2715
- branch: meta.branch ?? status.branch ?? null,
2716
- // A job that made no commit has commit: { created: false, sha: null }; only a sha is a commit.
2717
- commit: commitSha(meta.commit) ?? commitSha(status.commit),
2718
- issues,
2719
- };
2720
- if (json) out(result);
2721
- else {
2722
- const firstIssue = issues[0];
2723
- const issueText = firstIssue == null ? null : typeof firstIssue === "string" ? firstIssue : firstIssue.message ?? JSON.stringify(firstIssue);
2724
- console.log([result.jobId, result.outcome ?? "unknown", result.coordinatorStatus ?? "unknown",
2725
- result.branch ? `branch=${result.branch}` : null, result.commit ? `commit=${result.commit}` : null,
2726
- issueText ? `issue=${issueText}` : null].filter(Boolean).join(" "));
2837
+ const results = await Promise.all(jobIds.map(async (jobId) => {
2838
+ const jobDir = path.join(jobsRootDir(), jobId);
2839
+ for (;;) {
2840
+ const status = readJsonSafe(path.join(jobDir, "status.json")) ?? {};
2841
+ const meta = readJsonSafe(path.join(jobDir, "metadata.json")) ?? {};
2842
+ if (status.state === "finished" || meta.outcome) {
2843
+ const issues = Array.isArray(meta.issues) ? meta.issues : Array.isArray(status.issues) ? status.issues : [];
2844
+ const result = {
2845
+ jobId,
2846
+ outcome: meta.outcome ?? status.outcome ?? null,
2847
+ coordinatorStatus: meta.coordinatorStatus ?? status.coordinatorStatus ?? null,
2848
+ branch: meta.branch ?? status.branch ?? null,
2849
+ commit: commitSha(meta.commit) ?? commitSha(status.commit),
2850
+ issues,
2851
+ };
2852
+ if (json) out(result);
2853
+ else {
2854
+ const firstIssue = issues[0];
2855
+ const issueText = firstIssue == null ? null : typeof firstIssue === "string" ? firstIssue : firstIssue.message ?? JSON.stringify(firstIssue);
2856
+ console.log([result.jobId, result.outcome ?? "unknown", result.coordinatorStatus ?? "unknown",
2857
+ result.branch ? `branch=${result.branch}` : null, result.commit ? `commit=${result.commit}` : null,
2858
+ issueText ? `issue=${issueText}` : null].filter(Boolean).join(" "));
2859
+ }
2860
+ return result.coordinatorStatus === "complete" ? 0 : 1;
2727
2861
  }
2728
- process.exitCode = result.coordinatorStatus === "complete" ? 0 : 1;
2729
- return;
2730
- }
2731
- if (Date.now() >= deadline) {
2732
- if (json) out({ error: `timed out waiting for job ${jobId}` });
2733
- else console.error(`nomarmy jobs: timed out waiting for job ${jobId}`);
2734
- process.exitCode = 2;
2735
- return;
2862
+ if (Date.now() >= deadline) {
2863
+ errorOut(`timed out waiting for job ${jobId}`);
2864
+ return 2;
2865
+ }
2866
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(1, deadline - Date.now()))));
2736
2867
  }
2737
- await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(1, deadline - Date.now()))));
2738
- }
2868
+ }));
2869
+ process.exitCode = Math.max(...results);
2739
2870
  }
2740
2871
 
2741
2872
  /**
@@ -3019,7 +3150,23 @@ async function cmdDoctor() {
3019
3150
  }
3020
3151
  // Import lazily to avoid circular dependencies
3021
3152
  const { runDoctor } = await import("../lib/doctor.mjs");
3022
- await runDoctor({ json, exit: true, env: installEnv() });
3153
+ const vendors = configuredSubscriptionVendors(loadAgentsOrExit().agents);
3154
+ let checks;
3155
+ if (flag("fix")) {
3156
+ const repaired = await repairOpenclaw({
3157
+ command: openclawCmd(), vendors, yes: flag("yes"), isTTY: Boolean(input.isTTY),
3158
+ print: json ? console.error : console.log,
3159
+ ask: async (prompt) => {
3160
+ const rl = createInterface({ input, output: json ? process.stderr : output });
3161
+ try { return await rl.question(prompt); } finally { rl.close(); }
3162
+ },
3163
+ });
3164
+ checks = repaired.checks;
3165
+ 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" });
3166
+ } else {
3167
+ checks = verifyOpenclaw({ command: openclawCmd(), vendors });
3168
+ }
3169
+ await runDoctor({ json, exit: true, env: installEnv(), additionalChecks: checks });
3023
3170
  }
3024
3171
  commands.doctor = cmdDoctor;
3025
3172
  if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
package/install.sh CHANGED
@@ -101,27 +101,13 @@ fi
101
101
  # on a fresh macOS install nothing has initialized that VM yet at this point.
102
102
  if nomarmy_is_cloud; then need aws || { echo 'ERROR: the AWS CLI is required for cloud profiles.'; exit 1; }; fi
103
103
  "$ROOT/scripts/install-llama-cpp.sh" "$NOMARMY_PROFILE"
104
- # A pinned npm release, which npm checks against the registry's integrity
105
- # hash, rather than piping a remote installer script into bash.
106
- OPENCLAW_VERSION="${NOMARMY_OPENCLAW_VERSION:-2026.9.6}"
107
- if ! command -v openclaw >/dev/null 2>&1; then
108
- if ! node -e 'const [a, b] = process.versions.node.split(".").map(Number); process.exit((a === 24 && b >= 16) || (a === 26 && b >= 1) || a > 26 ? 0 : 1)'; then
109
- echo "ERROR: OpenClaw $OPENCLAW_VERSION needs Node 24.16+ or 26.1+; this is Node $(node -v)."
110
- echo ' Upgrade Node and re-run this installer, or install OpenClaw yourself first:'
111
- echo ' https://openclaw.ai (then re-run this installer)'
112
- exit 1
113
- fi
114
- echo "==> Installing OpenClaw $OPENCLAW_VERSION from npm"
115
- if [[ -w "$(npm prefix -g)" ]]; then
116
- npm install -g "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
117
- else
118
- # No writable global prefix: install for this user, as OpenClaw's own installer does.
119
- npm install -g --prefix "$HOME/.npm-global" "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
120
- # nomArmy finds it there on its own (lib/openclaw-path.mjs); your shell won't until PATH has it.
121
- echo "NOTE: OpenClaw is in ~/.npm-global/bin. nomArmy finds it there; to run openclaw yourself, add it to PATH:"
122
- echo ' echo '"'"'export PATH="$HOME/.npm-global/bin:$PATH"'"'"' >> ~/.profile'
123
- fi
124
- export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
104
+ # One shared installation policy; no dist-tag or environment override.
105
+ # Only this process's PATH is changed, never a shell rc file.
106
+ export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
107
+ if [[ -w "$(npm prefix -g)" ]]; then
108
+ node "$ROOT/scripts/install-openclaw.mjs"
109
+ else
110
+ node "$ROOT/scripts/install-openclaw.mjs" --prefix "$HOME/.npm-global"
125
111
  fi
126
112
  need openclaw
127
113
  if nomarmy_has_local_model; then openclaw plugins install @openclaw/llama-cpp-provider || true; fi
package/lib/admission.mjs CHANGED
@@ -13,7 +13,7 @@ import { readClaudeSessionTranscript } from "./claude-transcript.mjs";
13
13
  import { notify } from "./notify.mjs";
14
14
  import { readCodexRateLimits, recordUsageSnapshot, readUsageSnapshots, usageStatus } from "./usage-limits.mjs";
15
15
  import { projectDirProblem } from "./server-context.mjs";
16
- import { recentModelRefusal, refusedModelIn, recordModelRefusal, recordProbeSuccess, modelProven, modelRefusals } from "./health.mjs";
16
+ import { recentModelRefusal, refusedModelIn, recordModelRefusal, recordProbeSuccess, modelProven, modelRefusals, claimModelRefusalRetry } from "./health.mjs";
17
17
  import { writeLease, removeLease, liveLeases, liveSlots, acquireSlot } from "./slots.mjs";
18
18
  import { loadRun, runTotals, runAdmissionProblems, recordRunJob, detectUsageLimit } from "./runs.mjs";
19
19
  import { agentProviderId, hostToolsImplementProblem } from "./agents.mjs";
@@ -287,16 +287,30 @@ export function createJobRuntime(deps) {
287
287
  try { problem = hostToolsImplementProblem(j.agentName, agentsConfig().agents[j.agentName]); } catch { return; }
288
288
  if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
289
289
  });
290
- // A model its vendor refused on a job today, with nothing working on it
291
- // since, isn't sent another job (lib/health.mjs recentModelRefusal).
290
+ // Re-test stale refusals once per day before making an admission decision.
291
+ const now = deps.now?.() ?? Date.now();
292
+ const refusedJobs = new Map();
292
293
  jobs.forEach((j, i) => {
293
294
  if (j.mode === "verify" || !j.agentName || !j.model) return;
294
295
  let provider = null;
295
296
  try { provider = agentProviderId(agentsConfig().agents[j.agentName]); } catch { return; }
296
297
  if (!provider) return;
297
- const refusal = recentModelRefusal(stateRoot, `${provider}/${j.model}`);
298
- if (refusal) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${provider}/${j.model} was refused on an earlier job today and hasn't worked since, so this job wasn't sent. Use another model (the job's \`model\`, or \`nomarmy army assign\`); \`nomarmy army assign <role> ${j.agentName} ${j.model}\` re-tests it, and a passing test clears this.`);
298
+ const key = `${provider}/${j.model}`;
299
+ if (!recentModelRefusal(stateRoot, key, { now })) return;
300
+ if (!refusedJobs.has(key)) refusedJobs.set(key, { provider, model: j.model, jobs: [] });
301
+ refusedJobs.get(key).jobs.push(i);
299
302
  });
303
+ await Promise.all([...refusedJobs.entries()].map(async ([key, entry]) => {
304
+ const hadRefusal = Boolean(modelRefusals(stateRoot)[key]);
305
+ if (hadRefusal && deps.probeModel && claimModelRefusalRetry(stateRoot, key, { now })) {
306
+ try {
307
+ const result = await deps.probeModel({ provider: entry.provider, model: entry.model, stateRoot });
308
+ if (result.ok) { recordProbeSuccess(stateRoot, key, { now }); return; }
309
+ if (result.refused) recordModelRefusal(stateRoot, key, result.reason, { now });
310
+ } catch { /* A failed probe is inconclusive; keep the original refusal. */ }
311
+ }
312
+ for (const i of entry.jobs) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}model_not_found: ${key} was refused on an earlier job and hasn't worked since, so this job wasn't sent. Use another model or re-test with nomarmy army assign.`);
313
+ }));
300
314
  // A model that has never worked for its agent gets one test call first
301
315
  // (lib/model-probe.mjs): a refusal then costs seconds, not a whole job.
302
316
  // The server wires the probe; without one (tests), nothing is probed.
package/lib/connect.mjs CHANGED
@@ -24,6 +24,7 @@
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
27
28
  import { mcpBridgeLaunch } from "./wsl.mjs";
28
29
  import { toPosix } from "./path-utils.mjs";
29
30
  import { execFileSync } from "node:child_process";
@@ -432,6 +433,48 @@ export function portableServerLaunch({ nomarmyRoot, installDir = defaultInstallD
432
433
  return { serverPath, env: { ...derivePoolAuthEnvPlaceholders(configDir), ...deriveWorkerModelEnv(nomarmyRoot), ...env } };
433
434
  }
434
435
 
436
+ /** The on-disk server scripts registered by connect for this repository. */
437
+ export async function registeredMcpCopies({ projectDir = null, installDir = defaultInstallDir(), nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), run }) {
438
+ const copies = [];
439
+ const add = (target, scope, command, args = []) => {
440
+ let serverPath = null;
441
+ if (command === "node" && typeof args[0] === "string") serverPath = path.resolve(args[0]);
442
+ else if (command === PORTABLE_SERVER.command && args[0] === PORTABLE_SERVER.args[0]) {
443
+ serverPath = portableServerLaunch({ nomarmyRoot, installDir }).serverPath;
444
+ }
445
+ if (serverPath && path.basename(serverPath) === "server.mjs" && path.basename(path.dirname(serverPath)) === "mcp") copies.push({ target, scope, serverPath });
446
+ };
447
+ const output = async (cmd, args, cwd) => {
448
+ try {
449
+ const result = await run(cmd, args, { cwd, timeoutMs: 5000 });
450
+ return result?.ok ? result.stdout : null;
451
+ } catch { return null; }
452
+ };
453
+ const claude = (value) => {
454
+ if (!value || !value.includes(`${SERVER_NAME}:`)) return;
455
+ const scope = /^\s*Scope:\s*(User|Local|Project)\b/im.exec(value)?.[1]?.toLowerCase();
456
+ const command = /^\s*Command:\s*(\S+)\s*$/im.exec(value)?.[1];
457
+ const args = /^\s*Args:\s*(.*?)\s*$/im.exec(value)?.[1];
458
+ if (scope && command) add("claude", scope, command, args ? [args] : []);
459
+ };
460
+ claude(await output("claude", ["mcp", "get", SERVER_NAME], projectDir || undefined));
461
+ if (projectDir) claude(await output("claude", ["mcp", "get", SERVER_NAME], os.homedir()));
462
+ const codex = await output("codex", ["mcp", "get", SERVER_NAME, "--json"]);
463
+ if (codex) {
464
+ try {
465
+ const transport = JSON.parse(codex).transport;
466
+ if (transport) add("codex", "user", transport.command, transport.args);
467
+ } catch { /* no registration */ }
468
+ }
469
+ for (const [scope, configPath] of [["user", defaultCursorConfigPath()], ...(projectDir ? [["local", path.join(projectDir, ".cursor", "mcp.json")]] : [])]) {
470
+ try {
471
+ const entry = readCursorConfig(configPath).mcpServers?.[SERVER_NAME];
472
+ if (entry) add("cursor", scope === "local" && entry.command === PORTABLE_SERVER.command ? "project" : scope, entry.command, entry.args);
473
+ } catch { /* unreadable registration */ }
474
+ }
475
+ return [...new Map(copies.map((copy) => [`${copy.target}:${copy.scope}:${copy.serverPath}`, copy])).values()];
476
+ }
477
+
435
478
  /** Whether Claude Code also has nomArmy registered for every project. */
436
479
  export function claudeUserScoped(run = defaultRun) {
437
480
  try {
@@ -16,7 +16,7 @@ Before dispatching:
16
16
  - To run tests without changing anything, use mode: verify; it costs no model usage.
17
17
  - Mark an implement job stakes: high when a mistake would be costly (security, access control, personal or tenant data, data loss, money, irreversible changes), however small it is. It then needs a verification profile, keeps the revert check, and needs an independent review before you accept it: a scout on another vendor with reviews: <job id>.
18
18
  - Brief outcomes, not edits: a task, explicit acceptance criteria, and the tests that prove it. Put facts you've already resolved in evidence.
19
- - Prefer local_worker_start for anything longer than a few minutes. Right after, if you can run a background command, run \`nomarmy jobs --wait <job_id>\` in the background so you're told the moment it finishes and can tell the operator (for several jobs, \`nomarmy jobs --events --until-done\`, which exits when they've all finished); otherwise poll local_worker_status with wait_seconds. To read a finished job, use local_worker_status with report: true (the report, a scout's cited findings, the outcome, issues and commit); full: true is the whole record and rarely needed. Review scouts (reviews set, or a review-phase role) default to 20 minutes, other jobs to 10; pass timeout_seconds for more. Never run the plain \`nomarmy jobs --events\` stream as a background command: it only reports when it exits, so you'd never hear; it's for a monitor that reads each line. For a whole feature, use /feature (run_start keeps a run's jobs, spend and hours bounded).
19
+ - Prefer local_worker_start for anything longer than a few minutes. Right after, if you can run a background command, run \`nomarmy jobs --wait <job_id>\` in the background so you're told the moment it finishes and can tell the operator (for several jobs, \`nomarmy jobs --wait <id> <id> ...\`; for a whole run, \`nomarmy jobs --events --until-done --run <run-id>\`); otherwise poll local_worker_status with wait_seconds. To read a finished job, use local_worker_status with report: true (the report, a scout's cited findings, the outcome, issues and commit); full: true is the whole record and rarely needed. Review scouts (reviews set, or a review-phase role) default to 20 minutes, other jobs to 10; pass timeout_seconds for more. Never use unscoped \`--until-done\` when other sessions may have jobs. Never run the plain \`nomarmy jobs --events\` stream as a background command: it only reports when it exits, so you'd never hear; it's for a monitor that reads each line. For a whole feature, use /feature (run_start keeps a run's jobs, spend and hours bounded).
20
20
 
21
21
  Trust boundary:
22
22
  - A worker's four-line report is a claim; nomArmy's verified git record and independent verification are the evidence. A job isn't complete if its report is missing or malformed, its STATUS is partial or blocked, STATUS done lacks VERIFICATION pass, or its changes aren't committed by nomArmy.
package/lib/doctor.mjs CHANGED
@@ -553,7 +553,7 @@ export function evaluateChecks(facts) {
553
553
  export async function runDoctor(opts = {}) {
554
554
  const { json = false, exit = false, env = process.env } = opts;
555
555
  const facts = opts.facts ?? await collectFacts(env);
556
- const checks = evaluateChecks(facts);
556
+ const checks = [...evaluateChecks(facts), ...(opts.additionalChecks ?? [])];
557
557
  const allOk = checks.every((c) => c.ok);
558
558
  const result = { ok: allOk, checks };
559
559
 
package/lib/health.mjs CHANGED
@@ -27,18 +27,28 @@ import { providerConfigured, readOpenclawConfig } from "./openclaw-config.mjs";
27
27
  import { readUsageSnapshots, usageStatus } from "./usage-limits.mjs";
28
28
  import { freshnessIssues, readInstallVersions } from "./install-freshness.mjs";
29
29
 
30
+ import { PINNED_OPENCLAW_VERSION } from "./openclaw-install.mjs";
31
+
30
32
  const DAY = 86400000;
31
33
 
32
34
  /** Run a command asynchronously, bounded; resolves { ok, stdout }. Never rejects. */
33
- export function runBounded(cmd, args, { timeoutMs = 20000 } = {}) {
35
+ export function runBounded(cmd, args, { timeoutMs = 20000, cwd } = {}) {
34
36
  return new Promise((resolve) => {
35
- execFile(cmd, args, { encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => resolve({ ok: !error, stdout: stdout ?? "" }));
37
+ execFile(cmd, args, { encoding: "utf8", timeout: timeoutMs, cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => resolve({ ok: !error, stdout: stdout ?? "" }));
36
38
  });
37
39
  }
38
40
 
39
41
  const versionOf = (text) => /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ""))?.slice(1, 4).map(Number) ?? null;
40
42
  const older = (a, b) => { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] < b[i]; } return false; };
41
43
 
44
+ /** Read OpenClaw's auth-list JSON, tolerating text before the JSON envelope. */
45
+ export function parseOpenclawAuthProfiles(stdout) {
46
+ try {
47
+ const profiles = JSON.parse(stdout.slice(stdout.indexOf("{"))).profiles;
48
+ return Array.isArray(profiles) ? profiles : null;
49
+ } catch { return null; }
50
+ }
51
+
42
52
  /** OAuth profiles near or past expiry, from `openclaw models auth list --json`. */
43
53
  export function loginExpiryIssues(authJson, { now = Date.now(), warnDays = 7 } = {}) {
44
54
  const issues = [];
@@ -61,7 +71,7 @@ export function versionIssues({ installed, latest, plugins = [] }) {
61
71
  if (have && want && older(have, want)) {
62
72
  issues.push({ id: `openclaw-update:${want.join(".")}`, severity: "info", title: `OpenClaw ${want.join(".")} is out (you have ${have.join(".")})`,
63
73
  detail: "Its bundled model catalog lags new models; an old one made a working model look unavailable and budgeted new ones at the 32k fallback.",
64
- fix: "npm update -g openclaw && openclaw doctor --fix (when no nomArmy jobs are running)", short: "openclaw update" });
74
+ fix: older(have, versionOf(PINNED_OPENCLAW_VERSION)) ? "nomarmy doctor --fix (installs the tested OpenClaw release, when no nomArmy jobs are running)" : "No automatic upgrade: nomArmy keeps the tested release or a newer installed version", short: "openclaw update" });
65
75
  }
66
76
  for (const p of plugins) {
67
77
  const pv = versionOf(p.version);
@@ -162,7 +172,8 @@ export function recordProbeSuccess(stateRoot, model, { now = Date.now() } = {})
162
172
  // Models a vendor refused on a job, kept until a job or test call on them
163
173
  // works. A plan's model limit doesn't expire overnight (gpt-6-luna was
164
174
  // refused again days after the first time), so this isn't a 24-hour memory.
165
- // <stateRoot>/model-refusals.json: { "<provider>/<model>": { at, reason } }.
175
+ // <stateRoot>/model-refusals.json: { "<provider>/<model>": { at, reason, retriedAt? } }.
176
+ export const REFUSAL_RETRY_MS = 24 * 60 * 60 * 1000;
166
177
  const REFUSALS_FILE = "model-refusals.json";
167
178
  export function modelRefusals(stateRoot) {
168
179
  try { const data = JSON.parse(fs.readFileSync(path.join(stateRoot, REFUSALS_FILE), "utf8")); return data && typeof data === "object" && !Array.isArray(data) ? data : {}; } catch { return {}; }
@@ -173,6 +184,16 @@ function writeRefusals(stateRoot, data) {
173
184
  export function recordModelRefusal(stateRoot, model, reason = null, { now = Date.now() } = {}) {
174
185
  writeRefusals(stateRoot, { ...modelRefusals(stateRoot), [model]: { at: new Date(now).toISOString(), reason } });
175
186
  }
187
+ /** Claim a stale refusal before a retry so overlapping calls do not re-test it. */
188
+ export function claimModelRefusalRetry(stateRoot, model, { now = Date.now() } = {}) {
189
+ const data = modelRefusals(stateRoot);
190
+ const entry = data[model];
191
+ if (!entry || now - Date.parse(entry.at) < REFUSAL_RETRY_MS ||
192
+ (entry.retriedAt && now - Date.parse(entry.retriedAt) < REFUSAL_RETRY_MS)) return false;
193
+ data[model] = { ...entry, retriedAt: new Date(now).toISOString() };
194
+ writeRefusals(stateRoot, data);
195
+ return true;
196
+ }
176
197
  export function clearModelRefusal(stateRoot, model) {
177
198
  const data = modelRefusals(stateRoot);
178
199
  if (!(model in data)) return;
@@ -245,7 +266,8 @@ export async function runHealthChecks({ now = Date.now(), mode = "local", opencl
245
266
  install ? run("npm", ["view", "nomarmy", "dist-tags.alpha"], { timeoutMs: 15000 }) : null,
246
267
  ]);
247
268
  if (install) issues.push(...freshnessIssues({ ...install, latestVersion: nomarmyLatest?.ok ? nomarmyLatest.stdout : null }));
248
- if (auth.ok) { try { issues.push(...loginExpiryIssues(JSON.parse(auth.stdout.slice(auth.stdout.indexOf("{"))), { now })); } catch { /* unparseable: skip */ } }
269
+ const authProfiles = auth.ok ? parseOpenclawAuthProfiles(auth.stdout) : null;
270
+ if (authProfiles) issues.push(...loginExpiryIssues({ profiles: authProfiles }, { now }));
249
271
  const pluginVersion = /Version:\s*(\S+)/.exec(plugins.stdout ?? "")?.[1];
250
272
  if (version.ok) issues.push(...versionIssues({ installed: version.stdout, latest: latest.ok ? latest.stdout : null, plugins: pluginVersion ? [{ id: "codex", version: pluginVersion }] : [] }));
251
273
  if (agentsError) issues.push({ id: `config:agents:${agentsError}`, severity: "error", title: "agents.yml can't be loaded", detail: agentsError, fix: "nomarmy agents list (shows the problem)", short: "agents.yml broken" });
@@ -295,6 +317,18 @@ export function recordHealth(file, result, { now = Date.now() } = {}) {
295
317
  return toNotify;
296
318
  }
297
319
 
320
+ /** Read versions and harnesses only from MCP registrations that launch nomArmy. */
321
+ export async function readRegisteredInstall({ projectDir, run = runBounded, installDir } = {}) {
322
+ const { registeredMcpCopies } = await import("./connect.mjs");
323
+ const { loadHarnesses } = await import("./harnesses.mjs");
324
+ const registered = await registeredMcpCopies({ projectDir, run, installDir });
325
+ if (!registered.length) return null;
326
+ return { registrations: registered.map(({ target, scope, serverPath }) => {
327
+ const copyDir = path.dirname(path.dirname(serverPath));
328
+ return { target, scope, ...readInstallVersions(copyDir), copyHarnesses: Object.keys(loadHarnesses(path.join(copyDir, "harnesses")).harnesses).length };
329
+ }) };
330
+ }
331
+
298
332
  /**
299
333
  * Everything a check run needs, gathered the same way for the server's
300
334
  * periodic run and `nomarmy health`, then run and recorded to the shared
@@ -327,10 +361,7 @@ export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, n
327
361
  let autoPruned = null;
328
362
  if (ageMs !== null) { try { autoPruned = { ...pruneJobRuntime({ stateRoot, olderThanMs: ageMs, now }), olderThanHours: ageMs / 3600000 }; } catch { /* best-effort */ } }
329
363
  const mode = executionMode(env).mode;
330
- const { defaultInstallDir } = await import("./connect.mjs");
331
- const installDir = defaultInstallDir();
332
- const { loadHarnesses } = await import("./harnesses.mjs");
333
- const install = { ...readInstallVersions(installDir), copyHarnesses: Object.keys(loadHarnesses(path.join(installDir, "harnesses")).harnesses).length };
364
+ const install = await readRegisteredInstall({ projectDir });
334
365
  const result = await runHealthChecks({ now, mode, armySummary, agentsError, jobsRoot: path.join(stateRoot, "jobs"), pidAlive,
335
366
  agents, openclawConfig: readOpenclawConfig(), vendors: SUBSCRIPTION_VENDORS, modelsInUse, autoPruned, usageSnapshots: readUsageSnapshots(stateRoot), install });
336
367
  try {
@@ -76,12 +76,15 @@ export function compareVersions(a, b) {
76
76
  const valid = (v) => typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
77
77
 
78
78
  /** Health issues for a stale CLI, a stale installed copy, or a copy missing its harnesses. */
79
- export function freshnessIssues({ copyVersion = null, sourceVersion = null, latestVersion = null, copyHarnesses = null }) {
79
+ export function freshnessIssues({ copyVersion = null, sourceVersion = null, latestVersion = null, copyHarnesses = null, registrations = null, target = "claude", scope = "user" }) {
80
+ if (registrations) return registrations.flatMap((copy, index) => freshnessIssues({ ...copy, latestVersion: index === 0 ? latestVersion : null }));
81
+ const reconnect = `nomarmy connect ${target}${scope === "user" ? "" : ` --scope ${scope}`}`;
82
+ const fix = registrations === null && target === "claude" && scope === "user" ? "nomarmy connect claude (and codex, cursor), then restart those sessions" : `${reconnect}, then restart that session`;
80
83
  const issues = [];
81
84
  if (valid(copyVersion) && copyHarnesses === 0) {
82
85
  issues.push({ id: `nomarmy-harnesses:${copyVersion}`, severity: "error", title: "The nomArmy your coordinators run has no harnesses",
83
86
  detail: "Jobs match no harness, so they run in the plain base image: no dependencies, fake services, browser tests or artifacts.",
84
- fix: "nomarmy connect claude (and codex, cursor), then restart those sessions", short: "no harnesses" });
87
+ fix, short: "no harnesses" });
85
88
  }
86
89
  if (valid(latestVersion) && valid(sourceVersion) && compareVersions(sourceVersion, latestVersion) < 0) {
87
90
  const latest = latestVersion.trim();
@@ -92,7 +95,7 @@ export function freshnessIssues({ copyVersion = null, sourceVersion = null, late
92
95
  if (valid(sourceVersion) && valid(copyVersion) && compareVersions(copyVersion, sourceVersion) < 0) {
93
96
  issues.push({ id: `nomarmy-copy:${sourceVersion}`, severity: "warn", title: `Your coordinators run nomArmy ${copyVersion}, but ${sourceVersion} is installed`,
94
97
  detail: "Claude Code, Codex and Cursor run a copy of nomArmy that only `nomarmy connect` refreshes.",
95
- fix: "nomarmy connect claude (and codex, cursor), then restart those sessions", short: "nomarmy reconnect" });
98
+ fix, short: "nomarmy reconnect" });
96
99
  }
97
100
  return issues;
98
101
  }
@@ -0,0 +1,122 @@
1
+ // One tested release for every nomArmy-managed OpenClaw installation.
2
+ // Never follow npm's dist-tag or downgrade a newer installation.
3
+ import { spawnSync } from "node:child_process";
4
+ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast } from "./subscription-setup.mjs";
5
+
6
+ export const PINNED_OPENCLAW_VERSION = "2026.9.6";
7
+ export const OPENCLAW_INSTALL_ARGS = Object.freeze(["install", "-g", `openclaw@${PINNED_OPENCLAW_VERSION}`]);
8
+
9
+ export function runOpenclawCommand(command, args, { timeoutMs = command === "npm" ? 600000 : 60000 } = {}) {
10
+ const r = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs });
11
+ const timedOut = r.error?.code === "ETIMEDOUT";
12
+ return { ok: !timedOut && r.status === 0, stdout: r.stdout ?? "",
13
+ stderr: timedOut ? `Command timed out after ${timeoutMs} ms.` : r.stderr ?? "", timedOut };
14
+ }
15
+
16
+ export function openclawInstallPlan(installed, { prefix = null } = {}) {
17
+ const have = parseOpenclawVersion(installed);
18
+ if (versionAtLeast(have, PINNED_OPENCLAW_VERSION)) return [];
19
+ return [{
20
+ description: `${have ? "Upgrade" : "Install"} OpenClaw to ${PINNED_OPENCLAW_VERSION}`,
21
+ command: "npm",
22
+ args: [...OPENCLAW_INSTALL_ARGS, ...(prefix ? ["--prefix", prefix] : [])],
23
+ }];
24
+ }
25
+
26
+ export function configuredSubscriptionVendors(agents = {}, extra = []) {
27
+ return [...new Set([...extra, ...Object.values(agents)
28
+ .filter((a) => a?.kind === "subscription")
29
+ .map((a) => Object.keys(SUBSCRIPTION_VENDORS).find((key) => SUBSCRIPTION_VENDORS[key].provider === a.provider))])].filter(Boolean);
30
+ }
31
+
32
+ /** Read-only postflight using structured status and plugin compatibility data. */
33
+ function parseCommandJson(stdout) {
34
+ const start = (stdout ?? "").indexOf("{");
35
+ if (start < 0) throw new Error("No JSON object in command output");
36
+ return JSON.parse(stdout.slice(start));
37
+ }
38
+
39
+ export function verifyOpenclaw({ run = runOpenclawCommand, command = "openclaw", vendors = [] } = {}) {
40
+ const checks = [];
41
+ const version = run(command, ["--version"]);
42
+ const have = version.ok ? parseOpenclawVersion(version.stdout) : null;
43
+ checks.push({
44
+ id: "openclaw", ok: versionAtLeast(have, PINNED_OPENCLAW_VERSION),
45
+ message: have ? `OpenClaw ${have.join(".")}${have.join(".") !== PINNED_OPENCLAW_VERSION && versionAtLeast(have, PINNED_OPENCLAW_VERSION) ? " is newer than the tested release; left unchanged" : ""}.` : version.timedOut ? "OpenClaw version check timed out." : "OpenClaw version could not be verified.",
46
+ fix: `npm ${OPENCLAW_INSTALL_ARGS.join(" ")}`,
47
+ });
48
+ if (!have) return checks;
49
+ for (const key of [...new Set(vendors)]) {
50
+ const vendor = SUBSCRIPTION_VENDORS[key];
51
+ if (!vendor) throw new Error(`Unknown subscription vendor: ${key}`);
52
+ if (!vendor.plugin) continue;
53
+ const { id, spec } = vendor.plugin;
54
+ const inspected = run(command, ["plugins", "inspect", id, "--json"]);
55
+ let plugin;
56
+ try { plugin = parseCommandJson(inspected.stdout).plugin; } catch { /* Unverified output fails below. */ }
57
+ const pluginVersion = plugin?.builtWithOpenClawVersion === undefined ? plugin?.version : plugin.builtWithOpenClawVersion;
58
+ const installedVersion = have.join(".");
59
+ const ok = inspected.ok && plugin?.enabled === true && pluginVersion !== undefined && versionAtLeast(have, pluginVersion);
60
+ checks.push({
61
+ id: `openclaw-plugin:${id}`, ok,
62
+ message: ok ? `OpenClaw plugin ${id} ${pluginVersion} is ready (built for ${pluginVersion}; OpenClaw is ${installedVersion}).` : `OpenClaw plugin ${id} ${inspected.timedOut ? "check timed out." : plugin?.enabled === true && pluginVersion === undefined ? "version could not be verified." : `is missing, disabled, unreadable, or built for a newer OpenClaw than ${installedVersion}.`}`,
63
+ fix: `openclaw plugins install ${spec}`,
64
+ });
65
+ }
66
+ const status = run(command, ["update", "status", "--json"]);
67
+ let warnings;
68
+ try {
69
+ if (!status.ok) throw new Error("Status command failed");
70
+ const data = parseCommandJson(status.stdout);
71
+ warnings = data.migrationWarnings === undefined ? [] : data.migrationWarnings;
72
+ if (!Array.isArray(warnings) || warnings.some((warning) => typeof warning !== "string")) throw new Error("Invalid migration warnings");
73
+ } catch {
74
+ checks.push({ id: "openclaw-migrations", ok: false, message: status.timedOut ? "OpenClaw migration check timed out." : "OpenClaw migrations could not be verified. Run openclaw update status --json by hand.", fix: "openclaw update status --json" });
75
+ return checks;
76
+ }
77
+ checks.push({
78
+ id: "openclaw-migrations", ok: warnings.length === 0,
79
+ message: warnings.length ? warnings.join("\n") : "No pending OpenClaw migrations.",
80
+ fix: "openclaw update repair",
81
+ });
82
+ return checks;
83
+ }
84
+
85
+ /** Print the complete mutation plan before a single default-no consent gate. */
86
+ export async function repairOpenclaw({
87
+ run = runOpenclawCommand, command = "openclaw", vendors = [], prefix = null,
88
+ yes = false, isTTY = false, ask = async () => "", print = console.log,
89
+ } = {}) {
90
+ const version = run(command, ["--version"]);
91
+ const actions = openclawInstallPlan(version.ok ? version.stdout : null, { prefix });
92
+ print("Planned changes:");
93
+ for (const action of actions) print(` ${action.description}: ${action.command} ${action.args.join(" ")}`);
94
+ if (!actions.length) print(" None. The installed OpenClaw is not older than the tested release.");
95
+ print("Postflight checks (read-only):");
96
+ print(` ${command} --version`);
97
+ for (const key of [...new Set(vendors)]) {
98
+ const vendor = SUBSCRIPTION_VENDORS[key];
99
+ if (!vendor) throw new Error(`Unknown subscription vendor: ${key}`);
100
+ if (vendor.plugin) print(` ${command} plugins inspect ${vendor.plugin.id} --json`);
101
+ }
102
+ print(` ${command} update status --json`);
103
+ print(" Migration repairs are not run automatically. If needed, run openclaw update repair --yes separately.");
104
+ const changed = [];
105
+ if (actions.length && !yes && (!isTTY || !/^(y|yes)$/i.test((await ask("Apply these changes? [y/N] ")).trim()))) {
106
+ print(isTTY ? "No changes made." : "No changes made. Non-interactive repair requires --yes.");
107
+ return { ok: false, actions, changed, checks: [] };
108
+ }
109
+ for (const action of actions) {
110
+ const result = run(action.command, action.args);
111
+ if (!result.ok) {
112
+ print(`Failed: ${action.description}. Fix: ${action.command} ${action.args.join(" ")}`);
113
+ print(`Completed changes: ${changed.length ? changed.join("; ") : "none"}. The failed install may have partially modified OpenClaw.`);
114
+ return { ok: false, actions, changed, checks: [] };
115
+ }
116
+ changed.push(action.description);
117
+ }
118
+ print(`Changed: ${changed.length ? changed.join("; ") : "nothing"}.`);
119
+ const checks = verifyOpenclaw({ run, command, vendors });
120
+ for (const check of checks) print(`${check.ok ? "OK" : "FAIL"}: ${check.message}${check.ok ? "" : ` Fix: ${check.fix}`}`);
121
+ return { ok: checks.every((check) => check.ok), actions, changed, checks };
122
+ }
@@ -0,0 +1,16 @@
1
+ import { claimModelRefusalRetry, modelRefusals, recordModelRefusal, recordProbeSuccess } from "./health.mjs";
2
+
3
+ /** Start eligible one-token probes without waiting for them in the caller. */
4
+ export function retryRefusedModelsInBackground(stateRoot, { probeModel, now = Date.now() } = {}) {
5
+ if (!probeModel) return;
6
+ for (const key of Object.keys(modelRefusals(stateRoot))) {
7
+ if (!claimModelRefusalRetry(stateRoot, key, { now })) continue;
8
+ const slash = key.indexOf("/");
9
+ if (slash < 1 || slash === key.length - 1) continue;
10
+ const provider = key.slice(0, slash), model = key.slice(slash + 1);
11
+ Promise.resolve().then(() => probeModel({ provider, model, stateRoot })).then((result) => {
12
+ if (result?.ok) recordProbeSuccess(stateRoot, key, { now });
13
+ else if (result?.refused) recordModelRefusal(stateRoot, key, result.reason, { now });
14
+ }).catch(() => { /* An inconclusive probe keeps the refusal and retry timestamp. */ });
15
+ }
16
+ }
package/mcp/server.mjs CHANGED
@@ -34,6 +34,7 @@ import { agentDispatchFields, resolveAgentModel, agentProviderId, describeAgent
34
34
  import { OUTCOMES, COORDINATOR_STATUS_BY_OUTCOME } from "../lib/outcomes.mjs";
35
35
  import { readUsageSnapshots, usageStatus } from "../lib/usage-limits.mjs";
36
36
  import { modelRefusals } from "../lib/health.mjs";
37
+ import { retryRefusedModelsInBackground } from "../lib/refusal-retry.mjs";
37
38
  import { podmanProblem, podmanVmStartedAt } from "../lib/podman-health.mjs";
38
39
  import { restartNotice } from "../lib/install-freshness.mjs";
39
40
  import { requestJobStop } from "../lib/openclaw-run.mjs";
@@ -583,6 +584,7 @@ server.tool("army", "Who you, the General, are and who you call for what in this
583
584
  const catalog = await modelCatalogReady();
584
585
  // A model its vendor refused on a job is listed apart, so a role on
585
586
  // "auto" isn't sent to it (the catalog lists what a plan may refuse).
587
+ retryRefusedModelsInBackground(stateRoot, { probeModel });
586
588
  const refusals = modelRefusals(stateRoot);
587
589
  summary.agents = Object.fromEntries(Object.entries(agents).map(([name, agent]) => {
588
590
  const provider = agentProviderId(agent);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Every byte verified: a harness for AI coding workers whose claims are never trusted. Your coding assistant stays in charge while workers implement and test in sandboxes, and nomArmy checks every change before it is committed.",
4
4
  "author": "Rayson Technologies",
5
5
  "license": "Apache-2.0",
6
- "version": "0.1.0-alpha.19",
6
+ "version": "0.1.0-alpha.20",
7
7
  "private": false,
8
8
  "type": "module",
9
9
  "engines": {
@@ -25,9 +25,11 @@ When you hit a choice you'd normally ask the operator about, don't stop: pick th
25
25
 
26
26
  Stop and ask only for something irreversible or outside this repository: merging or pushing, deploying, anything needing cloud or production credentials, deleting data, or changing another repository.
27
27
 
28
+ A gate's result is not yours to reinterpret. When a check the plan or the operator set (an exact expected output, a count, a checksum, a contract) doesn't match, that's a failure, even when the difference looks cosmetic: whitespace from BSD versus GNU `wc`, line endings, ordering, a trailing newline. Don't judge it "equivalent" and pass it. Either fix the check so it compares what was meant (normalize both sides, and write that change into the run log as a decision), or send the mismatch back as a failed job. A gate the General can wave through is no gate.
29
+
28
30
  ## Watching jobs
29
31
 
30
- Prefer `local_worker_start`. Right after starting a job, if your coordinator can run a background command, run `nomarmy jobs --wait <job_id>` in the background so you're told the moment it finishes and can tell the operator. Otherwise poll `local_worker_status` with the longest `wait_seconds` it allows. For several jobs at once, `nomarmy jobs --events --until-done` exits when every one has finished. The plain `nomarmy jobs --events` stream never exits on its own while jobs run, so don't run it as a background command (you'd only hear when it exits); use it only with a monitor that wakes on each line. `run_status` lists the run's running jobs as well as finished ones. The operator gets a desktop notification whenever a job finishes and whenever the run crosses a limit, so you don't need to relay each one.
32
+ Prefer `local_worker_start`. Right after starting a job, if your coordinator can run a background command, run `nomarmy jobs --wait <job_id>` in the background so you're told the moment it finishes and can tell the operator. Otherwise poll `local_worker_status` with the longest `wait_seconds` it allows. For several jobs at once, `nomarmy jobs --wait <id> <id> ...` exits when every one has finished. For a whole run, use `nomarmy jobs --events --until-done --run <run-id>`. Never use unscoped `--until-done` when other sessions may have jobs. The plain `nomarmy jobs --events` stream never exits on its own while jobs run, so don't run it as a background command (you'd only hear when it exits); use it only with a monitor that wakes on each line. `run_status` lists the run's running jobs as well as finished ones. The operator gets a desktop notification whenever a job finishes and whenever the run crosses a limit, so you don't need to relay each one.
31
33
 
32
34
  ## Limits
33
35
 
@@ -0,0 +1,23 @@
1
+ // install.sh has already installed nomArmy's dependencies. Nothing is ready
2
+ // until the configured vendors and read-only OpenClaw diagnostics pass.
3
+ import { loadAgents } from "../lib/agents.mjs";
4
+ import { globalConfigDir } from "../lib/army.mjs";
5
+ import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
6
+ import { configuredSubscriptionVendors, openclawInstallPlan, repairOpenclaw, runOpenclawCommand, PINNED_OPENCLAW_VERSION } from "../lib/openclaw-install.mjs";
7
+
8
+ ensureOpenClawOnPath();
9
+ const command = process.env.NOMARMY_OPENCLAW_CMD || "openclaw";
10
+ const version = runOpenclawCommand(command, ["--version"]);
11
+ const [major, minor] = process.versions.node.split(".").map(Number);
12
+ if (openclawInstallPlan(version.ok ? version.stdout : null).length &&
13
+ !((major === 24 && minor >= 16) || (major === 26 && minor >= 1) || major > 26)) {
14
+ console.error(`OpenClaw ${PINNED_OPENCLAW_VERSION} needs Node 24.16+ or 26.1+. Upgrade Node first.`);
15
+ process.exitCode = 1;
16
+ } else {
17
+ const prefixIndex = process.argv.indexOf("--prefix");
18
+ const result = await repairOpenclaw({
19
+ command, yes: true, prefix: prefixIndex < 0 ? null : process.argv[prefixIndex + 1],
20
+ vendors: configuredSubscriptionVendors(loadAgents(globalConfigDir()).agents),
21
+ });
22
+ process.exitCode = result.ok ? 0 : 1;
23
+ }