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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
@@ -14,6 +14,7 @@
14
14
  // Estimates are labeled as such and use the same 4-chars-per-token rule as
15
15
  // the budgets; the point is the sign and the order of magnitude.
16
16
  import fs from "node:fs";
17
+ import zlib from "node:zlib";
17
18
  import path from "node:path";
18
19
  import { CALIBRATED } from "./budget.mjs";
19
20
 
@@ -33,6 +34,27 @@ export function findTranscriptDb(stateDir) {
33
34
  return null;
34
35
  }
35
36
 
37
+ /**
38
+ * One transcript row's event. OpenClaw 2026.9.6 stores larger events
39
+ * zstd-compressed in event_zstd with event_json null; reading event_json alone
40
+ * missed 85 of a Grok scout's 140 events (the file reads and command output
41
+ * report recovery and the idle breaker rely on). Null when the row can't be
42
+ * read: a torn row, or a Node without zstd (before 22.15).
43
+ */
44
+ export function eventFromRow(row) {
45
+ try {
46
+ if (row.event_json != null) return JSON.parse(row.event_json);
47
+ if (row.event_zstd != null && typeof zlib.zstdDecompressSync === "function") return JSON.parse(zlib.zstdDecompressSync(Buffer.from(row.event_zstd)).toString("utf8"));
48
+ } catch { /* torn or unreadable */ }
49
+ return null;
50
+ }
51
+
52
+ // event_zstd arrived with OpenClaw 2026.9.6; older databases don't have it.
53
+ function eventColumns(db) {
54
+ try { return db.prepare("select name from pragma_table_info('transcript_events')").all().some((c) => c.name === "event_zstd") ? "event_json, event_zstd" : "event_json"; }
55
+ catch { return "event_json"; }
56
+ }
57
+
36
58
  /**
37
59
  * Reduce raw transcript events to what the coordinator cares about. Pure:
38
60
  * takes the parsed `event_json` objects in order.
@@ -99,6 +121,9 @@ export function summarizeTranscriptEvents(events) {
99
121
  }
100
122
  }
101
123
  out.filesRead = [...new Set(out.filesRead)];
124
+ // A tool call with no result yet: the worker is waiting on it (a long test
125
+ // run writes nothing to the transcript until it returns).
126
+ out.toolInFlight = pending.length > 0;
102
127
  return out;
103
128
  }
104
129
 
@@ -116,9 +141,9 @@ export async function readOpenClawTranscript(stateDir) {
116
141
  try {
117
142
  const db = new DatabaseSync(dbPath, { readOnly: true });
118
143
  try {
119
- const rows = db.prepare("select event_json from transcript_events order by seq, rowid").all();
144
+ const rows = db.prepare(`select ${eventColumns(db)} from transcript_events order by seq, rowid`).all();
120
145
  const events = [];
121
- for (const r of rows) { try { events.push(JSON.parse(r.event_json)); } catch { /* skip a torn row */ } }
146
+ for (const r of rows) { const e = eventFromRow(r); if (e) events.push(e); }
122
147
  return { available: true, reason: null, dbPath, events: events.length, ...summarizeTranscriptEvents(events) };
123
148
  } finally { db.close(); }
124
149
  } catch (error) {
@@ -161,10 +186,10 @@ export async function readOpenClawTranscriptTail(stateDir, { limit = 40, sinceEv
161
186
  try {
162
187
  const total = Number(db.prepare("select count(*) as n from transcript_events").get()?.n ?? 0);
163
188
  const rows = sinceEvent !== null
164
- ? db.prepare("select event_json from transcript_events order by rowid limit -1 offset ?").all(sinceEvent)
165
- : limit > 0 ? db.prepare("select event_json from transcript_events order by rowid desc limit ?").all(limit).reverse() : [];
189
+ ? db.prepare(`select ${eventColumns(db)} from transcript_events order by rowid limit -1 offset ?`).all(sinceEvent)
190
+ : limit > 0 ? db.prepare(`select ${eventColumns(db)} from transcript_events order by rowid desc limit ?`).all(limit).reverse() : [];
166
191
  const events = [];
167
- for (const r of rows) { try { events.push(JSON.parse(r.event_json)); } catch { /* skip a torn row */ } }
192
+ for (const r of rows) { const e = eventFromRow(r); if (e) events.push(e); }
168
193
  return { available: true, reason: null, dbPath, events: total, ...summarizeTranscriptEvents(events) };
169
194
  } finally { db.close(); }
170
195
  } catch (error) {
@@ -0,0 +1,329 @@
1
+ import { execFile } from "node:child_process";
2
+ import { isDeepStrictEqual, stripVTControlCharacters } from "node:util";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ // How much of each subscription's usage limit is used, as its vendor reports
8
+ // it: Codex writes rate_limits into every job's session log, and Claude Code
9
+ // passes them to the status line. Kept per OpenClaw provider id (one host
10
+ // login per provider) in <stateRoot>/usage-limits.json. Admission holds a job
11
+ // on an agent that's over its limit until the General confirms.
12
+ //
13
+ // Those readings only update when a job finishes. A reading taken before a
14
+ // reset, or before the operator bought more usage, would then hold every
15
+ // job, and the reading would never refresh. A reading older than a few
16
+ // minutes is refreshed from `openclaw models status` before that hold, and
17
+ // again when health or the army/capacity views show it. The refresh is
18
+ // async: spawnSync would freeze the server's event loop.
19
+
20
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
21
+ const finite = value => typeof value === "number" && Number.isFinite(value);
22
+ const windowName = minutes => ({ 300: "5h", 10080: "week", 1440: "day" })[minutes] ?? `${minutes}m`;
23
+ const USAGE_SOURCES = ["codex", "claude", "openclaw"];
24
+ // Older than this, an over-limit reading is refreshed before it holds a job.
25
+ export const USAGE_STALE_MINUTES = 10;
26
+ // Source observations age out independently, even when the reset stays the same.
27
+ export const CLAUDE_USAGE_SOURCE_TTL_MS = 30 * 60 * 1000;
28
+ const CLAUDE_USAGE_SOURCE_REFRESH_MS = 5 * 60 * 1000;
29
+ export const USAGE_REFRESH_TIMEOUT_MS = 20000;
30
+ const RESET_UNIT_MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 };
31
+ function window(value, name, minutes, percentKey) {
32
+ if (!object(value) || !finite(value[percentKey]) || value[percentKey] < 0) return null;
33
+ return { name, usedPercent: value[percentKey], windowMinutes: minutes,
34
+ resetsAt: finite(value.resets_at) ? value.resets_at * 1000 : null };
35
+ }
36
+
37
+ /** Read the last rate-limit event from the newest rollout (by modification time). */
38
+ export function readCodexRateLimits(jobDir) {
39
+ try {
40
+ const files = [];
41
+ function walk(dir) {
42
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const file = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) walk(file);
45
+ else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) files.push({ file, mtime: fs.statSync(file).mtimeMs });
46
+ }
47
+ }
48
+ const agents = path.join(jobDir, "runtime/state/agents");
49
+ for (const agent of fs.readdirSync(agents, { withFileTypes: true })) {
50
+ if (!agent.isDirectory()) continue;
51
+ const sessions = path.join(agents, agent.name, "agent/codex-home/sessions");
52
+ if (fs.existsSync(sessions)) walk(sessions);
53
+ }
54
+ files.sort((a, b) => b.mtime - a.mtime || b.file.localeCompare(a.file));
55
+ if (!files.length) return null;
56
+ let last = null;
57
+ for (const line of fs.readFileSync(files[0].file, "utf8").split(/\r?\n/)) {
58
+ try {
59
+ const event = JSON.parse(line);
60
+ if (event.type === "event_msg" && event.payload?.type === "token_count" && object(event.payload.rate_limits)) last = event;
61
+ } catch { /* A partial trailing write is not an event. */ }
62
+ }
63
+ if (!last) return null;
64
+ const limits = last.payload.rate_limits;
65
+ const windows = [limits.primary, limits.secondary].map(value => {
66
+ const minutes = finite(value?.window_minutes) ? value.window_minutes : null;
67
+ return window(value, minutes === null ? "unknown" : windowName(minutes), minutes, "used_percent");
68
+ }).filter(Boolean);
69
+ const limitReached = limits.rate_limit_reached_type != null || Boolean(limits.spend_control_reached);
70
+ if (!windows.length && !limitReached) return null;
71
+ const timestamp = Date.parse(last.timestamp);
72
+ return { source: "codex", plan: typeof limits.plan_type === "string" ? limits.plan_type : null,
73
+ limitReached, observedAt: Number.isFinite(timestamp) ? timestamp : files[0].mtime, windows };
74
+ } catch { return null; }
75
+ }
76
+
77
+ export function normalizeClaudeRateLimits(rateLimits) {
78
+ if (!object(rateLimits)) return null;
79
+ const windows = [["five_hour", "5h", 300], ["seven_day", "week", 10080], ["spend_limit", "spend", null]]
80
+ .map(([key, name, minutes]) => window(rateLimits[key], name, minutes, "used_percentage")).filter(Boolean);
81
+ // Claude supplies percentages, not an independent provider-wide reached flag.
82
+ return windows.length ? { source: "claude", plan: null, limitReached: false, observedAt: Date.now(), windows } : null;
83
+ }
84
+
85
+ /** Maximum of the recent sources for one reset window. */
86
+ function recentUsageWindow(w, observedAt, now, sourceTtlMs) {
87
+ if (w.resetsAt !== null && w.resetsAt <= now) return null;
88
+ const sources = (w.sources ?? [{ usedPercent: w.usedPercent, observedAt }])
89
+ .filter(s => now - s.observedAt <= sourceTtlMs);
90
+ if (!sources.length) return null;
91
+ return { ...w, usedPercent: Math.max(...sources.map(s => s.usedPercent)), sources };
92
+ }
93
+
94
+ /**
95
+ * Keep the latest observation per source and window. At the same reset,
96
+ * recent sources compete by percentage; a later reset replaces all sources
97
+ * for the earlier reset. Legacy snapshots count as one source at observedAt.
98
+ * Anonymous readings each get their own source. Unchanged redraws refresh
99
+ * source timestamps at most once every five minutes, before sources expire.
100
+ */
101
+ export function mergeUsageSnapshot(previous, incoming, now = Date.now(), sourceTtlMs = CLAUDE_USAGE_SOURCE_TTL_MS) {
102
+ const sourceId = typeof incoming.sourceId === "string" && incoming.sourceId ? incoming.sourceId : randomUUID();
103
+ const legacyId = previous?.sourceId ?? randomUUID();
104
+ const byName = new Map((previous?.windows ?? [])
105
+ .filter(w => w.resetsAt === null || w.resetsAt > now)
106
+ .map(w => [w.name, { ...w, sources: w.sources ?? [{ sourceId: legacyId, observedAt: previous.observedAt, usedPercent: w.usedPercent }] }]));
107
+ for (const w of incoming.windows) {
108
+ if (w.resetsAt !== null && w.resetsAt <= now) continue;
109
+ const stored = byName.get(w.name);
110
+ if (stored && (stored.resetsAt ?? 0) > (w.resetsAt ?? 0)) continue;
111
+ const sources = stored && stored.resetsAt === w.resetsAt ? stored.sources : [];
112
+ const last = sources.find(s => s.sourceId === sourceId);
113
+ if (last && last.observedAt > incoming.observedAt) continue;
114
+ if (last && last.usedPercent === w.usedPercent && stored.windowMinutes === w.windowMinutes
115
+ && incoming.observedAt - last.observedAt < Math.min(CLAUDE_USAGE_SOURCE_REFRESH_MS, sourceTtlMs / 2)) continue;
116
+ byName.set(w.name, { ...w, sources: [
117
+ ...sources.filter(s => s.sourceId !== sourceId),
118
+ { sourceId, observedAt: incoming.observedAt, usedPercent: w.usedPercent },
119
+ ] });
120
+ }
121
+ const windows = [...byName.values()].map(w => recentUsageWindow(w, incoming.observedAt, now, sourceTtlMs)).filter(Boolean);
122
+ const { sourceId: ignored, ...snapshot } = incoming;
123
+ const merged = { ...snapshot, limitReached: incoming.limitReached || Boolean(previous?.limitReached), observedAt: now, windows };
124
+ // Keep identity for callers that avoid writes when only the redraw time changed.
125
+ return previous && isDeepStrictEqual({ ...merged, observedAt: previous.observedAt }, previous) ? previous : merged;
126
+ }
127
+
128
+ export function readUsageSnapshots(stateRoot) {
129
+ try {
130
+ const data = JSON.parse(fs.readFileSync(path.join(stateRoot, "usage-limits.json"), "utf8"));
131
+ if (!object(data)) return {};
132
+ return Object.fromEntries(Object.entries(data).filter(([, s]) => object(s) && USAGE_SOURCES.includes(s.source)
133
+ && finite(s.observedAt) && typeof s.limitReached === "boolean" && (s.plan === null || typeof s.plan === "string")
134
+ && Array.isArray(s.windows) && s.windows.every(w => object(w) && typeof w.name === "string" && finite(w.usedPercent)
135
+ && (w.windowMinutes === null || finite(w.windowMinutes)) && (w.resetsAt === null || finite(w.resetsAt))
136
+ && (w.sources === undefined || (Array.isArray(w.sources) && w.sources.every(s => object(s)
137
+ && typeof s.sourceId === "string" && finite(s.observedAt) && finite(s.usedPercent) && s.usedPercent >= 0))))));
138
+ } catch { return {}; }
139
+ }
140
+
141
+ /** Save a provider's snapshot, unless the one on file is newer (jobs finish out of order). */
142
+ export function recordUsageSnapshot(stateRoot, provider, snapshot) {
143
+ const current = readUsageSnapshots(stateRoot);
144
+ if (current[provider]?.observedAt > snapshot.observedAt) return;
145
+ const snapshots = { ...current, [provider]: snapshot };
146
+ fs.mkdirSync(stateRoot, { recursive: true });
147
+ const file = path.join(stateRoot, "usage-limits.json"), tmp = `${file}.${randomUUID()}.tmp`;
148
+ try {
149
+ fs.writeFileSync(tmp, JSON.stringify(snapshots, null, 2));
150
+ fs.renameSync(tmp, file);
151
+ } finally { fs.rmSync(tmp, { force: true }); }
152
+ }
153
+
154
+ export function usageStatus(snapshot, now = Date.now(), sourceTtlMs = CLAUDE_USAGE_SOURCE_TTL_MS) {
155
+ const windows = snapshot.source === "claude"
156
+ ? snapshot.windows.map(w => recentUsageWindow(w, snapshot.observedAt, now, sourceTtlMs)).filter(Boolean)
157
+ : snapshot.windows;
158
+ const live = windows.filter(w => w.resetsAt === null || w.resetsAt > now).sort((a, b) => b.usedPercent - a.usedPercent);
159
+ const reached = snapshot.limitReached && (snapshot.windows.length === 0 || live.length > 0);
160
+ const highest = live[0];
161
+ const level = reached || highest?.usedPercent >= 100 ? "over" : highest?.usedPercent >= 80 ? "high" : "ok";
162
+ const text = live.length ? live.map(w => `${w.usedPercent}% of ${w.name}, resets ${w.resetsAt === null ? "unknown" : new Date(w.resetsAt).toLocaleString("en-US", { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false })}`).join("; ") : reached ? "limit reached, reset unknown" : "no live usage windows";
163
+ // A short label for tight spaces (status line, health): "85% wk".
164
+ const short = highest ? `${highest.usedPercent}% ${highest.name === "week" ? "wk" : highest.name}` : reached ? "limit" : null;
165
+ return { level, text, short, resetsAt: level === "over" ? highest?.resetsAt ?? null : null,
166
+ ageMinutes: Math.max(0, Math.floor((now - snapshot.observedAt) / 60000)) };
167
+ }
168
+
169
+ /** An over-limit reading old enough that the window may already have reset. */
170
+ export function usageReadingIsStale(status) {
171
+ return status?.level === "over" && status.ageMinutes >= USAGE_STALE_MINUTES;
172
+ }
173
+
174
+ /** View text. A stale over-limit reading is marked; a fresh one is unchanged. */
175
+ export function usageDisplayText(status) {
176
+ if (!usageReadingIsStale(status)) return status.text;
177
+ return `${status.text}; possibly stale (${status.ageMinutes} minutes old)`;
178
+ }
179
+
180
+ export function staleUsageRefreshedNote(provider) {
181
+ return `stale usage reading for ${provider} was refreshed from OpenClaw`;
182
+ }
183
+
184
+ function refreshFailureMessage(error) {
185
+ const message = String(error?.message ?? error ?? "");
186
+ return /timed out|timeout/i.test(message) ? "OpenClaw usage refresh failed (timed out)" : "OpenClaw usage refresh failed";
187
+ }
188
+
189
+ function withTimeout(promise, timeoutMs) {
190
+ return new Promise((resolve, reject) => {
191
+ const timer = setTimeout(() => reject(new Error("OpenClaw usage refresh timed out")), timeoutMs);
192
+ Promise.resolve(promise).then(
193
+ (value) => { clearTimeout(timer); resolve(value); },
194
+ (error) => { clearTimeout(timer); reject(error); },
195
+ );
196
+ });
197
+ }
198
+
199
+ /** Async `openclaw` runner. Never spawnSync: that blocks every other request. */
200
+ export function runOpenClawStatus(cmd, args, { timeoutMs = USAGE_REFRESH_TIMEOUT_MS } = {}) {
201
+ return new Promise((resolve) => {
202
+ execFile(cmd, args, { encoding: "utf8", timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout) => {
203
+ resolve({ ok: !error, stdout: stdout ?? "", error: error ? String(error.message ?? error) : null });
204
+ });
205
+ });
206
+ }
207
+
208
+ function windowFromLabel(label) {
209
+ const text = String(label ?? "").trim();
210
+ const hours = /^(\d+(?:\.\d+)?)h$/i.exec(text);
211
+ if (hours) {
212
+ const minutes = Math.round(Number(hours[1]) * 60);
213
+ return { name: windowName(minutes), windowMinutes: minutes };
214
+ }
215
+ const days = /^(\d+(?:\.\d+)?)d$/i.exec(text);
216
+ if (days) {
217
+ const minutes = Math.round(Number(days[1]) * 1440);
218
+ return { name: windowName(minutes), windowMinutes: minutes };
219
+ }
220
+ const mins = /^(\d+(?:\.\d+)?)m$/i.exec(text);
221
+ if (mins) {
222
+ const minutes = Math.round(Number(mins[1]));
223
+ return { name: windowName(minutes), windowMinutes: minutes };
224
+ }
225
+ const named = { week: 10080, day: 1440, "5h": 300, spend: null };
226
+ if (Object.hasOwn(named, text)) return { name: text === "week" || text === "day" || text === "5h" ? windowName(named[text]) : text, windowMinutes: named[text] };
227
+ return { name: text || "unknown", windowMinutes: null };
228
+ }
229
+
230
+ function resetAtFromDelay(delay, now) {
231
+ if (delay == null || delay === "") return null;
232
+ if (typeof delay === "number" && Number.isFinite(delay)) {
233
+ if (delay > 1e12) return delay;
234
+ if (delay > 1e9) return delay * 1000;
235
+ return null;
236
+ }
237
+ const text = String(delay).trim();
238
+ if (!text) return null;
239
+ if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
240
+ const iso = Date.parse(text);
241
+ return Number.isFinite(iso) ? iso : null;
242
+ }
243
+ let ms = 0, matched = false;
244
+ for (const part of text.matchAll(/(\d+(?:\.\d+)?)\s*([dhms])/gi)) {
245
+ matched = true;
246
+ ms += Number(part[1]) * RESET_UNIT_MS[part[2].toLowerCase()];
247
+ }
248
+ return matched ? now + ms : null;
249
+ }
250
+
251
+ function snapshotFromWindows(windows, now) {
252
+ if (!windows.length) return null;
253
+ return { source: "openclaw", plan: null, limitReached: false, observedAt: now, windows };
254
+ }
255
+
256
+ function parseUsageRemainder(remainder, now) {
257
+ const windows = [];
258
+ const re = /(\S+)\s+(\d+(?:\.\d+)?)%\s+left(?:\s*⏱\uFE0F?\s*((?:\d+(?:\.\d+)?\s*[dhms]\s*)+))?/gi;
259
+ for (const match of String(remainder).matchAll(re)) {
260
+ const built = windowFromLabel(match[1]);
261
+ const usedPercent = 100 - Number(match[2]);
262
+ if (!Number.isFinite(usedPercent) || usedPercent < 0) continue;
263
+ windows.push({ name: built.name, usedPercent, windowMinutes: built.windowMinutes, resetsAt: resetAtFromDelay(match[3] ?? null, now) });
264
+ }
265
+ return windows;
266
+ }
267
+
268
+ function parseTextUsage(raw, now) {
269
+ const out = {};
270
+ const lineRe = /(?:^|[\n"\s\u2500-\u257f])([A-Za-z0-9._-]+)\s+usage:\s+([^"\n]+)/g;
271
+ for (const line of String(raw).matchAll(lineRe)) {
272
+ const snapshot = snapshotFromWindows(parseUsageRemainder(line[2], now), now);
273
+ if (snapshot) out[line[1]] = snapshot;
274
+ }
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * OpenClaw usage from plain-text `models status`:
280
+ * `- <provider> usage: <window> <N>% left ⏱<duration>`.
281
+ * "N% left" becomes used percent (100 - N). Returns provider -> snapshot.
282
+ * The JSON status output does not include usage readings.
283
+ */
284
+ export function parseOpenClawUsageOutput(raw, now = Date.now()) {
285
+ return parseTextUsage(stripVTControlCharacters(String(raw ?? "")), now);
286
+ }
287
+
288
+ /** Ask OpenClaw for current usage without blocking the event loop. */
289
+ export async function fetchOpenClawUsage({ run = runOpenClawStatus, now = Date.now(), timeoutMs = USAGE_REFRESH_TIMEOUT_MS, openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw" } = {}) {
290
+ const failure = (error) => ({ ok: false, snapshots: {}, error: refreshFailureMessage(error) });
291
+ try {
292
+ return await withTimeout((async () => {
293
+ const result = await run(openclawCmd, ["models", "status"], { timeoutMs });
294
+ // Partial stdout from a failed command is not a fresh usage reading.
295
+ if (!result?.ok) return failure(result?.error);
296
+ const snapshots = parseOpenClawUsageOutput(result.stdout, now);
297
+ return Object.keys(snapshots).length ? { ok: true, snapshots, error: null } : failure(null);
298
+ })(), timeoutMs);
299
+ } catch (error) { return failure(error); }
300
+ }
301
+
302
+ /**
303
+ * Refresh over-limit readings older than USAGE_STALE_MINUTES. No OpenClaw
304
+ * call when nothing is stale. A failed refresh leaves the stored readings
305
+ * and says so. `snapshots` lets a caller pass what it already read.
306
+ */
307
+ export async function refreshStaleOverLimitReadings(stateRoot, { run, now = Date.now(), snapshots = null, timeoutMs = USAGE_REFRESH_TIMEOUT_MS, openclawCmd } = {}) {
308
+ const current = snapshots ?? (stateRoot ? readUsageSnapshots(stateRoot) : {});
309
+ const stale = Object.entries(current).filter(([, snapshot]) => usageReadingIsStale(usageStatus(snapshot, now))).map(([provider]) => provider);
310
+ if (!stale.length) return { called: false, ok: true, snapshots: current, error: null, failedProviders: [] };
311
+ let fetched;
312
+ try { fetched = await fetchOpenClawUsage({ run, now, timeoutMs, openclawCmd }); }
313
+ catch (error) { return { called: true, ok: false, snapshots: current, error: refreshFailureMessage(error), failedProviders: stale }; }
314
+ if (!fetched.ok) return { called: true, ok: false, snapshots: current, error: fetched.error ?? "OpenClaw usage refresh failed", failedProviders: stale };
315
+ const updated = { ...current };
316
+ const seen = new Set();
317
+ for (const provider of stale) {
318
+ const snapshot = fetched.snapshots[provider];
319
+ if (!snapshot) continue;
320
+ try {
321
+ if (stateRoot) recordUsageSnapshot(stateRoot, provider, snapshot);
322
+ // A job may have saved a newer observation while the refresh was pending.
323
+ updated[provider] = stateRoot ? readUsageSnapshots(stateRoot)[provider] ?? snapshot : snapshot;
324
+ seen.add(provider);
325
+ } catch { /* Keep the hold if the fresh snapshot could not be recorded. */ }
326
+ }
327
+ const failedProviders = stale.filter((provider) => !seen.has(provider));
328
+ return { called: true, ok: failedProviders.length === 0, snapshots: updated, error: failedProviders.length ? "OpenClaw usage refresh failed" : null, failedProviders };
329
+ }
@@ -0,0 +1,106 @@
1
+ // Your setup choices (where models run, the local model, hardware profiles)
2
+ // live in ~/.config/nomarmy/, layered over the package's config/ defaults.
3
+ // They used to be written into config/ itself, which for an npm install is
4
+ // inside the package: every `nomarmy update` put them back to the defaults
5
+ // (found in a fresh-install practice run: hosted silently became local).
6
+ //
7
+ // ~/.config/nomarmy/common.env your overrides of config/common.env
8
+ // ~/.config/nomarmy/profiles/<name>.env your profiles, used before config/profiles/
9
+ //
10
+ // scripts/lib.sh layers the same files the same way.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { globalConfigDir } from "./army.mjs";
15
+
16
+ export function userCommonPath(env = process.env) { return path.join(globalConfigDir(env), "common.env"); }
17
+ export function userProfilePath(name, env = process.env) { return path.join(globalConfigDir(env), "profiles", `${name}.env`); }
18
+
19
+ export function parseEnvFile(filePath) {
20
+ const out = {};
21
+ let text;
22
+ try { text = fs.readFileSync(filePath, "utf8"); } catch { return out; }
23
+ for (const line of text.split("\n")) {
24
+ const m = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
25
+ if (m) out[m[1]] = m[2].trim();
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Your value, else the package default. */
31
+ export function readSetting(key, { nomarmyRoot, env = process.env }) {
32
+ const mine = parseEnvFile(userCommonPath(env));
33
+ if (key in mine) return mine[key];
34
+ return parseEnvFile(path.join(nomarmyRoot, "config", "common.env"))[key] ?? null;
35
+ }
36
+
37
+ /** Every common setting, package defaults with yours on top. */
38
+ export function readSettings({ nomarmyRoot, env = process.env }) {
39
+ return { ...parseEnvFile(path.join(nomarmyRoot, "config", "common.env")), ...parseEnvFile(userCommonPath(env)) };
40
+ }
41
+
42
+ /** A profile's file: yours if you have one by that name, else the shipped one (null if neither). */
43
+ export function profilePathFor(name, { nomarmyRoot, env = process.env }) {
44
+ if (!name) return null;
45
+ const mine = userProfilePath(name, env);
46
+ if (fs.existsSync(mine)) return mine;
47
+ const shipped = path.join(nomarmyRoot, "config", "profiles", `${name}.env`);
48
+ return fs.existsSync(shipped) ? shipped : null;
49
+ }
50
+
51
+ const HEADER = "# Your nomArmy settings, layered over the package's config/common.env.\n# Written by nomarmy setup and nomarmy model; kept across updates.\n";
52
+
53
+ /** Set one KEY=VALUE in a settings file, replacing it if present. */
54
+ export function writeEnvLine(filePath, key, value, { header = HEADER } = {}) {
55
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
56
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : header;
57
+ const line = `${key}=${value}`;
58
+ const re = new RegExp(`^${key}=.*$`, "m");
59
+ const updated = re.test(existing) ? existing.replace(re, line) : `${existing.trimEnd()}\n${line}\n`.replace(/^\n/, "");
60
+ fs.writeFileSync(filePath, updated);
61
+ }
62
+
63
+ export function writeSetting(key, value, { env = process.env } = {}) {
64
+ writeEnvLine(userCommonPath(env), key, value);
65
+ return userCommonPath(env);
66
+ }
67
+
68
+ /**
69
+ * One-time rescue, run before an update's copy replaces the installed one:
70
+ * settings that only ever lived in the package (or its installed copy) move
71
+ * to ~/.config/nomarmy/. A key is moved when the old copy's value differs from
72
+ * the new package's default; a profile when the new package doesn't ship it
73
+ * or ships it differently. Never overwrites what you already have there.
74
+ * @returns {{ keys: string[], profiles: string[] }}
75
+ */
76
+ export function migrateUserConfig({ oldConfigDir, nomarmyRoot, env = process.env }) {
77
+ const moved = { keys: [], profiles: [] };
78
+ if (!oldConfigDir || !fs.existsSync(oldConfigDir)) return moved;
79
+ if (path.resolve(oldConfigDir) === path.resolve(path.join(nomarmyRoot, "config"))) return moved;
80
+ const old = parseEnvFile(path.join(oldConfigDir, "common.env"));
81
+ const shipped = parseEnvFile(path.join(nomarmyRoot, "config", "common.env"));
82
+ const mine = parseEnvFile(userCommonPath(env));
83
+ for (const [key, value] of Object.entries(old)) {
84
+ if (key in mine || shipped[key] === value) continue;
85
+ writeSetting(key, value, { env });
86
+ moved.keys.push(key);
87
+ }
88
+ let names = [];
89
+ try { names = fs.readdirSync(path.join(oldConfigDir, "profiles")).filter((f) => f.endsWith(".env")); } catch { /* none */ }
90
+ for (const file of names) {
91
+ const target = userProfilePath(path.basename(file, ".env"), env);
92
+ if (fs.existsSync(target)) continue;
93
+ const text = fs.readFileSync(path.join(oldConfigDir, "profiles", file), "utf8");
94
+ const shippedFile = path.join(nomarmyRoot, "config", "profiles", file);
95
+ if (fs.existsSync(shippedFile) && fs.readFileSync(shippedFile, "utf8") === text) continue;
96
+ fs.mkdirSync(path.dirname(target), { recursive: true });
97
+ fs.writeFileSync(target, text);
98
+ moved.profiles.push(path.basename(file, ".env"));
99
+ }
100
+ return moved;
101
+ }
102
+
103
+ /** A path for messages: ~ for the home directory. */
104
+ export function tildePath(p, home = process.env.HOME) {
105
+ return home && p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
106
+ }