nomarmy 0.1.0-alpha.6 → 0.1.0-alpha.7
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/README.md +1 -1
- package/bin/nomarmy.mjs +47 -11
- package/docker/Dockerfile.go +5 -3
- package/docker/Dockerfile.rust +16 -1
- package/install.sh +16 -2
- package/lib/agents.mjs +6 -5
- package/lib/army.mjs +2 -1
- package/lib/connect.mjs +2 -0
- package/lib/dispatch-schema.mjs +12 -11
- package/lib/health.mjs +10 -3
- package/lib/install-freshness.mjs +83 -0
- package/lib/schema.mjs +9 -11
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +15 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
## TL;DR
|
|
14
14
|
|
|
15
|
-
1. **Have** Git, Node
|
|
15
|
+
1. **Have** Git, Node 24.16+ (or 26.1+; OpenClaw, which nomArmy installs, needs it) and [Podman](https://podman.io) (on macOS: `brew install podman && podman machine init --memory 8192 && podman machine start`; Podman's 2 GiB default is too small for nomArmy's sandboxes).
|
|
16
16
|
2. **Install and set up:**
|
|
17
17
|
```bash
|
|
18
18
|
npm install -g nomarmy@alpha
|
package/bin/nomarmy.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { buildConfigProposal } from "../lib/propose.mjs";
|
|
|
18
18
|
import { detectHardware } from "../lib/hardware.mjs";
|
|
19
19
|
import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
|
|
20
20
|
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
|
-
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv } from "../lib/connect.mjs";
|
|
21
|
+
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir } from "../lib/connect.mjs";
|
|
22
|
+
import { compareVersions, readPackageVersion, readInstallVersions } from "../lib/install-freshness.mjs";
|
|
22
23
|
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
23
24
|
import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
|
|
24
25
|
import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
|
|
@@ -115,8 +116,9 @@ Usage: nomarmy <command> [options]
|
|
|
115
116
|
registration (never done silently)
|
|
116
117
|
--restart-inference with --json, also stop/start local
|
|
117
118
|
inference (never done silently)
|
|
118
|
-
update
|
|
119
|
-
|
|
119
|
+
update Update nomArmy and reconnect your coordinators: installs
|
|
120
|
+
npm's latest alpha, or for a git checkout pulls (fast-forward
|
|
121
|
+
only; refuses on local changes). Then restart open sessions.
|
|
120
122
|
agents <list|add|update|remove>
|
|
121
123
|
Every account a job can run on, in one list:
|
|
122
124
|
~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
|
|
@@ -1642,14 +1644,9 @@ function git(args) {
|
|
|
1642
1644
|
*/
|
|
1643
1645
|
async function cmdUpdate() {
|
|
1644
1646
|
const say = (s) => { if (!json) console.log(s); };
|
|
1645
|
-
// Installed from npm:
|
|
1646
|
-
//
|
|
1647
|
-
if (!fs.existsSync(path.join(nomarmyRoot, ".git")))
|
|
1648
|
-
const how = "npm install -g nomarmy@alpha && nomarmy connect";
|
|
1649
|
-
if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
|
|
1650
|
-
console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
|
|
1651
|
-
return;
|
|
1652
|
-
}
|
|
1647
|
+
// Installed from npm: npm updates the package, then connect resyncs the
|
|
1648
|
+
// copy each coordinator runs.
|
|
1649
|
+
if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) return updateFromNpm();
|
|
1653
1650
|
const status = git(["status", "--porcelain"]);
|
|
1654
1651
|
if (status) {
|
|
1655
1652
|
if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
|
|
@@ -1704,6 +1701,45 @@ async function cmdUpdate() {
|
|
|
1704
1701
|
console.log(c.yellow("\nThe MCP server is a per-session child process: every open Claude Code / Codex / Cursor session needs a restart to pick this up, not just this one."));
|
|
1705
1702
|
}
|
|
1706
1703
|
|
|
1704
|
+
// The coordinators nomArmy is registered with. Cursor has no CLI to probe,
|
|
1705
|
+
// so it counts when its own config already lists nomArmy.
|
|
1706
|
+
function connectedTargets() {
|
|
1707
|
+
return [commandExists("claude") && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async function updateFromNpm() {
|
|
1711
|
+
const current = readPackageVersion(nomarmyRoot);
|
|
1712
|
+
let latest = null;
|
|
1713
|
+
try { latest = execFileSync("npm", ["view", "nomarmy", "dist-tags.alpha"], { encoding: "utf8", timeout: 20000 }).trim(); } catch { /* offline */ }
|
|
1714
|
+
if (!latest) {
|
|
1715
|
+
const fix = "npm install -g nomarmy@alpha && nomarmy connect claude";
|
|
1716
|
+
if (json) { out({ error: "could not read nomarmy's latest release from npm", fix }); process.exit(1); }
|
|
1717
|
+
console.log(c.red("Couldn't reach npm to find nomArmy's latest release.") + ` Update by hand:\n ${fix}`);
|
|
1718
|
+
process.exit(1);
|
|
1719
|
+
}
|
|
1720
|
+
const { copyVersion } = readInstallVersions(defaultInstallDir());
|
|
1721
|
+
const upgrade = compareVersions(current, latest) < 0;
|
|
1722
|
+
const staleCopy = !copyVersion || compareVersions(copyVersion, upgrade ? latest : current) < 0;
|
|
1723
|
+
if (!upgrade && !staleCopy) {
|
|
1724
|
+
if (json) return out({ updated: false, version: current, reason: "already up to date" });
|
|
1725
|
+
console.log(c.green(`✓ nomArmy ${current} is the latest, and your coordinators run it.`));
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (upgrade) {
|
|
1729
|
+
if (!json) console.log(c.bold(`🍪 Updating nomArmy ${current} → ${latest}\n`));
|
|
1730
|
+
execFileSync("npm", ["install", "-g", `nomarmy@${latest}`, "--no-audit", "--no-fund"], { stdio: json ? "ignore" : "inherit" });
|
|
1731
|
+
}
|
|
1732
|
+
// A child process, so the reconnect runs the code just installed rather
|
|
1733
|
+
// than the old code this process loaded.
|
|
1734
|
+
const targets = connectedTargets();
|
|
1735
|
+
if (targets.length) {
|
|
1736
|
+
if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
|
|
1737
|
+
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1738
|
+
}
|
|
1739
|
+
if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
|
|
1740
|
+
console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1707
1743
|
function commandExists(cmd) {
|
|
1708
1744
|
try { execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }); return true; }
|
|
1709
1745
|
catch { return false; }
|
package/docker/Dockerfile.go
CHANGED
|
@@ -9,15 +9,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
9
9
|
# (verified live: apt installed 1.19 here while go.dev's stable was 1.27) --
|
|
10
10
|
# same reasoning as using rustup instead of apt for Rust in Dockerfile.rust.
|
|
11
11
|
# Pinned, not "latest", for a reproducible build; bump GO_VERSION by hand
|
|
12
|
-
# periodically against https://go.dev/dl
|
|
12
|
+
# periodically against https://go.dev/dl/, with both SHA256 values from
|
|
13
|
+
# https://go.dev/dl/?mode=json.
|
|
13
14
|
ENV GO_VERSION=1.27.1
|
|
14
15
|
RUN ARCH="$(dpkg --print-architecture)" \
|
|
15
16
|
&& case "$ARCH" in \
|
|
16
|
-
amd64) GOARCH=amd64 ;; \
|
|
17
|
-
arm64) GOARCH=arm64 ;; \
|
|
17
|
+
amd64) GOARCH=amd64; SHA256=63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445 ;; \
|
|
18
|
+
arm64) GOARCH=arm64; SHA256=3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec ;; \
|
|
18
19
|
*) echo "unsupported architecture for Go install: $ARCH" >&2; exit 1 ;; \
|
|
19
20
|
esac \
|
|
20
21
|
&& curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o /tmp/go.tgz \
|
|
22
|
+
&& echo "${SHA256} /tmp/go.tgz" | sha256sum -c - \
|
|
21
23
|
&& tar -C /usr/local -xzf /tmp/go.tgz \
|
|
22
24
|
&& rm /tmp/go.tgz
|
|
23
25
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
package/docker/Dockerfile.rust
CHANGED
|
@@ -14,6 +14,21 @@ USER node
|
|
|
14
14
|
ENV RUSTUP_HOME=/home/node/.rustup
|
|
15
15
|
ENV CARGO_HOME=/home/node/.cargo
|
|
16
16
|
ENV PATH="${CARGO_HOME}/bin:${PATH}"
|
|
17
|
-
|
|
17
|
+
# rustup-init is pinned by version and checksum instead of piping
|
|
18
|
+
# sh.rustup.rs into sh; rustup then verifies the toolchain it downloads.
|
|
19
|
+
# Bump RUSTUP_VERSION and both hashes together, from
|
|
20
|
+
# https://static.rust-lang.org/rustup/archive/<version>/<target>/rustup-init.sha256.
|
|
21
|
+
ENV RUSTUP_VERSION=1.29.1
|
|
22
|
+
RUN ARCH="$(dpkg --print-architecture)" \
|
|
23
|
+
&& case "$ARCH" in \
|
|
24
|
+
amd64) TARGET=x86_64-unknown-linux-gnu; SHA256=dda7234360b7f578ca8b0ddcb80145646fa61a67c1720a5abc7051b35c9fcb71 ;; \
|
|
25
|
+
arm64) TARGET=aarch64-unknown-linux-gnu; SHA256=15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433 ;; \
|
|
26
|
+
*) echo "unsupported architecture for Rust install: $ARCH" >&2; exit 1 ;; \
|
|
27
|
+
esac \
|
|
28
|
+
&& curl --proto '=https' --tlsv1.2 -fsSL "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${TARGET}/rustup-init" -o /tmp/rustup-init \
|
|
29
|
+
&& echo "${SHA256} /tmp/rustup-init" | sha256sum -c - \
|
|
30
|
+
&& chmod +x /tmp/rustup-init \
|
|
31
|
+
&& /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain stable \
|
|
32
|
+
&& rm /tmp/rustup-init
|
|
18
33
|
WORKDIR /workspace
|
|
19
34
|
CMD ["sleep", "infinity"]
|
package/install.sh
CHANGED
|
@@ -99,9 +99,23 @@ fi
|
|
|
99
99
|
# on a fresh macOS install nothing has initialized that VM yet at this point.
|
|
100
100
|
if nomarmy_is_cloud; then need aws || { echo 'ERROR: the AWS CLI is required for cloud profiles.'; exit 1; }; fi
|
|
101
101
|
"$ROOT/scripts/install-llama-cpp.sh" "$NOMARMY_PROFILE"
|
|
102
|
+
# A pinned npm release, which npm checks against the registry's integrity
|
|
103
|
+
# hash, rather than piping a remote installer script into bash.
|
|
104
|
+
OPENCLAW_VERSION="${NOMARMY_OPENCLAW_VERSION:-2026.9.6}"
|
|
102
105
|
if ! command -v openclaw >/dev/null 2>&1; then
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
if ! node -e 'const [a, b] = process.versions.node.split(".").map(Number); process.exit((a === 24 && b >= 16) || (a === 26 && b >= 1) || a > 26 ? 0 : 1)'; then
|
|
107
|
+
echo "ERROR: OpenClaw $OPENCLAW_VERSION needs Node 24.16+ or 26.1+; this is Node $(node -v)."
|
|
108
|
+
echo ' Upgrade Node and re-run this installer, or install OpenClaw yourself first:'
|
|
109
|
+
echo ' https://openclaw.ai (then re-run this installer)'
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
echo "==> Installing OpenClaw $OPENCLAW_VERSION from npm"
|
|
113
|
+
if [[ -w "$(npm prefix -g)" ]]; then
|
|
114
|
+
npm install -g "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
|
|
115
|
+
else
|
|
116
|
+
# No writable global prefix: install for this user, as OpenClaw's own installer does.
|
|
117
|
+
npm install -g --prefix "$HOME/.npm-global" "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
|
|
118
|
+
fi
|
|
105
119
|
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
|
|
106
120
|
fi
|
|
107
121
|
need openclaw
|
package/lib/agents.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import fs from "node:fs";
|
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import YAML from "yaml";
|
|
32
32
|
import { z } from "zod";
|
|
33
|
+
import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
|
|
33
34
|
|
|
34
35
|
import { providerEntrySchema, formatDispatchIssues, openclawProviderId, PROVIDER_TYPES, ID_RE, HOST_TOOL_PROVIDERS, agentRunsToolsOnHost } from "./dispatch-schema.mjs";
|
|
35
36
|
|
|
@@ -45,10 +46,10 @@ export const RESERVED_AGENT_NAMES = Object.freeze(["__proto__", "constructor", "
|
|
|
45
46
|
export const BUILTIN_LOCAL_AGENT = Object.freeze({ kind: "local", slot: "coder" });
|
|
46
47
|
|
|
47
48
|
const requiredString = () =>
|
|
48
|
-
z.string({
|
|
49
|
+
z.string({ error: typeError("a string") })
|
|
49
50
|
.refine((value) => value.trim().length > 0, { message: "must not be empty" });
|
|
50
51
|
const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])]).default(true);
|
|
51
|
-
const positiveInt = () => z.number({
|
|
52
|
+
const positiveInt = () => z.number({ error: typeError("a number") }).int("must be a whole number").positive("must be a positive number");
|
|
52
53
|
|
|
53
54
|
const localAgentSchema = z.object({
|
|
54
55
|
kind: z.literal("local"),
|
|
@@ -62,7 +63,7 @@ const localAgentSchema = z.object({
|
|
|
62
63
|
// providerEntrySchema in agentsFileSchema's superRefine, so they exist once.
|
|
63
64
|
const apiAgentSchema = z.object({
|
|
64
65
|
kind: z.literal("api"),
|
|
65
|
-
provider: z.enum(API_PROVIDER_TYPES, {
|
|
66
|
+
provider: z.enum(API_PROVIDER_TYPES, { error: `must be one of ${API_PROVIDER_TYPES.join(", ")}` }),
|
|
66
67
|
model: requiredString().optional(),
|
|
67
68
|
auth_env: requiredString(),
|
|
68
69
|
base_url: z.string().optional(),
|
|
@@ -95,7 +96,7 @@ export function hostToolsImplementProblem(name, agent) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
export const agentSchema = z.discriminatedUnion("kind", [localAgentSchema, apiAgentSchema, subscriptionAgentSchema], {
|
|
98
|
-
|
|
99
|
+
error: (issue) => (isUnknownDiscriminator(issue) ? `kind must be one of ${AGENT_KINDS.join(", ")}` : undefined),
|
|
99
100
|
});
|
|
100
101
|
|
|
101
102
|
/** An api agent as the pool entry the dispatch path understands. `model` may be absent (see resolveAgentModel). */
|
|
@@ -148,7 +149,7 @@ export function formatAgentIssues(error) {
|
|
|
148
149
|
const lines = error.issues.map((issue) => {
|
|
149
150
|
const where = issue.path.length ? issue.path.join(".") : "config";
|
|
150
151
|
if (issue.code === "unrecognized_keys") return `${where}: unexpected field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
|
|
151
|
-
if (issue
|
|
152
|
+
if (isMissingField(issue)) return `${where}: is required`;
|
|
152
153
|
return `${where}: ${issue.message}`;
|
|
153
154
|
});
|
|
154
155
|
return [...new Set(lines)];
|
package/lib/army.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import os from "node:os";
|
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import YAML from "yaml";
|
|
31
31
|
import { z } from "zod";
|
|
32
|
+
import { typeError } from "./zod-issues.mjs";
|
|
32
33
|
import { agentRunsToolsOnHost } from "./dispatch-schema.mjs";
|
|
33
34
|
import { usageStatus } from "./usage-limits.mjs";
|
|
34
35
|
|
|
@@ -116,7 +117,7 @@ const roleSchema = z.object({
|
|
|
116
117
|
disabled: z.boolean().optional(),
|
|
117
118
|
}).strict();
|
|
118
119
|
|
|
119
|
-
const positiveNumber = () => z.number({
|
|
120
|
+
const positiveNumber = () => z.number({ error: typeError("a number") }).positive("must be positive");
|
|
120
121
|
// Limits on one /feature run (lib/runs.mjs). Personal, like the General:
|
|
121
122
|
// global or local only, never a committed project file.
|
|
122
123
|
const runLimitsSchema = z.object({
|
package/lib/connect.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { execFileSync } from "node:child_process";
|
|
|
28
28
|
import { loadAgents } from "./agents.mjs";
|
|
29
29
|
import { globalConfigDir } from "./army.mjs";
|
|
30
30
|
import { buildNotifierApp } from "./notifier-app.mjs";
|
|
31
|
+
import { recordCopySource } from "./install-freshness.mjs";
|
|
31
32
|
|
|
32
33
|
export function defaultInstallDir() {
|
|
33
34
|
return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
|
|
@@ -142,6 +143,7 @@ export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
|
142
143
|
fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
|
|
143
144
|
fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
|
|
144
145
|
fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
|
|
146
|
+
recordCopySource(installDir, nomarmyRoot);
|
|
145
147
|
fs.rmSync(path.join(installDir, "lib"), { recursive: true, force: true });
|
|
146
148
|
fs.cpSync(path.join(nomarmyRoot, "lib"), path.join(installDir, "lib"), { recursive: true });
|
|
147
149
|
// lib/sandbox-images.mjs resolves docker/ relative to its own location
|
package/lib/dispatch-schema.mjs
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// never in a file nomArmy writes or reads back.
|
|
16
16
|
|
|
17
17
|
import { z } from "zod";
|
|
18
|
+
import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
|
|
18
19
|
|
|
19
20
|
const requiredString = () =>
|
|
20
21
|
z
|
|
21
|
-
.string({
|
|
22
|
+
.string({ error: typeError("a string") })
|
|
22
23
|
.refine((value) => value.trim().length > 0, { message: "must not be empty" });
|
|
23
24
|
|
|
24
25
|
// Exported (not just used internally) so bin/nomarmy.mjs's interactive
|
|
@@ -101,11 +102,11 @@ export function isNativeProviderType(type) {
|
|
|
101
102
|
export const CUSTOM_ENDPOINT_PROVIDER_TYPES = Object.freeze(["bedrock", "azure-openai", "openai-compatible"]);
|
|
102
103
|
|
|
103
104
|
const idSchema = z
|
|
104
|
-
.string({
|
|
105
|
+
.string({ error: typeError("a string") })
|
|
105
106
|
.regex(ID_RE, "must be 1-64 characters of letters, numbers, dot, underscore or hyphen");
|
|
106
107
|
|
|
107
108
|
const weightSchema = z
|
|
108
|
-
.number({
|
|
109
|
+
.number({ error: typeError("a number") })
|
|
109
110
|
.positive("must be a positive number")
|
|
110
111
|
.finite("must be a finite number");
|
|
111
112
|
|
|
@@ -113,13 +114,13 @@ const weightSchema = z
|
|
|
113
114
|
// one entry at once -- a stand-in for real rate-limit-aware admission (see
|
|
114
115
|
// README's dispatch-pool section for why that's out of scope for now).
|
|
115
116
|
const maxConcurrentSchema = z
|
|
116
|
-
.number({
|
|
117
|
+
.number({ error: typeError("a number") })
|
|
117
118
|
.int("must be a whole number")
|
|
118
119
|
.positive("must be a positive number")
|
|
119
120
|
.default(2);
|
|
120
121
|
|
|
121
122
|
const authEnvSchema = z
|
|
122
|
-
.string({
|
|
123
|
+
.string({ error: typeError("a string") })
|
|
123
124
|
.regex(
|
|
124
125
|
AUTH_ENV_NAME_RE,
|
|
125
126
|
"must be an environment variable NAME (uppercase letters, digits, underscores), never the credential itself",
|
|
@@ -127,7 +128,7 @@ const authEnvSchema = z
|
|
|
127
128
|
|
|
128
129
|
const urlSchema = () =>
|
|
129
130
|
z
|
|
130
|
-
.string({
|
|
131
|
+
.string({ error: typeError("a string") })
|
|
131
132
|
.url("must be a valid URL");
|
|
132
133
|
|
|
133
134
|
// Whether/how the entry's model uses a thinking/reasoning mode. `true`
|
|
@@ -152,7 +153,7 @@ const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])])
|
|
|
152
153
|
// knows about yet (see lib/model-catalog.mjs), or an operator who wants to
|
|
153
154
|
// be more conservative than the model's rated maximum.
|
|
154
155
|
const contextWindowSchema = z
|
|
155
|
-
.number({
|
|
156
|
+
.number({ error: typeError("a number") })
|
|
156
157
|
.int("must be a whole number")
|
|
157
158
|
.positive("must be a positive number")
|
|
158
159
|
.optional();
|
|
@@ -189,7 +190,7 @@ const llamaCppEntrySchema = z
|
|
|
189
190
|
// `agents add` installs it before registering the key.
|
|
190
191
|
const genericOpenclawEntrySchema = hostedProviderSchema("openclaw", { requireBaseUrl: false }).extend({
|
|
191
192
|
openclaw_provider: z
|
|
192
|
-
.string({
|
|
193
|
+
.string({ error: typeError("a string") })
|
|
193
194
|
.regex(OPENCLAW_PROVIDER_ID_RE, "must be an OpenClaw provider id (lowercase letters, digits, dot, underscore, hyphen)")
|
|
194
195
|
.refine((id) => !PROVIDER_TYPES.includes(id) || NATIVE_PROVIDER_TYPES.includes(id), "names a nomArmy provider type with its own setup -- use that type instead"),
|
|
195
196
|
plugin: z.string().regex(/^\S+$/, "must be one plugin install spec, e.g. clawhub:@openclaw/deepseek-provider").optional(),
|
|
@@ -205,7 +206,7 @@ export const providerEntrySchema = z.discriminatedUnion("provider", [
|
|
|
205
206
|
export const poolsSchema = z.record(
|
|
206
207
|
z.string().min(1, "pool name must not be empty"),
|
|
207
208
|
z
|
|
208
|
-
.array(providerEntrySchema, {
|
|
209
|
+
.array(providerEntrySchema, { error: typeError("an array of provider entries") })
|
|
209
210
|
.min(1, "must list at least one provider entry"),
|
|
210
211
|
);
|
|
211
212
|
|
|
@@ -250,11 +251,11 @@ export function formatDispatchIssues(error) {
|
|
|
250
251
|
lines.push(`${where}: unexpected field(s) ${keys}`);
|
|
251
252
|
continue;
|
|
252
253
|
}
|
|
253
|
-
if (issue
|
|
254
|
+
if (isUnknownDiscriminator(issue)) {
|
|
254
255
|
lines.push(`${where}: must be one of ${PROVIDER_TYPES.join(", ")}`);
|
|
255
256
|
continue;
|
|
256
257
|
}
|
|
257
|
-
if (issue
|
|
258
|
+
if (isMissingField(issue)) {
|
|
258
259
|
lines.push(`${where}: is required`);
|
|
259
260
|
continue;
|
|
260
261
|
}
|
package/lib/health.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// army a role or the General pointing at something unusable
|
|
12
12
|
// config agents.yml unloadable or unsafe
|
|
13
13
|
// leftovers retained worktrees, job storage, stale "running" jobs
|
|
14
|
+
// nomarmy-update a newer nomArmy on npm; nomarmy-copy: coordinators run
|
|
15
|
+
// an older copy than the installed CLI (install-freshness.mjs)
|
|
14
16
|
//
|
|
15
17
|
// Each issue: { id, severity: "error"|"warn"|"info", title, detail, fix,
|
|
16
18
|
// short }. `id` is stable across runs, so a notification goes out once per
|
|
@@ -23,6 +25,7 @@ import { executionMode } from "./execution.mjs";
|
|
|
23
25
|
import { modelRejection } from "./openclaw-errors.mjs";
|
|
24
26
|
import { providerConfigured, readOpenclawConfig } from "./openclaw-config.mjs";
|
|
25
27
|
import { readUsageSnapshots, usageStatus } from "./usage-limits.mjs";
|
|
28
|
+
import { freshnessIssues, readInstallVersions } from "./install-freshness.mjs";
|
|
26
29
|
|
|
27
30
|
const DAY = 86400000;
|
|
28
31
|
|
|
@@ -213,7 +216,7 @@ export function leftoverIssues({ retainedWorktrees = 0, jobsBytes = 0, staleRunn
|
|
|
213
216
|
* Run every check. `env` supplies what each needs, with real defaults;
|
|
214
217
|
* tests pass their own.
|
|
215
218
|
*/
|
|
216
|
-
export async function runHealthChecks({ now = Date.now(), mode = "local", 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, usageSnapshots = null } = {}) {
|
|
219
|
+
export async function runHealthChecks({ now = Date.now(), mode = "local", 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, usageSnapshots = null, install = null } = {}) {
|
|
217
220
|
const issues = [];
|
|
218
221
|
if (autoPruned?.freedBytes) issues.push({ id: `auto-prune:${new Date(now).toISOString()}`, severity: "info",
|
|
219
222
|
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(", ")}`,
|
|
@@ -230,12 +233,14 @@ export async function runHealthChecks({ now = Date.now(), mode = "local", opencl
|
|
|
230
233
|
short });
|
|
231
234
|
}
|
|
232
235
|
}
|
|
233
|
-
const [auth, version, latest, plugins] = await Promise.all([
|
|
236
|
+
const [auth, version, latest, plugins, nomarmyLatest] = await Promise.all([
|
|
234
237
|
run(openclawCmd, ["models", "auth", "list", "--json"]),
|
|
235
238
|
run(openclawCmd, ["--version"]),
|
|
236
239
|
run("npm", ["view", "openclaw", "version"], { timeoutMs: 15000 }),
|
|
237
240
|
run(openclawCmd, ["plugins", "inspect", "codex"]),
|
|
241
|
+
install ? run("npm", ["view", "nomarmy", "dist-tags.alpha"], { timeoutMs: 15000 }) : null,
|
|
238
242
|
]);
|
|
243
|
+
if (install) issues.push(...freshnessIssues({ ...install, latestVersion: nomarmyLatest?.ok ? nomarmyLatest.stdout : null }));
|
|
239
244
|
if (auth.ok) { try { issues.push(...loginExpiryIssues(JSON.parse(auth.stdout.slice(auth.stdout.indexOf("{"))), { now })); } catch { /* unparseable: skip */ } }
|
|
240
245
|
const pluginVersion = /Version:\s*(\S+)/.exec(plugins.stdout ?? "")?.[1];
|
|
241
246
|
if (version.ok) issues.push(...versionIssues({ installed: version.stdout, latest: latest.ok ? latest.stdout : null, plugins: pluginVersion ? [{ id: "codex", version: pluginVersion }] : [] }));
|
|
@@ -318,8 +323,10 @@ export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, n
|
|
|
318
323
|
let autoPruned = null;
|
|
319
324
|
if (ageMs !== null) { try { autoPruned = { ...pruneJobRuntime({ stateRoot, olderThanMs: ageMs, now }), olderThanHours: ageMs / 3600000 }; } catch { /* best-effort */ } }
|
|
320
325
|
const mode = executionMode(env).mode;
|
|
326
|
+
const { defaultInstallDir } = await import("./connect.mjs");
|
|
327
|
+
const install = readInstallVersions(defaultInstallDir());
|
|
321
328
|
const result = await runHealthChecks({ now, mode, armySummary, agentsError, jobsRoot: path.join(stateRoot, "jobs"), pidAlive,
|
|
322
|
-
agents, openclawConfig: readOpenclawConfig(), vendors: SUBSCRIPTION_VENDORS, modelsInUse, autoPruned, usageSnapshots: readUsageSnapshots(stateRoot) });
|
|
329
|
+
agents, openclawConfig: readOpenclawConfig(), vendors: SUBSCRIPTION_VENDORS, modelsInUse, autoPruned, usageSnapshots: readUsageSnapshots(stateRoot), install });
|
|
323
330
|
const toNotify = recordHealth(path.join(stateRoot, "health.json"), result, { now });
|
|
324
331
|
return { result, toNotify };
|
|
325
332
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Whether the nomArmy a coordinator runs is current. Claude Code, Codex and
|
|
2
|
+
// Cursor run a copy of the server that `nomarmy connect` puts in the install
|
|
3
|
+
// dir, and each session keeps the code it started with. So an upgrade can
|
|
4
|
+
// stall at three points, each checked here:
|
|
5
|
+
//
|
|
6
|
+
// nomarmy-update npm's alpha release is newer than the installed CLI
|
|
7
|
+
// nomarmy-copy the CLI is newer than the copy coordinators run
|
|
8
|
+
// (`nomarmy connect` wasn't re-run)
|
|
9
|
+
// restart the copy on disk changed after this server started
|
|
10
|
+
// (the session wasn't restarted); per session, so it is
|
|
11
|
+
// reported by that session's server, not in health.json
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Written into the install dir by `nomarmy connect`: where the copy came from. */
|
|
17
|
+
export const SOURCE_FILE = "source.json";
|
|
18
|
+
|
|
19
|
+
export function readPackageVersion(root) {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version ?? null; }
|
|
21
|
+
catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function recordCopySource(installDir, nomarmyRoot) {
|
|
25
|
+
fs.writeFileSync(path.join(installDir, SOURCE_FILE), JSON.stringify({ root: nomarmyRoot, version: readPackageVersion(nomarmyRoot) }, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The copy's version and, when connect recorded it, the version now at its source. */
|
|
29
|
+
export function readInstallVersions(installDir) {
|
|
30
|
+
let source = null;
|
|
31
|
+
try { source = JSON.parse(fs.readFileSync(path.join(installDir, SOURCE_FILE), "utf8")); } catch { /* connected before source.json existed */ }
|
|
32
|
+
return { copyVersion: readPackageVersion(installDir), sourceVersion: source?.root ? readPackageVersion(source.root) : null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Semver order, prerelease included (0.1.0-alpha.7 < 0.1.0-alpha.10 < 0.1.0). */
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const split = (v) => { const [main, pre] = String(v).trim().replace(/^v/, "").split("-", 2); return { main: main.split(".").map(Number), pre: pre ? pre.split(".") : null }; };
|
|
38
|
+
const x = split(a), y = split(b);
|
|
39
|
+
for (let i = 0; i < 3; i++) if ((x.main[i] || 0) !== (y.main[i] || 0)) return (x.main[i] || 0) < (y.main[i] || 0) ? -1 : 1;
|
|
40
|
+
if (!x.pre || !y.pre) return x.pre === y.pre ? 0 : x.pre ? -1 : 1;
|
|
41
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
42
|
+
const p = x.pre[i], q = y.pre[i];
|
|
43
|
+
if (p === undefined || q === undefined) return p === undefined ? -1 : 1;
|
|
44
|
+
if (p === q) continue;
|
|
45
|
+
const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
|
|
46
|
+
if (pn && qn) return Number(p) < Number(q) ? -1 : 1;
|
|
47
|
+
if (pn !== qn) return pn ? -1 : 1;
|
|
48
|
+
return p < q ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const valid = (v) => typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
|
|
54
|
+
|
|
55
|
+
/** Health issues for a stale CLI or a stale installed copy. */
|
|
56
|
+
export function freshnessIssues({ copyVersion = null, sourceVersion = null, latestVersion = null }) {
|
|
57
|
+
const issues = [];
|
|
58
|
+
if (valid(latestVersion) && valid(sourceVersion) && compareVersions(sourceVersion, latestVersion) < 0) {
|
|
59
|
+
const latest = latestVersion.trim();
|
|
60
|
+
issues.push({ id: `nomarmy-update:${latest}`, severity: "info", title: `nomArmy ${latest} is out (you have ${sourceVersion})`,
|
|
61
|
+
detail: "Updating installs it, reconnects your coordinators and tells you which sessions to restart.",
|
|
62
|
+
fix: "nomarmy update", short: "nomarmy update" });
|
|
63
|
+
}
|
|
64
|
+
if (valid(sourceVersion) && valid(copyVersion) && compareVersions(copyVersion, sourceVersion) < 0) {
|
|
65
|
+
issues.push({ id: `nomarmy-copy:${sourceVersion}`, severity: "warn", title: `Your coordinators run nomArmy ${copyVersion}, but ${sourceVersion} is installed`,
|
|
66
|
+
detail: "Claude Code, Codex and Cursor run a copy of nomArmy that only `nomarmy connect` refreshes.",
|
|
67
|
+
fix: "nomarmy connect claude (and codex, cursor), then restart those sessions", short: "nomarmy reconnect" });
|
|
68
|
+
}
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* For the running server: a notice when its copy on disk changed after it
|
|
74
|
+
* started, so this session still runs the old code. Null when current.
|
|
75
|
+
*/
|
|
76
|
+
export function restartNotice({ serverFile, startedAtMs, runningVersion, stat = fs.statSync, readVersion = readPackageVersion }) {
|
|
77
|
+
let changedMs;
|
|
78
|
+
try { changedMs = stat(serverFile).mtimeMs; } catch { return null; }
|
|
79
|
+
if (!(changedMs > startedAtMs)) return null;
|
|
80
|
+
const onDisk = readVersion(path.join(path.dirname(serverFile), ".."));
|
|
81
|
+
const versions = onDisk && onDisk !== runningVersion ? ` (this session runs ${runningVersion}; ${onDisk} is installed)` : "";
|
|
82
|
+
return `nomArmy was updated after this session started${versions}. Restart this session to use the new version; until then it runs the old code.`;
|
|
83
|
+
}
|
package/lib/schema.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// approval before the job runs. See `collectElevated`.
|
|
13
13
|
|
|
14
14
|
import { z } from "zod";
|
|
15
|
+
import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
|
|
15
16
|
import { armySchema } from "./army.mjs";
|
|
16
17
|
|
|
17
18
|
export const SERVICE_SOURCES = Object.freeze([
|
|
@@ -57,12 +58,12 @@ export const SERVICE_FIELDS = Object.freeze({
|
|
|
57
58
|
// the dotted path, so repeating it reads as stutter.
|
|
58
59
|
const requiredString = () =>
|
|
59
60
|
z
|
|
60
|
-
.string({
|
|
61
|
+
.string({ error: typeError("a string") })
|
|
61
62
|
.refine((value) => value.trim().length > 0, { message: "must not be empty" });
|
|
62
63
|
|
|
63
64
|
const enumOf = (values) =>
|
|
64
65
|
z.enum(values, {
|
|
65
|
-
|
|
66
|
+
error: `must be one of ${values.join(", ")}`,
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
// A plain hostname: no scheme, no path, no port, no whitespace.
|
|
@@ -90,7 +91,7 @@ export function hostnameProblem(value) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
const hostnameSchema = z
|
|
93
|
-
.string({
|
|
94
|
+
.string({ error: typeError("a string hostname") })
|
|
94
95
|
.superRefine((value, ctx) => {
|
|
95
96
|
const problem = hostnameProblem(value);
|
|
96
97
|
if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem });
|
|
@@ -162,7 +163,7 @@ export const serviceSchema = z.discriminatedUnion("source", [
|
|
|
162
163
|
export const pythonEnvironmentSchema = z
|
|
163
164
|
.object({
|
|
164
165
|
requirements: z
|
|
165
|
-
.array(requiredString(), {
|
|
166
|
+
.array(requiredString(), { error: typeError("an array of strings") })
|
|
166
167
|
.min(1, "must list at least one requirements file"),
|
|
167
168
|
})
|
|
168
169
|
.strict();
|
|
@@ -211,10 +212,7 @@ export const verificationProfileSchema = z
|
|
|
211
212
|
.object({
|
|
212
213
|
environment: enumOf(ENVIRONMENT_LEVELS).default("none"),
|
|
213
214
|
commands: z
|
|
214
|
-
.array(requiredString(), {
|
|
215
|
-
required_error: "is required",
|
|
216
|
-
invalid_type_error: "must be an array of strings",
|
|
217
|
-
})
|
|
215
|
+
.array(requiredString(), { error: typeError("an array of strings") })
|
|
218
216
|
.min(1, "must list at least one command"),
|
|
219
217
|
})
|
|
220
218
|
.strict();
|
|
@@ -235,7 +233,7 @@ export const environmentRetentionSchema = z
|
|
|
235
233
|
debug: enumOf(RETENTION_ACTIONS).default(DEFAULT_RETENTION.debug),
|
|
236
234
|
})
|
|
237
235
|
.strict()
|
|
238
|
-
.
|
|
236
|
+
.prefault({});
|
|
239
237
|
|
|
240
238
|
// ---------------------------------------------------------------------------
|
|
241
239
|
// root
|
|
@@ -295,11 +293,11 @@ export function formatIssues(error) {
|
|
|
295
293
|
lines.push(`${where}: unexpected field(s) ${keys}`);
|
|
296
294
|
continue;
|
|
297
295
|
}
|
|
298
|
-
if (issue
|
|
296
|
+
if (isUnknownDiscriminator(issue)) {
|
|
299
297
|
lines.push(`${where}: must be one of ${SERVICE_SOURCES.join(", ")}`);
|
|
300
298
|
continue;
|
|
301
299
|
}
|
|
302
|
-
if (issue
|
|
300
|
+
if (isMissingField(issue)) {
|
|
303
301
|
lines.push(`${where}: is required`);
|
|
304
302
|
continue;
|
|
305
303
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Zod 4 helpers shared by nomArmy's config schemas and their issue formatters.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `error` option for a primitive schema: "is required" when the field is
|
|
5
|
+
* absent, "must be <expected>" when it has the wrong type. Zod 4 replaced
|
|
6
|
+
* zod 3's required_error and invalid_type_error with this single function.
|
|
7
|
+
* @param {string} expected e.g. "a string"
|
|
8
|
+
*/
|
|
9
|
+
export const typeError = (expected) => (issue) => (issue.input === undefined ? "is required" : `must be ${expected}`);
|
|
10
|
+
|
|
11
|
+
/** A field that is absent entirely, with no custom message of its own. */
|
|
12
|
+
export const isMissingField = (issue) => issue.code === "invalid_type" && / received undefined$/.test(issue.message);
|
|
13
|
+
|
|
14
|
+
/** A discriminated union whose discriminator matched none of its options. */
|
|
15
|
+
export const isUnknownDiscriminator = (issue) => issue.code === "invalid_union" && issue.note === "No matching discriminator";
|
package/mcp/server.mjs
CHANGED
|
@@ -35,6 +35,7 @@ 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
37
|
import { podmanProblem, podmanVmStartedAt } from "../lib/podman-health.mjs";
|
|
38
|
+
import { restartNotice } from "../lib/install-freshness.mjs";
|
|
38
39
|
import { createBuildMetrics, resolveOutcome, finalText, workerMetadata, usageMetrics, policyAdmissionProblems, applyRefactorContract, applyVerificationPolicy, resolveVerifyRegression } from "../lib/outcome.mjs";
|
|
39
40
|
import { compactJobRecord, formatResult, formatUnion, testChangeBanner, regressionCheckBanner, decomposeOverlapBanner } from "../lib/job-format.mjs";
|
|
40
41
|
|
|
@@ -57,6 +58,7 @@ export { TEST_PATH_PATTERNS, isTestPath, testPatternFor, classifyTestChanges, de
|
|
|
57
58
|
// package.json to the same relative location next to the installed
|
|
58
59
|
// mcp/server.mjs, so this resolves identically in a dev checkout or an
|
|
59
60
|
// installed copy.
|
|
61
|
+
const SERVER_STARTED_MS = Date.now();
|
|
60
62
|
const VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
61
63
|
// Sent to every coordinator on connect, so no project needs a copied CLAUDE.md.
|
|
62
64
|
const server = new McpServer({ name: "nomarmy-local-worker", version: VERSION }, { instructions: COORDINATOR_INSTRUCTIONS });
|
|
@@ -406,9 +408,20 @@ server.tool("local_worker_status", `Status of one job started by this server: ph
|
|
|
406
408
|
if (full && files.meta) return toolText(JSON.stringify(files.meta, null, 2), summary.coordinatorStatus !== "complete");
|
|
407
409
|
return toolText(JSON.stringify({ ...summary, jobDir, hint: entry?.result || files.meta ? "call again with full=true for the complete report" : null }, null, 2), summary.state === "orphaned" || summary.state === "failed");
|
|
408
410
|
});
|
|
411
|
+
// Set when this session's copy of nomArmy changed on disk after it started
|
|
412
|
+
// (nomarmy connect or update ran): shown first in army and capacity, and
|
|
413
|
+
// notified once, since only a restart of this session picks it up.
|
|
414
|
+
let restartNotified = false;
|
|
415
|
+
function currentRestartNotice() {
|
|
416
|
+
const notice = restartNotice({ serverFile: fileURLToPath(import.meta.url), startedAtMs: SERVER_STARTED_MS, runningVersion: VERSION });
|
|
417
|
+
if (notice && !restartNotified) { restartNotified = true; try { notify("nomArmy: restart this session", notice); } catch { /* best-effort */ } }
|
|
418
|
+
return notice;
|
|
419
|
+
}
|
|
420
|
+
const withRestartNotice = (value) => { const notice = currentRestartNotice(); return notice ? { restartNeeded: notice, ...value } : value; };
|
|
421
|
+
|
|
409
422
|
server.tool("local_worker_capacity", "What this host can take right now: context per nom and the brief/report budgets derived from it, memory pressure and whether another job would be admitted, and the jobs currently running. Read-only.", {}, async () => {
|
|
410
423
|
await budgetState.refresh();
|
|
411
|
-
return toolText(JSON.stringify(capacitySnapshot(), null, 2));
|
|
424
|
+
return toolText(JSON.stringify(withRestartNotice(capacitySnapshot()), null, 2));
|
|
412
425
|
});
|
|
413
426
|
// The only way to know what `verification`/`union_verification`/
|
|
414
427
|
// `verify_regression` profile names are actually valid for this repo used to
|
|
@@ -515,7 +528,7 @@ server.tool("army", "Who you, the General, are and who you call for what in this
|
|
|
515
528
|
role.modelNote = `${role.model} isn't in OpenClaw's catalog for ${role.agent}; \`army assign\` checked it with a real test call when it was set, and the catalog can lag new models. Use it as assigned; if a job reports "Unknown model", reassign.`;
|
|
516
529
|
}
|
|
517
530
|
}
|
|
518
|
-
return toolText(JSON.stringify(summary, null, 2));
|
|
531
|
+
return toolText(JSON.stringify(withRestartNotice(summary), null, 2));
|
|
519
532
|
} catch (error) {
|
|
520
533
|
return toolText(error.message, true);
|
|
521
534
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "A harness for AI coding workers whose claims are never trusted: your coding assistant stays in charge while workers implement and test in sandboxes, on local models, API keys or your own subscriptions.",
|
|
4
4
|
"author": "Rayson Technologies",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"version": "0.1.0-alpha.
|
|
6
|
+
"version": "0.1.0-alpha.7",
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"tag": "alpha"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
25
25
|
"@secretlint/node": "^13.0.5",
|
|
26
26
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.5",
|
|
27
27
|
"yaml": "^2.5.0",
|
|
28
|
-
"zod": "^
|
|
28
|
+
"zod": "^4.6.5"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"test": "node --test tests/*.test.mjs",
|