nomarmy 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- package/scripts/verify-install.sh +68 -0
package/lib/health.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// nomArmy health checks: the mechanical problems that have actually bitten
|
|
2
|
+
// a real run, found before they fail one. Deterministic, no model, cheap;
|
|
3
|
+
// every external command is async and time-limited, so a check can never
|
|
4
|
+
// stall the server that runs it.
|
|
5
|
+
//
|
|
6
|
+
// login-expiry an OAuth login (the ChatGPT plan's Codex import) about
|
|
7
|
+
// to expire or already expired
|
|
8
|
+
// openclaw-update OpenClaw older than npm's latest (a stale 2026.9.5
|
|
9
|
+
// catalog made gpt-6-sol look unavailable)
|
|
10
|
+
// plugin-skew an OpenClaw plugin older than OpenClaw itself
|
|
11
|
+
// army a role or the General pointing at something unusable
|
|
12
|
+
// config agents.yml unloadable or unsafe
|
|
13
|
+
// leftovers retained worktrees, job storage, stale "running" jobs
|
|
14
|
+
//
|
|
15
|
+
// Each issue: { id, severity: "error"|"warn"|"info", title, detail, fix,
|
|
16
|
+
// short }. `id` is stable across runs, so a notification goes out once per
|
|
17
|
+
// issue rather than on every check; `short` is what the status line shows.
|
|
18
|
+
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { modelRejection } from "./openclaw-errors.mjs";
|
|
23
|
+
import { providerConfigured, readOpenclawConfig } from "./openclaw-config.mjs";
|
|
24
|
+
|
|
25
|
+
const DAY = 86400000;
|
|
26
|
+
|
|
27
|
+
/** Run a command asynchronously, bounded; resolves { ok, stdout }. Never rejects. */
|
|
28
|
+
export function runBounded(cmd, args, { timeoutMs = 20000 } = {}) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
execFile(cmd, args, { encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => resolve({ ok: !error, stdout: stdout ?? "" }));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const versionOf = (text) => /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ""))?.slice(1, 4).map(Number) ?? null;
|
|
35
|
+
const older = (a, b) => { for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] < b[i]; } return false; };
|
|
36
|
+
|
|
37
|
+
/** OAuth profiles near or past expiry, from `openclaw models auth list --json`. */
|
|
38
|
+
export function loginExpiryIssues(authJson, { now = Date.now(), warnDays = 7 } = {}) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
for (const p of authJson?.profiles ?? []) {
|
|
41
|
+
const at = Date.parse(p.expiresAt ?? "");
|
|
42
|
+
if (!Number.isFinite(at)) continue;
|
|
43
|
+
const days = Math.floor((at - now) / DAY);
|
|
44
|
+
const who = p.provider === "openai" ? "Codex (ChatGPT plan)" : p.provider;
|
|
45
|
+
const fix = p.provider === "openai" ? "nomarmy agents add subscription codex (then replace the existing codex agent)" : `openclaw models auth login --provider ${p.provider}`;
|
|
46
|
+
if (at <= now) issues.push({ id: `login-expired:${p.id}`, severity: "error", title: `${who} login has expired`, detail: `Auth profile ${p.provider} expired ${new Date(at).toISOString().slice(0, 10)}; every job on it will fail.`, fix, short: `${p.provider} login expired` });
|
|
47
|
+
else if (at - now <= warnDays * DAY) issues.push({ id: `login-expiring:${p.id}`, severity: "warn", title: `${who} login expires in ${days} day${days === 1 ? "" : "s"}`, detail: `Auth profile ${p.provider} expires ${new Date(at).toISOString().slice(0, 10)}.`, fix, short: `${p.provider} login ${days}d` });
|
|
48
|
+
}
|
|
49
|
+
return issues;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** OpenClaw behind npm's latest, and plugins behind OpenClaw. */
|
|
53
|
+
export function versionIssues({ installed, latest, plugins = [] }) {
|
|
54
|
+
const issues = [];
|
|
55
|
+
const have = versionOf(installed), want = versionOf(latest);
|
|
56
|
+
if (have && want && older(have, want)) {
|
|
57
|
+
issues.push({ id: `openclaw-update:${want.join(".")}`, severity: "info", title: `OpenClaw ${want.join(".")} is out (you have ${have.join(".")})`,
|
|
58
|
+
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.",
|
|
59
|
+
fix: "npm update -g openclaw && openclaw doctor --fix (when no nomArmy jobs are running)", short: "openclaw update" });
|
|
60
|
+
}
|
|
61
|
+
for (const p of plugins) {
|
|
62
|
+
const pv = versionOf(p.version);
|
|
63
|
+
if (have && pv && older(pv, have)) {
|
|
64
|
+
issues.push({ id: `plugin-skew:${p.id}:${pv.join(".")}`, severity: "info", title: `OpenClaw's ${p.id} plugin (${pv.join(".")}) is older than OpenClaw (${have.join(".")})`,
|
|
65
|
+
detail: "OpenClaw tries to upgrade it on its own and warns while no matching version is published; the installed one keeps working meanwhile.",
|
|
66
|
+
fix: `openclaw update repair (once @openclaw/${p.id} ${have.join(".")} is published)`, short: null });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Roles, and the General, pointing at something unusable (from describeArmy's summary). */
|
|
73
|
+
export function armyIssues(summary) {
|
|
74
|
+
const issues = [];
|
|
75
|
+
if (summary?.general?.problem) issues.push({ id: "army:general", severity: "warn", title: "The General's agent isn't set up", detail: summary.general.problem, fix: "nomarmy army general <agent>", short: null });
|
|
76
|
+
for (const [role, r] of Object.entries(summary?.roles ?? {})) {
|
|
77
|
+
if (r.problem) issues.push({ id: `army:role:${role}:${r.problem}`, severity: "warn", title: `Role ${role} can't be dispatched`, detail: r.problem, fix: `nomarmy army assign ${role} <agent> [model|auto]`, short: `${role} unusable` });
|
|
78
|
+
else if (r.hostTools?.implementRole && !r.hostTools.allowed) issues.push({ id: `army:host-tools:${role}:${r.agent}`, severity: "warn",
|
|
79
|
+
title: `Role ${role} builds on ${r.agent}, whose tools run on this machine`,
|
|
80
|
+
detail: `${r.agent}'s worker runs its own shell on your machine, with your files and the network, outside nomArmy's sandbox, so nomArmy refuses implement jobs on it.`,
|
|
81
|
+
fix: `nomarmy army assign ${role} <a sandboxed agent> [model], or set allow_host_tools: true on ${r.agent} in agents.yml to accept it`, short: `${role} unsandboxed` });
|
|
82
|
+
}
|
|
83
|
+
return issues;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Models the provider refused (model_not_found, "Unknown model") on a real job in the last day,
|
|
88
|
+
* from job records. A listed model isn't proof it runs: Muse was listed
|
|
89
|
+
* while every job on it failed this way (a real Senti review).
|
|
90
|
+
*/
|
|
91
|
+
export function unknownModelIssues(jobRecords, { now = Date.now(), inUse = null, probedOk = {} } = {}) {
|
|
92
|
+
const failed = new Map(), lastOk = new Map(Object.entries(probedOk));
|
|
93
|
+
for (const m of jobRecords) {
|
|
94
|
+
const at = Date.parse(m.finishedAt ?? "");
|
|
95
|
+
if (now - at > DAY) continue;
|
|
96
|
+
const text = `${m.workerError ?? ""} ${m.worker?.error ?? ""}`;
|
|
97
|
+
// A job's own model_not_found line first (mcp/server.mjs names the model
|
|
98
|
+
// it attempted), then any raw vendor wording in older records.
|
|
99
|
+
const tagged = /model_not_found: ([\w.-]+\/[\w.:-]+)/.exec(text)?.[1];
|
|
100
|
+
const model = (tagged ?? modelRejection(text)?.model)?.replace(/[.:]+$/, ""); // the sentence's own trailing period isn't part of the name
|
|
101
|
+
if (model) { const f = failed.get(model) ?? { n: 0, last: 0 }; f.n++; f.last = Math.max(f.last, at); failed.set(model, f); }
|
|
102
|
+
else if (m.worker?.provider && m.worker?.model && !m.workerError) {
|
|
103
|
+
const ran = `${m.worker.provider}/${m.worker.model}`;
|
|
104
|
+
lastOk.set(ran, Math.max(lastOk.get(ran) ?? 0, at));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Fixed since: a later job on it ran, or no role uses it any more.
|
|
108
|
+
return [...failed].filter(([model, f]) => !(lastOk.get(model) > f.last) && !(inUse && !inUse.has(model))).map(([model, { n }]) => ({ id: `unknown-model:${model}`, severity: "warn",
|
|
109
|
+
title: `${model} failed as an unknown or unsupported model on ${n} job${n === 1 ? "" : "s"} today`,
|
|
110
|
+
detail: "OpenClaw or the provider won't run it (it may still be listed in the catalog), so every role on it fails.",
|
|
111
|
+
fix: "nomarmy agents list (see which roles use it), then nomarmy army assign <role> <another agent> [model]", short: `${model.split("/").pop()} not running` }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Subscription agents whose provider has no entry in OpenClaw's config,
|
|
116
|
+
* for vendors whose plugin writes one (lib/openclaw-config.mjs): every
|
|
117
|
+
* model on them is listed but fails "Unknown model".
|
|
118
|
+
*/
|
|
119
|
+
export function providerConfigIssues({ agents = {}, openclawConfig = null, vendors = {} }) {
|
|
120
|
+
if (!openclawConfig) return [];
|
|
121
|
+
const issues = [];
|
|
122
|
+
for (const [vendorKey, vendor] of Object.entries(vendors)) {
|
|
123
|
+
if (!vendor.plugin?.providerConfig) continue;
|
|
124
|
+
const users = Object.entries(agents).filter(([, a]) => a?.kind === "subscription" && a.provider === vendor.provider).map(([n]) => n);
|
|
125
|
+
if (!users.length || providerConfigured(openclawConfig, vendor.provider)) continue;
|
|
126
|
+
issues.push({ id: `provider-config:${vendor.provider}`, severity: "warn",
|
|
127
|
+
title: `OpenClaw has no ${vendor.provider} provider entry, so ${users.join(", ")} can't run`,
|
|
128
|
+
detail: `Its models are listed (the catalog asks ${vendor.provider} directly) but every job fails "Unknown model": the key is linked, the provider's config isn't.`,
|
|
129
|
+
fix: `nomarmy agents add subscription ${vendorKey} (adds it with the plugin's own setup step)`, short: `${vendor.provider} not set up` });
|
|
130
|
+
}
|
|
131
|
+
return issues;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A real test call that worked (`army assign`, `agents update --probe`), in
|
|
136
|
+
* the shared probe-ok.json: it clears a model's earlier failure warning the
|
|
137
|
+
* same way a later successful job does.
|
|
138
|
+
*/
|
|
139
|
+
export function recordProbeSuccess(stateRoot, model, { now = Date.now() } = {}) {
|
|
140
|
+
const file = path.join(stateRoot, "probe-ok.json");
|
|
141
|
+
let seen = {};
|
|
142
|
+
try { seen = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* first */ }
|
|
143
|
+
seen[model] = new Date(now).toISOString();
|
|
144
|
+
try { fs.writeFileSync(file, JSON.stringify(seen, null, 2)); } catch { /* best-effort */ }
|
|
145
|
+
}
|
|
146
|
+
function readProbeSuccesses(stateRoot) {
|
|
147
|
+
try { return Object.fromEntries(Object.entries(JSON.parse(fs.readFileSync(path.join(stateRoot, "probe-ok.json"), "utf8"))).map(([k, v]) => [k, Date.parse(v)])); } catch { return {}; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `model` ("provider/model") was refused on a job in the last day
|
|
152
|
+
* with nothing working on it since -- the admission check that stops a
|
|
153
|
+
* second job going to a model the vendor already rejected (gpt-6-sol on a
|
|
154
|
+
* ChatGPT plan failed job after job before health noticed). The issue, or null.
|
|
155
|
+
*/
|
|
156
|
+
export function recentModelRefusal(stateRoot, model, { now = Date.now() } = {}) {
|
|
157
|
+
const jobsRoot = path.join(stateRoot, "jobs");
|
|
158
|
+
const records = [];
|
|
159
|
+
let names = [];
|
|
160
|
+
try { names = fs.readdirSync(jobsRoot); } catch { return null; }
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
const file = path.join(jobsRoot, name, "metadata.json");
|
|
163
|
+
try { if (now - fs.statSync(file).mtimeMs < DAY) records.push(JSON.parse(fs.readFileSync(file, "utf8"))); } catch { /* unfinished */ }
|
|
164
|
+
}
|
|
165
|
+
return unknownModelIssues(records, { now, probedOk: readProbeSuccesses(stateRoot) }).find((i) => i.id === `unknown-model:${model}`) ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Job storage and leftovers. */
|
|
169
|
+
export function leftoverIssues({ retainedWorktrees = 0, jobsBytes = 0, staleRunning = 0 }) {
|
|
170
|
+
const issues = [];
|
|
171
|
+
if (retainedWorktrees >= 15) issues.push({ id: `leftovers:worktrees:${Math.floor(retainedWorktrees / 15)}`, severity: "info", title: `${retainedWorktrees} job worktrees are being kept`, detail: "Failed and incomplete jobs keep their worktree for review; they add up.", fix: "local_worker_sweep (empty ones), local_worker_cleanup (reviewed ones)", short: null });
|
|
172
|
+
if (jobsBytes >= 2 * 1024 ** 3) issues.push({ id: `leftovers:storage:${Math.floor(jobsBytes / 1024 ** 3)}`, severity: "warn", title: `Job storage is ${(jobsBytes / 1024 ** 3).toFixed(1)} GB`, detail: "Mostly per-job runtime directories (npm caches, harness state).", fix: "nomarmy jobs --prune --older-than 0 (removes runtime data from every finished job now; records and reports stay, only saved transcripts go). Finished jobs older than a day are pruned on their own, so what's left is recent jobs and kept worktrees (local_worker_sweep / local_worker_cleanup).", short: `jobs ${(jobsBytes / 1024 ** 3).toFixed(0)}GB` });
|
|
173
|
+
if (staleRunning > 0) issues.push({ id: `leftovers:stale:${staleRunning}`, severity: "info", title: `${staleRunning} job${staleRunning === 1 ? "" : "s"} still marked running by a server that's gone`, detail: "Its outcome is unknown; its worktree may hold work.", fix: "nomarmy jobs (shows them as orphaned), then review or clean up", short: null });
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run every check. `env` supplies what each needs, with real defaults;
|
|
179
|
+
* tests pass their own.
|
|
180
|
+
*/
|
|
181
|
+
export async function runHealthChecks({ now = Date.now(), openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw", run = runBounded, armySummary = null, agentsError = null, jobsRoot = null, pidAlive = () => true, agents = null, openclawConfig = null, vendors = {}, modelsInUse = null, autoPruned = null } = {}) {
|
|
182
|
+
const issues = [];
|
|
183
|
+
if (autoPruned?.freedBytes) issues.push({ id: `auto-prune:${new Date(now).toISOString()}`, severity: "info",
|
|
184
|
+
title: `Freed ${(autoPruned.freedBytes / 1024 ** 3).toFixed(2)} GB: ${[autoPruned.pruned ? `runtime data of ${autoPruned.pruned} finished job${autoPruned.pruned === 1 ? "" : "s"} older than ${autoPruned.olderThanHours}h` : null, autoPruned.scratchCleared ? `OpenClaw scratch files of ${autoPruned.scratchCleared} more` : null].filter(Boolean).join(", ")}`,
|
|
185
|
+
detail: "Automatic; each job's record and report are kept. NOMARMY_AUTO_PRUNE_HOURS sets the age (0 turns it off).", fix: null, short: null });
|
|
186
|
+
if (agents) issues.push(...providerConfigIssues({ agents, openclawConfig, vendors }));
|
|
187
|
+
const [auth, version, latest, plugins] = await Promise.all([
|
|
188
|
+
run(openclawCmd, ["models", "auth", "list", "--json"]),
|
|
189
|
+
run(openclawCmd, ["--version"]),
|
|
190
|
+
run("npm", ["view", "openclaw", "version"], { timeoutMs: 15000 }),
|
|
191
|
+
run(openclawCmd, ["plugins", "inspect", "codex"]),
|
|
192
|
+
]);
|
|
193
|
+
if (auth.ok) { try { issues.push(...loginExpiryIssues(JSON.parse(auth.stdout.slice(auth.stdout.indexOf("{"))), { now })); } catch { /* unparseable: skip */ } }
|
|
194
|
+
const pluginVersion = /Version:\s*(\S+)/.exec(plugins.stdout ?? "")?.[1];
|
|
195
|
+
if (version.ok) issues.push(...versionIssues({ installed: version.stdout, latest: latest.ok ? latest.stdout : null, plugins: pluginVersion ? [{ id: "codex", version: pluginVersion }] : [] }));
|
|
196
|
+
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" });
|
|
197
|
+
if (armySummary) issues.push(...armyIssues(armySummary));
|
|
198
|
+
if (jobsRoot) {
|
|
199
|
+
let retainedWorktrees = 0, jobsBytes = 0, staleRunning = 0;
|
|
200
|
+
const records = [];
|
|
201
|
+
let names = [];
|
|
202
|
+
try { names = fs.readdirSync(jobsRoot); } catch { /* none */ }
|
|
203
|
+
for (const name of names) {
|
|
204
|
+
const dir = path.join(jobsRoot, name);
|
|
205
|
+
if (fs.existsSync(path.join(dir, "worktree"))) retainedWorktrees++;
|
|
206
|
+
try { const st = fs.statSync(path.join(dir, "metadata.json")); if (now - st.mtimeMs < DAY) records.push(JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8"))); } catch { /* not finished */ }
|
|
207
|
+
try {
|
|
208
|
+
const status = JSON.parse(fs.readFileSync(path.join(dir, "status.json"), "utf8"));
|
|
209
|
+
if (status.state === "running" && !pidAlive(status.serverPid)) staleRunning++;
|
|
210
|
+
} catch { /* no status */ }
|
|
211
|
+
}
|
|
212
|
+
const du = await run("du", ["-sk", jobsRoot], { timeoutMs: 30000 });
|
|
213
|
+
if (du.ok) jobsBytes = (Number(du.stdout.split(/\s/)[0]) || 0) * 1024;
|
|
214
|
+
issues.push(...unknownModelIssues(records, { now, inUse: modelsInUse, probedOk: readProbeSuccesses(path.dirname(jobsRoot)) }));
|
|
215
|
+
issues.push(...leftoverIssues({ retainedWorktrees, jobsBytes, staleRunning }));
|
|
216
|
+
}
|
|
217
|
+
const rank = { error: 0, warn: 1, info: 2 };
|
|
218
|
+
issues.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
219
|
+
return { checkedAt: new Date(now).toISOString(), issues };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Save results to the shared health.json, returning the issues that are new
|
|
224
|
+
* enough to notify about: warn/error ones not notified in the last day by
|
|
225
|
+
* any session's server (several sessions run checks; one notification each).
|
|
226
|
+
*/
|
|
227
|
+
export function recordHealth(file, result, { now = Date.now() } = {}) {
|
|
228
|
+
let prev = {};
|
|
229
|
+
try { prev = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* first run */ }
|
|
230
|
+
const notified = { ...(prev.notified ?? {}) };
|
|
231
|
+
const toNotify = [];
|
|
232
|
+
for (const issue of result.issues) {
|
|
233
|
+
if (issue.severity === "info") continue;
|
|
234
|
+
if (!notified[issue.id] || now - Date.parse(notified[issue.id]) > DAY) { toNotify.push(issue); notified[issue.id] = new Date(now).toISOString(); }
|
|
235
|
+
}
|
|
236
|
+
for (const id of Object.keys(notified)) if (now - Date.parse(notified[id]) > 7 * DAY) delete notified[id];
|
|
237
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
238
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify({ ...result, notified }, null, 2));
|
|
239
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
240
|
+
return toNotify;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Everything a check run needs, gathered the same way for the server's
|
|
245
|
+
* periodic run and `nomarmy health`, then run and recorded to the shared
|
|
246
|
+
* health.json. Returns { result, toNotify }.
|
|
247
|
+
*/
|
|
248
|
+
export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, now = Date.now() }) {
|
|
249
|
+
const { loadAgents, describeAgent, agentProviderId } = await import("./agents.mjs");
|
|
250
|
+
const { loadArmy, describeArmy } = await import("./army.mjs");
|
|
251
|
+
const { pidAlive } = await import("./slots.mjs");
|
|
252
|
+
let agents = null, agentsError = null, armySummary = null;
|
|
253
|
+
try { agents = loadAgents(configDir).agents; } catch (error) { agentsError = String(error.message).split("\n")[0]; }
|
|
254
|
+
if (agents) { try { armySummary = describeArmy(loadArmy({ projectDir }), { agents, describeAgent }); } catch (error) { agentsError = agentsError ?? `army: ${String(error.message).split("\n")[0]}`; } }
|
|
255
|
+
const { SUBSCRIPTION_VENDORS } = await import("./subscription-setup.mjs");
|
|
256
|
+
// provider/model each role runs on, so a failure on a model no role uses
|
|
257
|
+
// any more (it was reassigned) stops warning.
|
|
258
|
+
let modelsInUse = null;
|
|
259
|
+
if (agents && armySummary) {
|
|
260
|
+
modelsInUse = new Set();
|
|
261
|
+
for (const r of Object.values(armySummary.roles ?? {})) {
|
|
262
|
+
const a = agents[r.agent];
|
|
263
|
+
if (!a || !r.model || r.modelIsAuto) continue;
|
|
264
|
+
try { modelsInUse.add(`${agentProviderId(a)}/${r.model}`); } catch { /* unknown shape: skip */ }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Finished jobs' runtime data goes on its own (lib/prune.mjs): about
|
|
268
|
+
// 100 MB a job, useful for a day at most, and no decision to make. Before
|
|
269
|
+
// the checks, so the storage warning is about what's left.
|
|
270
|
+
const { pruneJobRuntime, autoPruneAgeMs } = await import("./prune.mjs");
|
|
271
|
+
const ageMs = autoPruneAgeMs();
|
|
272
|
+
let autoPruned = null;
|
|
273
|
+
if (ageMs !== null) { try { autoPruned = { ...pruneJobRuntime({ stateRoot, olderThanMs: ageMs, now }), olderThanHours: ageMs / 3600000 }; } catch { /* best-effort */ } }
|
|
274
|
+
const result = await runHealthChecks({ now, armySummary, agentsError, jobsRoot: path.join(stateRoot, "jobs"), pidAlive,
|
|
275
|
+
agents, openclawConfig: readOpenclawConfig(), vendors: SUBSCRIPTION_VENDORS, modelsInUse, autoPruned });
|
|
276
|
+
const toNotify = recordHealth(path.join(stateRoot, "health.json"), result, { now });
|
|
277
|
+
return { result, toNotify };
|
|
278
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Reads OpenClaw's own model catalog for a hosted model's real context
|
|
2
|
+
// window -- deliberately NOT a second, hand-maintained table of the same
|
|
3
|
+
// numbers inside nomArmy, which would only ever go stale (this is exactly
|
|
4
|
+
// the class of thing a sharp reviewer flags first). OpenClaw already tracks
|
|
5
|
+
// this per "<provider>/<model>" key, the same key nomArmy already composes
|
|
6
|
+
// for dispatch (see resolvePoolSelection in mcp/server.mjs), and refreshes
|
|
7
|
+
// it itself (`openclaw models list --refresh`) independently of nomArmy.
|
|
8
|
+
//
|
|
9
|
+
// A brand-new model (verified live: xai/grok-4.7, released the same day
|
|
10
|
+
// this was written) will not be in OpenClaw's CACHED catalog until that
|
|
11
|
+
// refresh runs -- lib/dispatch-config.mjs's context_window override exists
|
|
12
|
+
// specifically for that gap, not as a alternative to this lookup.
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
// Bounded: this runs inside the MCP server, where a synchronous call with no
|
|
16
|
+
// timeout blocks every request the server handles until it returns.
|
|
17
|
+
const SYNC_TIMEOUT_MS = 20000;
|
|
18
|
+
const ASYNC_TIMEOUT_MS = 180000;
|
|
19
|
+
|
|
20
|
+
function defaultRun(cmd, args) {
|
|
21
|
+
// "Gateway is not running..." and similar operational notices land on
|
|
22
|
+
// OpenClaw's stderr, not stdout (verified live) -- stdout alone parses as
|
|
23
|
+
// clean JSON regardless of whether that notice fires.
|
|
24
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: SYNC_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCatalog(raw) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
30
|
+
const byKey = new Map();
|
|
31
|
+
for (const m of parsed?.models ?? []) {
|
|
32
|
+
if (m?.key && Number.isFinite(m.contextWindow) && m.contextWindow > 0) byKey.set(m.key, m.contextWindow);
|
|
33
|
+
}
|
|
34
|
+
return byKey;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The same query, off the caller's thread. `--refresh` contacts every
|
|
39
|
+
* provider (about 15 seconds when they're healthy, unbounded when one
|
|
40
|
+
* stalls), so the MCP server runs it this way: run synchronously, a stalled
|
|
41
|
+
* provider froze the whole server -- the likeliest cause of a status call
|
|
42
|
+
* that hung for 35 minutes in a real Senti run.
|
|
43
|
+
* @returns {Promise<Map<string, number>|null>}
|
|
44
|
+
*/
|
|
45
|
+
export function queryModelCatalogAsync({ openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw", refresh = false, timeoutMs = ASYNC_TIMEOUT_MS } = {}) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
execFile(openclawCmd, ["models", "list", "--all", "--json", ...(refresh ? ["--refresh"] : [])],
|
|
48
|
+
{ encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
|
|
49
|
+
(error, stdout) => resolve(error ? null : parseCatalog(stdout)));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @returns {Map<string, number>|null} "<provider>/<model>" -> context window
|
|
55
|
+
* in tokens, or null if openclaw isn't reachable/installed/erroring. Never
|
|
56
|
+
* throws -- a missing catalog is a fallback case for the caller, not a
|
|
57
|
+
* failure of this function.
|
|
58
|
+
*/
|
|
59
|
+
// `refresh` asks OpenClaw to re-discover every provider's models first
|
|
60
|
+
// (about 15 seconds, confirmed live). Without it the cached catalog may hold
|
|
61
|
+
// no entries at all for a provider OpenClaw hasn't refreshed yet (seen live:
|
|
62
|
+
// openai, xai and meta all absent while claude-cli was present).
|
|
63
|
+
export function queryModelCatalog({ openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw", run = defaultRun, refresh = false } = {}) {
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = run(openclawCmd, ["models", "list", "--all", "--json", ...(refresh ? ["--refresh"] : [])]);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return parseCatalog(raw);
|
|
71
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Builds nomArmy.app, the macOS notifier (notifier/main.swift), so
|
|
2
|
+
// notifications carry nomArmy's icon instead of Script Editor's.
|
|
3
|
+
//
|
|
4
|
+
// Built on this machine by `nomarmy connect` (swiftc, sips, iconutil and an
|
|
5
|
+
// ad-hoc codesign, all part of Xcode's command-line tools), into nomArmy's
|
|
6
|
+
// state directory, and only rebuilt when the source or icon changes. Two
|
|
7
|
+
// things found live on macOS 26: an AppleScript applet's notifications were
|
|
8
|
+
// dropped (it never registered with Notification Center), and macOS refused
|
|
9
|
+
// a bundle id it had already seen refused. The bundle id below is the one
|
|
10
|
+
// the person was asked about and allowed; changing it asks them again.
|
|
11
|
+
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
export const NOTIFIER_BUNDLE_ID = "com.rayson-tech.nomarmy.notifier2";
|
|
19
|
+
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
20
|
+
|
|
21
|
+
/** nomArmy's state directory (job records, leases, and the notifier). */
|
|
22
|
+
export function stateRoot(env = process.env) {
|
|
23
|
+
return env.NOMARMY_AGENT_STATE || path.join(env.HOME || os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
24
|
+
}
|
|
25
|
+
/** Where the notifier app and its spool folder live. */
|
|
26
|
+
export function notifierPaths(root = stateRoot()) {
|
|
27
|
+
const dir = path.join(root, "notifier");
|
|
28
|
+
const app = path.join(dir, "nomArmy.app");
|
|
29
|
+
return { dir, app, binary: path.join(app, "Contents", "MacOS", "nomArmy"), spool: path.join(dir, "spool") };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function notifierInfoPlist() {
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0"><dict>
|
|
36
|
+
<key>CFBundleIdentifier</key><string>${NOTIFIER_BUNDLE_ID}</string>
|
|
37
|
+
<key>CFBundleName</key><string>nomArmy</string>
|
|
38
|
+
<key>CFBundleDisplayName</key><string>nomArmy</string>
|
|
39
|
+
<key>CFBundleExecutable</key><string>nomArmy</string>
|
|
40
|
+
<key>CFBundleIconFile</key><string>nomArmy</string>
|
|
41
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
42
|
+
<key>CFBundleShortVersionString</key><string>1.0</string>
|
|
43
|
+
<key>CFBundleVersion</key><string>1</string>
|
|
44
|
+
<key>LSUIElement</key><true/>
|
|
45
|
+
</dict></plist>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build (or keep) the notifier app. Never throws: a missing toolchain or a
|
|
51
|
+
* failed step leaves notifications on osascript, which still works.
|
|
52
|
+
* @returns {{ status: "built"|"current"|"skipped"|"failed", app?: string, reason?: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function buildNotifierApp({ nomarmyRoot, root = stateRoot(), platform = process.platform, env = process.env, run = (cmd, args) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }) } = {}) {
|
|
55
|
+
if (platform !== "darwin") return { status: "skipped", reason: "not macOS" };
|
|
56
|
+
if (env.NOMARMY_NOTIFY === "0") return { status: "skipped", reason: "notifications are off (NOMARMY_NOTIFY=0)" };
|
|
57
|
+
const source = path.join(nomarmyRoot, "notifier", "main.swift"), icon = path.join(nomarmyRoot, "notifier", "nomarmy-icon.png");
|
|
58
|
+
const p = notifierPaths(root);
|
|
59
|
+
let digest;
|
|
60
|
+
try { digest = crypto.createHash("sha256").update(fs.readFileSync(source)).update(fs.readFileSync(icon)).update(notifierInfoPlist()).digest("hex"); }
|
|
61
|
+
catch (error) { return { status: "failed", reason: `notifier source missing: ${error.message}` }; }
|
|
62
|
+
const stamp = path.join(p.app, "Contents", "Resources", "nomarmy-build.sha256");
|
|
63
|
+
try { if (fs.existsSync(p.binary) && fs.readFileSync(stamp, "utf8").trim() === digest) return { status: "current", app: p.app }; } catch { /* rebuild */ }
|
|
64
|
+
// Built beside its final place: a rename from the system temp folder
|
|
65
|
+
// crosses volumes on macOS (EXDEV).
|
|
66
|
+
fs.mkdirSync(p.dir, { recursive: true });
|
|
67
|
+
const work = fs.mkdtempSync(path.join(p.dir, "build-"));
|
|
68
|
+
try {
|
|
69
|
+
const next = path.join(work, "nomArmy.app"), contents = path.join(next, "Contents");
|
|
70
|
+
fs.mkdirSync(path.join(contents, "MacOS"), { recursive: true });
|
|
71
|
+
fs.mkdirSync(path.join(contents, "Resources"), { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(contents, "Info.plist"), notifierInfoPlist());
|
|
73
|
+
run("swiftc", ["-O", "-o", path.join(contents, "MacOS", "nomArmy"), source]);
|
|
74
|
+
const iconset = path.join(work, "nomArmy.iconset");
|
|
75
|
+
fs.mkdirSync(iconset);
|
|
76
|
+
for (const s of [16, 32, 128, 256, 512]) {
|
|
77
|
+
run("sips", ["-z", String(s), String(s), icon, "--out", path.join(iconset, `icon_${s}x${s}.png`)]);
|
|
78
|
+
run("sips", ["-z", String(s * 2), String(s * 2), icon, "--out", path.join(iconset, `icon_${s}x${s}@2x.png`)]);
|
|
79
|
+
}
|
|
80
|
+
run("iconutil", ["-c", "icns", iconset, "-o", path.join(contents, "Resources", "nomArmy.icns")]);
|
|
81
|
+
run("codesign", ["--force", "-s", "-", next]);
|
|
82
|
+
fs.writeFileSync(path.join(contents, "Resources", "nomarmy-build.sha256"), `${digest}\n`);
|
|
83
|
+
// Swap in place: same path and bundle id, so the permission the person
|
|
84
|
+
// gave carries over.
|
|
85
|
+
fs.mkdirSync(p.spool, { recursive: true });
|
|
86
|
+
fs.rmSync(p.app, { recursive: true, force: true });
|
|
87
|
+
fs.renameSync(next, p.app);
|
|
88
|
+
try { run(LSREGISTER, ["-f", p.app]); } catch { /* registers on first launch anyway */ }
|
|
89
|
+
return { status: "built", app: p.app };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { status: "failed", reason: String(error.stderr ?? error.message).split("\n")[0].slice(0, 200) };
|
|
92
|
+
} finally {
|
|
93
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
package/lib/notify.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Desktop notifications from nomArmy itself, so the person watching hears
|
|
2
|
+
// about a finished job, a failure or a run limit no matter which
|
|
3
|
+
// coordinator (Claude Code, Codex, Cursor) is running the General.
|
|
4
|
+
//
|
|
5
|
+
// macOS: nomArmy.app (lib/notifier-app.mjs), which shows nomArmy's own icon,
|
|
6
|
+
// or osascript's `display notification` when the app isn't built (that one
|
|
7
|
+
// shows Script Editor's icon). Linux: notify-send. Anything else, or
|
|
8
|
+
// NOMARMY_NOTIFY=0: nothing. Fire-and-forget: a notification must never
|
|
9
|
+
// delay or fail the job that triggered it.
|
|
10
|
+
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { notifierPaths, stateRoot } from "./notifier-app.mjs";
|
|
16
|
+
|
|
17
|
+
/** An AppleScript string literal: backslashes and double quotes escaped, newlines flattened. */
|
|
18
|
+
export function appleScriptString(text) {
|
|
19
|
+
return `"${String(text ?? "").replace(/[\r\n]+/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The command that would show this notification here, or null. */
|
|
23
|
+
export function notificationCommand(title, message, { platform = process.platform, env = process.env, app = null } = {}) {
|
|
24
|
+
if (env.NOMARMY_NOTIFY === "0") return null;
|
|
25
|
+
const t = String(title).slice(0, 120), m = String(message).slice(0, 400);
|
|
26
|
+
if (platform === "darwin" && app) return ["open", ["-g", app.app, "--args", app.spool]];
|
|
27
|
+
if (platform === "darwin") return ["osascript", ["-e", `display notification ${appleScriptString(m)} with title ${appleScriptString(t)}`]];
|
|
28
|
+
if (platform === "linux") return ["notify-send", ["--app-name=nomArmy", t, m]];
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The built notifier app, or null. */
|
|
33
|
+
export function notifierApp(root = stateRoot()) {
|
|
34
|
+
const p = notifierPaths(root);
|
|
35
|
+
return fs.existsSync(p.binary) ? p : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Queue one notification for nomArmy.app: title and message on two lines,
|
|
40
|
+
* written under a temp name and renamed, so the app never reads half a file.
|
|
41
|
+
*/
|
|
42
|
+
export function spoolNotification(spool, title, message) {
|
|
43
|
+
fs.mkdirSync(spool, { recursive: true });
|
|
44
|
+
// A notifier that can't run (denied, broken) mustn't let the folder grow.
|
|
45
|
+
try { if (fs.readdirSync(spool).length > 50) return false; } catch { /* fresh */ }
|
|
46
|
+
const flat = (s, n) => String(s ?? "").replace(/[\r\n]+/g, " ").slice(0, n);
|
|
47
|
+
const name = `${Date.now()}-${process.pid}-${crypto.randomBytes(3).toString("hex")}`;
|
|
48
|
+
const tmp = path.join(spool, `${name}.part`);
|
|
49
|
+
fs.writeFileSync(tmp, `${flat(title, 120)}\n${flat(message, 400)}\n`);
|
|
50
|
+
fs.renameSync(tmp, path.join(spool, `${name}.txt`));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function notify(title, message, { platform = process.platform, env = process.env, run = spawn, root = stateRoot(env) } = {}) {
|
|
55
|
+
if (env.NOMARMY_NOTIFY === "0") return false;
|
|
56
|
+
let app = platform === "darwin" ? notifierApp(root) : null;
|
|
57
|
+
try { if (app && !spoolNotification(app.spool, title, message)) return false; } catch { app = null; }
|
|
58
|
+
const cmd = notificationCommand(title, message, { platform, env, app });
|
|
59
|
+
if (!cmd) return false;
|
|
60
|
+
try {
|
|
61
|
+
const child = run(cmd[0], cmd[1], { stdio: "ignore", detached: true });
|
|
62
|
+
child.on?.("error", () => {});
|
|
63
|
+
child.unref?.();
|
|
64
|
+
return true;
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A provider plugin's own config step, which `paste-api-key` never runs.
|
|
2
|
+
//
|
|
3
|
+
// OpenClaw runs a model only when ~/.openclaw/openclaw.json has an entry
|
|
4
|
+
// for its provider under models.providers (base URL, API shape, models).
|
|
5
|
+
// A plugin writes that entry in its onboarding step -- the Meta plugin's
|
|
6
|
+
// applyMetaConfig -- which `openclaw onboard` calls. nomArmy links a
|
|
7
|
+
// subscription key with `openclaw models auth paste-api-key`, which saves
|
|
8
|
+
// the key and nothing else. So the catalog listed every Muse model (it asks
|
|
9
|
+
// Meta directly) while every job on one failed "Unknown model" (a real
|
|
10
|
+
// Senti run). This runs that same plugin function, only when the entry is
|
|
11
|
+
// missing, with a backup of the file first.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
/** OpenClaw's home directory (its config, extensions and state). */
|
|
19
|
+
export function openclawHome(env = process.env) {
|
|
20
|
+
return env.OPENCLAW_STATE_DIR || path.join(env.HOME || os.homedir(), ".openclaw");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** OpenClaw's config file (OPENCLAW_CONFIG_PATH, as OpenClaw itself honors). */
|
|
24
|
+
export function openclawConfigPath(home = openclawHome(), env = process.env) {
|
|
25
|
+
return env.OPENCLAW_CONFIG_PATH || path.join(home, "openclaw.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether OpenClaw's config has an entry for this provider. */
|
|
29
|
+
export function providerConfigured(config, provider) {
|
|
30
|
+
return Boolean(config?.models?.providers && Object.prototype.hasOwnProperty.call(config.models.providers, provider));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The parsed openclaw.json, or null when it's missing or unreadable. */
|
|
34
|
+
export function readOpenclawConfig(home = openclawHome()) {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(openclawConfigPath(home), "utf8")); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply a plugin's provider config if its entry is missing.
|
|
40
|
+
* @param {{ provider: string, pluginId: string, module: string, exportName: string, home?: string }} spec
|
|
41
|
+
* module is relative to the plugin's directory under extensions/.
|
|
42
|
+
* @returns {Promise<{ changed: boolean, backup?: string, error?: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureProviderConfig({ provider, pluginId, module, exportName, home = openclawHome() }) {
|
|
45
|
+
const file = openclawConfigPath(home);
|
|
46
|
+
let config;
|
|
47
|
+
try { config = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { return { changed: false, error: `couldn't read ${file}: ${error.message}` }; }
|
|
48
|
+
if (providerConfigured(config, provider)) return { changed: false };
|
|
49
|
+
let apply;
|
|
50
|
+
try { apply = (await import(pathToFileURL(path.join(home, "extensions", pluginId, module)).href))[exportName]; }
|
|
51
|
+
catch (error) { return { changed: false, error: `couldn't load the ${pluginId} plugin's config step: ${error.message}` }; }
|
|
52
|
+
if (typeof apply !== "function") return { changed: false, error: `the ${pluginId} plugin has no ${exportName}; it may have changed shape` };
|
|
53
|
+
let next;
|
|
54
|
+
try { next = apply(structuredClone(config)); } catch (error) { return { changed: false, error: `the ${pluginId} plugin's config step failed: ${error.message}` }; }
|
|
55
|
+
if (!providerConfigured(next, provider)) return { changed: false, error: `the ${pluginId} plugin's config step didn't add a ${provider} provider entry` };
|
|
56
|
+
// It may only add: every top-level section the file had must still be there.
|
|
57
|
+
const dropped = Object.keys(config).filter((k) => !(k in next));
|
|
58
|
+
if (dropped.length) return { changed: false, error: `refused: the ${pluginId} plugin's config step would remove ${dropped.join(", ")}` };
|
|
59
|
+
const backup = `${file}.before-nomarmy-${provider}`;
|
|
60
|
+
fs.copyFileSync(file, backup);
|
|
61
|
+
const tmp = `${file}.nomarmy-tmp`;
|
|
62
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: fs.statSync(file).mode & 0o777 });
|
|
63
|
+
fs.renameSync(tmp, file);
|
|
64
|
+
return { changed: true, backup };
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What a failed `openclaw agent exec` actually said, from its stderr run log.
|
|
2
|
+
//
|
|
3
|
+
// A model the provider refuses fails as "openclaw exited 1" with the reason
|
|
4
|
+
// buried in a colored log line. Two real cases, both listed in OpenClaw's
|
|
5
|
+
// catalog while every job on them failed (a Senti run):
|
|
6
|
+
//
|
|
7
|
+
// ... decision=surface_error reason=model_not_found ... from=openai/gpt-6-sol
|
|
8
|
+
// rawError={... "message":"The 'gpt-6-sol' model is not supported when
|
|
9
|
+
// using Codex with a ChatGPT account."}
|
|
10
|
+
//
|
|
11
|
+
// Unknown model: meta/muse-spark-1.3
|
|
12
|
+
//
|
|
13
|
+
// modelRejection() turns either into { model, message } so the job can say
|
|
14
|
+
// model_not_found with the vendor's own sentence, and health can count it.
|
|
15
|
+
|
|
16
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The provider refusing the model itself, or null for any other failure.
|
|
20
|
+
* @param {string} text stderr (and/or stdout) of the failed call
|
|
21
|
+
* @param {string} [attemptedModel] the provider/model the job asked for
|
|
22
|
+
*/
|
|
23
|
+
export function modelRejection(text, attemptedModel = null) {
|
|
24
|
+
const s = String(text ?? "").replace(ANSI, "");
|
|
25
|
+
const unknown = /Unknown model:?\s+([\w.-]+\/[\w.:-]+)/.exec(s);
|
|
26
|
+
const notSupported = /The '([^'\\"]+)' model is not supported[^"\\\n]*/.exec(s);
|
|
27
|
+
const flagged = /reason=model_not_found\b/.test(s);
|
|
28
|
+
if (!unknown && !notSupported && !flagged) return null;
|
|
29
|
+
const from = /reason=model_not_found\b[^\n]*?\bfrom=([\w.-]+\/[\w.:-]+)/.exec(s)?.[1];
|
|
30
|
+
const model = (unknown?.[1] ?? from ?? attemptedModel ?? notSupported?.[1] ?? "").replace(/[.:]+$/, "") || null;
|
|
31
|
+
const message = notSupported ? notSupported[0].replace(/\.?$/, ".")
|
|
32
|
+
: unknown ? `Unknown model: ${model}.`
|
|
33
|
+
: "the provider refused this model.";
|
|
34
|
+
return { model, message };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The one-line job error for a refused model. */
|
|
38
|
+
export function modelRejectionLine({ model, message }) {
|
|
39
|
+
return `model_not_found: ${model ?? "this model"}: ${message} It may still be listed in OpenClaw's catalog; reassign the role (nomarmy army assign <role> <agent> <model>).`;
|
|
40
|
+
}
|