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 +257 -110
- package/install.sh +7 -21
- package/lib/admission.mjs +19 -5
- package/lib/connect.mjs +43 -0
- package/lib/coordinator-instructions.mjs +1 -1
- package/lib/doctor.mjs +1 -1
- package/lib/health.mjs +40 -9
- package/lib/install-freshness.mjs +6 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/refusal-retry.mjs +16 -0
- package/mcp/server.mjs +2 -0
- package/package.json +1 -1
- package/playbooks/feature.md +3 -1
- package/scripts/install-openclaw.mjs +23 -0
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
|
|
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
|
|
236
|
-
|
|
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>]
|
|
242
|
-
|
|
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(
|
|
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}
|
|
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,
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
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).
|
|
1283
|
-
*
|
|
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
|
|
1299
|
-
// to
|
|
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
|
|
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
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
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.
|
|
1577
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2438
|
-
const
|
|
2439
|
-
|
|
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
|
|
2500
|
-
|
|
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
|
|
2816
|
+
/** Wait for selected jobs in the shared, cross-session state directory. */
|
|
2681
2817
|
async function waitForJobCli() {
|
|
2682
|
-
const
|
|
2683
|
-
const
|
|
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
|
-
|
|
2686
|
-
if (json) out({ error:
|
|
2687
|
-
else console.error(
|
|
2823
|
+
const errorOut = (message) => {
|
|
2824
|
+
if (json) out({ error: message });
|
|
2825
|
+
else console.error(`nomarmy jobs: ${message}`);
|
|
2688
2826
|
process.exitCode = 2;
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
if (!
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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
|
-
|
|
2707
|
-
const
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
result.
|
|
2726
|
-
|
|
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
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
if
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
291
|
-
|
|
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
|
|
298
|
-
if (
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
6
|
+
"version": "0.1.0-alpha.20",
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
package/playbooks/feature.md
CHANGED
|
@@ -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 --
|
|
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
|
+
}
|