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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
package/lib/slots.mjs ADDED
@@ -0,0 +1,112 @@
1
+ // Machine-wide job leases and agent slots.
2
+ //
3
+ // Every coordinator session runs its own MCP server process, and each used
4
+ // to count only its own jobs -- seen live: six server processes, so an
5
+ // agent's `max_concurrent: 1` held per session, not per machine. These live
6
+ // in files under nomArmy's shared state directory instead, so every session
7
+ // sees every job:
8
+ //
9
+ // leases/<jobId>.json one per running job: lane, agent, owning pid
10
+ // slots/<agent>.<n>.lock one per occupied max_concurrent slot, taken
11
+ // with exclusive create (O_EXCL), so two sessions
12
+ // can't both take the last one
13
+ //
14
+ // A lease or slot whose owning process is gone is stale: it doesn't count,
15
+ // and the next reader removes it, so a crashed session never wedges a slot.
16
+
17
+ import crypto from "node:crypto";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ /** True when `pid` is a running process (EPERM: it exists, owned by someone else). */
22
+ export function pidAlive(pid) {
23
+ if (!Number.isInteger(pid) || pid <= 0) return false;
24
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
25
+ }
26
+
27
+ function readJson(file) {
28
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
29
+ }
30
+
31
+ /** A lease/slot file is stale when its owner is gone, or it's unreadable and not brand new. */
32
+ function isStale(file, { alive = pidAlive } = {}) {
33
+ const held = readJson(file);
34
+ if (held) return !alive(held.pid);
35
+ try { return Date.now() - fs.statSync(file).mtimeMs > 60000; } catch { return true; }
36
+ }
37
+
38
+ const safeKey = (key) => String(key).replace(/[^A-Za-z0-9._-]/g, "_");
39
+
40
+ // --- job leases -------------------------------------------------------------
41
+
42
+ export function writeLease(leasesDir, jobId, { lane, agent = null, runId = null, role = null, model = null, repo = null }) {
43
+ fs.mkdirSync(leasesDir, { recursive: true });
44
+ fs.writeFileSync(path.join(leasesDir, `${safeKey(jobId)}.json`), JSON.stringify({ jobId, lane, agent, runId, role, model, repo, pid: process.pid, startedAt: new Date().toISOString() }));
45
+ }
46
+
47
+ export function removeLease(leasesDir, jobId) {
48
+ try { fs.unlinkSync(path.join(leasesDir, `${safeKey(jobId)}.json`)); } catch { /* already gone */ }
49
+ }
50
+
51
+ /** Live leases across every session on this machine, optionally filtered. Removes stale ones. */
52
+ export function liveLeases(leasesDir, { lane = null, agent = null, runId = null, alive = pidAlive } = {}) {
53
+ let files;
54
+ try { files = fs.readdirSync(leasesDir).filter((f) => f.endsWith(".json")); } catch { return []; }
55
+ const out = [];
56
+ for (const f of files) {
57
+ const file = path.join(leasesDir, f);
58
+ if (isStale(file, { alive })) { try { fs.unlinkSync(file); } catch { /* raced another reader */ } continue; }
59
+ const lease = readJson(file);
60
+ if (!lease) continue;
61
+ if (lane && lease.lane !== lane) continue;
62
+ if (agent && lease.agent !== agent) continue;
63
+ if (runId && lease.runId !== runId) continue;
64
+ out.push(lease);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ // --- agent slots ------------------------------------------------------------
70
+
71
+ /** How many of `key`'s slots are held right now, across the machine. */
72
+ export function liveSlots(slotsDir, key, { alive = pidAlive } = {}) {
73
+ const prefix = `${safeKey(key)}.`;
74
+ let files;
75
+ try { files = fs.readdirSync(slotsDir).filter((f) => f.startsWith(prefix) && f.endsWith(".lock")); } catch { return 0; }
76
+ return files.filter((f) => !isStale(path.join(slotsDir, f), { alive })).length;
77
+ }
78
+
79
+ /**
80
+ * Take one of `key`'s `max` slots, atomically. Returns { release } or null
81
+ * when all are held. With `waitMs`, polls until one frees up or time runs
82
+ * out (for a batch, where the next job should queue, not fail).
83
+ */
84
+ export async function acquireSlot(slotsDir, key, max, { jobId = null, waitMs = 0, pollMs = 2000, alive = pidAlive } = {}) {
85
+ fs.mkdirSync(slotsDir, { recursive: true });
86
+ const token = crypto.randomBytes(6).toString("hex");
87
+ const deadline = Date.now() + waitMs;
88
+ for (;;) {
89
+ for (let i = 0, retried = false; i < max; i++) {
90
+ const file = path.join(slotsDir, `${safeKey(key)}.${i}.lock`);
91
+ try {
92
+ const fd = fs.openSync(file, "wx");
93
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, jobId, key, takenAt: new Date().toISOString() }));
94
+ fs.closeSync(fd);
95
+ return {
96
+ file,
97
+ release() {
98
+ const held = readJson(file);
99
+ if (held?.token === token) { try { fs.unlinkSync(file); } catch { /* already gone */ } }
100
+ },
101
+ };
102
+ } catch (error) {
103
+ if (error.code !== "EEXIST") throw error;
104
+ // A stale slot is cleared and retried once; never loop on it.
105
+ if (!retried && isStale(file, { alive })) { try { fs.unlinkSync(file); } catch { /* raced */ } retried = true; i--; continue; }
106
+ retried = false;
107
+ }
108
+ }
109
+ if (Date.now() >= deadline) return null;
110
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(1, deadline - Date.now()))));
111
+ }
112
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // nomArmy in Claude Code's status line.
3
+ //
4
+ // Claude Code runs a status-line command each time it redraws, passing the
5
+ // session (model, working directory) as JSON on stdin, and shows the one
6
+ // line it prints. This prints the session's model and repo, then what
7
+ // nomArmy is doing across every session on the machine:
8
+ //
9
+ // Opus 5.5 · rayson-senti │ 🍪 sr-dev codex/gpt-6-astra 9m 10f │ run 3/40 $0.41
10
+ // Opus 5.5 · rayson-senti │ 🍪 2: scout claude 20s · sr-dev-action-wr… codex 20s │ run 5/14 $2.16
11
+ // Opus 5.5 · nomarmy │ 🍪 idle · 2 in other repos
12
+ //
13
+ // This session's repo gets the detail; jobs in other repos are only a
14
+ // count (showing Senti's jobs in full in every window read as noise).
15
+ //
16
+ // Kept short: Claude Code cuts a long status line off. Job names lose
17
+ // their date/time/hash suffix, models show only for a single job, and
18
+ // the whole line is capped (NOMARMY_STATUSLINE_MAX, default 90), with
19
+ // jobs that don't fit collapsing to "+N" so the run summary survives.
20
+ //
21
+ // Deliberately Node built-ins only, reading nothing but the job leases,
22
+ // their status files and this repo's open runs: it runs on every redraw.
23
+ // `nomarmy connect claude` installs it (unless a status line is already
24
+ // configured); `nomarmy statusline` runs the same thing.
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ function readJson(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
32
+ function pidAlive(pid) {
33
+ if (!Number.isInteger(pid) || pid <= 0) return false;
34
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
35
+ }
36
+ /** "scout-20260924-143127-49f447" -> "scout"; long names shortened. */
37
+ export function shortJobName(name, max = 18) {
38
+ const bare = String(name ?? "job").replace(/-\d{8}-\d{6}-[0-9a-f]{4,}$/i, "");
39
+ return bare.length > max ? `${bare.slice(0, max - 1)}…` : bare;
40
+ }
41
+
42
+ const minutes = (ms) => (ms < 60000 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600000 ? `${Math.round(ms / 60000)}m` : `${Math.floor(ms / 3600000)}h${String(Math.round((ms % 3600000) / 60000)).padStart(2, "0")}`);
43
+
44
+ /**
45
+ * The status line's text.
46
+ * @param {{ session?: object, stateRoot?: string, now?: number }} input
47
+ */
48
+ export function statusLineText({ session = {}, stateRoot, now = Date.now(), maxLength = Number(process.env.NOMARMY_STATUSLINE_MAX) || 90 } = {}) {
49
+ const root = stateRoot ?? (process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"));
50
+ // workspace.project_dir is where Claude Code was launched -- the repo that
51
+ // session's nomArmy server works in; current_dir can be a subdirectory.
52
+ const cwd = session.workspace?.project_dir ?? session.workspace?.current_dir ?? session.cwd ?? process.cwd();
53
+ const repoName = path.basename(cwd);
54
+ const head = [session.model?.display_name, repoName.length > 16 ? `${repoName.slice(0, 15)}…` : repoName].filter(Boolean).join(" · ");
55
+
56
+ const leasesDir = path.join(root, "leases");
57
+ let leases = [];
58
+ try { leases = fs.readdirSync(leasesDir).filter((f) => f.endsWith(".json")).map((f) => readJson(path.join(leasesDir, f))).filter((l) => l && pidAlive(l.pid)); } catch { /* none */ }
59
+ // A lease from before leases recorded their repo can't be attributed; it
60
+ // counts as "elsewhere" rather than claimed by every window.
61
+ // A lease from a server that predates `repo`: its worktree's .git file
62
+ // ("gitdir: <repo>/.git/worktrees/<name>") still names the repo.
63
+ for (const l of leases) {
64
+ if (l.repo) continue;
65
+ try {
66
+ const m = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/]/.exec(fs.readFileSync(path.join(root, "jobs", l.jobId, "worktree", ".git"), "utf8"));
67
+ if (m) l.repo = m[1];
68
+ } catch { /* no worktree yet */ }
69
+ }
70
+ const inRepo = (l) => Boolean(l.repo) && (cwd === l.repo || cwd.startsWith(`${l.repo}${path.sep}`));
71
+ const elsewhere = leases.filter((l) => !inRepo(l)).length;
72
+ leases = leases.filter(inRepo);
73
+ const single = leases.length === 1;
74
+ const jobs = leases.map((l) => {
75
+ const status = readJson(path.join(root, "jobs", l.jobId, "status.json")) ?? {};
76
+ const name = shortJobName(l.role ?? status.workerId ?? l.jobId);
77
+ const who = l.agent ? `${l.agent}${single && l.model ? `/${l.model}` : ""}` : "local";
78
+ const started = Date.parse(status.startedAt ?? l.startedAt ?? "") || now;
79
+ const files = Number.isFinite(status.filesChangedLive) && status.filesChangedLive > 0 ? ` ${status.filesChangedLive}f` : "";
80
+ const phase = status.phase && status.phase !== "worker" ? ` ${status.phase}` : "";
81
+ return `${name} ${who} ${minutes(now - started)}${files}${phase}`;
82
+ });
83
+
84
+ let runPart = "";
85
+ try {
86
+ const runsDir = path.join(root, "runs");
87
+ const open = fs.readdirSync(runsDir).filter((f) => f.endsWith(".json")).map((f) => readJson(path.join(runsDir, f)))
88
+ .filter((r) => r?.status === "running" && r.repo === cwd).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
89
+ const run = open[0];
90
+ if (run) {
91
+ const inFlight = leases.filter((l) => l.runId === run.id).length;
92
+ const spent = run.jobs.reduce((s, j) => s + (j.kind === "api" && Number.isFinite(j.costUsd) ? j.costUsd : 0), 0);
93
+ const warn = Object.keys(run.pausedAgents ?? {}).length ? " ⚠ agent paused" : "";
94
+ runPart = ` │ run ${run.jobs.length + inFlight}/${run.limits.max_jobs}${spent ? ` $${spent.toFixed(2)}` : ""}${warn}`;
95
+ }
96
+ } catch { /* no runs */ }
97
+ // The most serious recent health warning (lib/health.mjs), short.
98
+ let healthPart = "";
99
+ try {
100
+ const health = readJson(path.join(root, "health.json"));
101
+ if (health && now - Date.parse(health.checkedAt) < 2 * 86400000) {
102
+ const top = (health.issues ?? []).find((i) => i.severity !== "info" && i.short);
103
+ if (top) healthPart = ` │ ⚠ ${top.short}`;
104
+ }
105
+ } catch { /* no health yet */ }
106
+ runPart += healthPart;
107
+ // As many jobs as fit, then "+N": the run summary is never what gets cut.
108
+ const prefix = `${head ? `${head} │ ` : ""}🍪 `;
109
+ const count = jobs.length > 1 ? `${jobs.length}: ` : "";
110
+ let shown = jobs.length, army;
111
+ for (;;) {
112
+ const rest = jobs.length - shown;
113
+ army = (!jobs.length ? "idle" : `${count}${jobs.slice(0, shown).join(" · ")}${rest ? `${shown ? " " : ""}+${rest}` : ""}`)
114
+ + (elsewhere ? ` · ${elsewhere} in other repo${elsewhere === 1 ? "" : "s"}` : "");
115
+ if (shown === 0 || [...`${prefix}${army}${runPart}`].length <= maxLength) break;
116
+ shown--;
117
+ }
118
+ return `${prefix}${army}${runPart}`;
119
+ }
120
+
121
+ const isMain = (() => { try { return path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); } catch { return false; } })();
122
+ if (isMain) {
123
+ let session = {};
124
+ if (!process.stdin.isTTY) { try { session = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { /* no session JSON */ } }
125
+ process.stdout.write(`${statusLineText({ session })}\n`);
126
+ }
@@ -0,0 +1,68 @@
1
+ // Subscription agents at dispatch time: exact-name lookup (never a pick),
2
+ // and the one-credential-per-provider conflict check. The agents
3
+ // themselves are defined in agents.yml (lib/agents.mjs), which hands these
4
+ // functions the `{ config: { workers } }` shape they were written for.
5
+
6
+ import { openclawProviderId } from "./dispatch-schema.mjs";
7
+
8
+ /**
9
+ * Look up a named worker in a loaded subscription config, or throw a clear
10
+ * error naming which ones DO exist -- a typo must never silently fall back
11
+ * to anything else. There is no picker
12
+ * function alongside this one, by design: dispatch to this mode always
13
+ * names an entry, never selects one.
14
+ */
15
+ export function resolveSubscriptionWorker(subscriptionConfig, name) {
16
+ const workers = subscriptionConfig?.config?.workers;
17
+ // hasOwnProperty, not a truthy lookup -- same reasoning as
18
+ // lib/dispatch-config.mjs's resolvePool: `workers?.["__proto__"]` on a
19
+ // plain object returns Object.prototype itself, a real truthy value even
20
+ // though lib/agents.mjs already refuses a file that declares one.
21
+ const entry = workers && Object.prototype.hasOwnProperty.call(workers, name) ? workers[name] : undefined;
22
+ if (!entry) {
23
+ const known = Object.keys(subscriptionConfig?.config?.workers || {});
24
+ throw new Error(
25
+ known.length
26
+ ? `unknown subscription agent "${name}" -- your subscription agents are: ${known.join(", ")}`
27
+ : `unknown subscription agent "${name}" -- none are defined yet (run \`nomarmy agents add subscription\`)`,
28
+ );
29
+ }
30
+ return { id: name, ...entry };
31
+ }
32
+
33
+ /**
34
+ * OpenClaw providers that BOTH an api agent (as its one-entry pool) and a
35
+ * subscription agent use. Some vendors (OpenAI, Meta, xAI) put their
36
+ * subscription credential and their pay-as-you-go API key under the same
37
+ * OpenClaw provider id -- so a pool entry for that provider could silently
38
+ * run on the subscription credential (or a subscription agent on the
39
+ * metered key), which is exactly the pooling subscriptions must never be. Only Claude (claude-cli vs anthropic) uses distinct ids and
40
+ * never collides. Callers refuse to dispatch either side of
41
+ * a conflict rather than guess which credential OpenClaw will pick.
42
+ * @returns {{ provider: string, poolEntries: string[], workers: string[] }[]}
43
+ */
44
+ export function findProviderConflicts(pools = {}, workers = {}) {
45
+ const poolByProvider = new Map();
46
+ for (const [pool, entries] of Object.entries(pools || {})) {
47
+ for (const entry of entries || []) {
48
+ const provider = openclawProviderId(entry);
49
+ if (!poolByProvider.has(provider)) poolByProvider.set(provider, []);
50
+ poolByProvider.get(provider).push(`${pool}/${entry.id}`);
51
+ }
52
+ }
53
+ const conflicts = new Map();
54
+ for (const [name, entry] of Object.entries(workers || {})) {
55
+ const poolEntries = poolByProvider.get(entry.provider);
56
+ if (!poolEntries) continue;
57
+ if (!conflicts.has(entry.provider)) conflicts.set(entry.provider, { provider: entry.provider, poolEntries, workers: [] });
58
+ conflicts.get(entry.provider).workers.push(name);
59
+ }
60
+ return [...conflicts.values()];
61
+ }
62
+
63
+ /** One readable refusal line for a conflict, shared by dispatch and the CLI. */
64
+ export function describeProviderConflict(conflict) {
65
+ const apiAgents = conflict.poolEntries.map((e) => e.split("/").pop());
66
+ return `OpenClaw provider "${conflict.provider}" is used by both api agent${apiAgents.length === 1 ? "" : "s"} ${apiAgents.join(", ")} and subscription agent${conflict.workers.length === 1 ? "" : "s"} ${conflict.workers.join(", ")} -- OpenClaw holds one credential per provider, so the api agent could run on the subscription (or the subscription on the metered key). Keep only one of them.`;
67
+ }
68
+
@@ -0,0 +1,217 @@
1
+ // Pure pieces of `nomarmy subscriptions setup <vendor>` -- the guided path
2
+ // that wraps every OpenClaw step behind one command so an operator never has
3
+ // to know OpenClaw is involved for the common case. bin/nomarmy.mjs owns the
4
+ // actual subprocess calls and prompts; everything here is parse/decide logic
5
+ // so it can be tested without a real TTY, a real login, or a real network.
6
+ //
7
+ // Every value in SUBSCRIPTION_VENDORS was confirmed live against a real
8
+ // install, not taken from docs: "claude-cli" (not "anthropic-cli", a wrong
9
+ // guess that cost real time), Codex needing OpenAI's own @openai/codex CLI
10
+ // installed separately, @openclaw/codex needing OpenClaw >= 2026.9.5, and a
11
+ // ChatGPT plan running under OpenClaw's `openai` provider, not `codex` (the
12
+ // codex login is imported as an `openai/oauth` profile; `codex/<model>`
13
+ // answers "Unknown model" while `openai/gpt-6-astra` completes).
14
+ // KNOWN TO DRIFT: if OpenClaw or a vendor renames any of this, the table goes
15
+ // stale before nomArmy's own code does.
16
+ //
17
+ // `credential.kind` is how the subscription credential actually reaches
18
+ // OpenClaw, and it genuinely differs per vendor (each confirmed live):
19
+ // cli-session OpenClaw reuses the vendor CLI's own logged-in session
20
+ // directly; nothing to link (Claude).
21
+ // openclaw-login OpenClaw's plugin wants its own `models auth login` on
22
+ // top of the vendor CLI's login (Codex). `loginProvider`
23
+ // is the id that login takes when it differs from the
24
+ // provider the models run under.
25
+ // minted-key The vendor CLI's login mints a Model API key into the OS
26
+ // keychain, and OpenClaw's plugin only accepts an API key
27
+ // (Meta: @openclaw/meta-provider declares authMethods
28
+ // ["api-key"] only). Meta's own subscription docs say the
29
+ // flat rate covers "the Muse Code API key that is
30
+ // automatically connected in the Muse Code CLI onboarding
31
+ // process" -- that minted key, not one you create by hand
32
+ // (those bill pay-as-you-go). It is copied into OpenClaw by
33
+ // stdin on every setup run, since it can rotate.
34
+
35
+ import { modelRejection } from "./openclaw-errors.mjs";
36
+
37
+ export const SUBSCRIPTION_VENDORS = Object.freeze({
38
+ claude: Object.freeze({
39
+ label: "Anthropic Claude (Pro/Max/Team seat)",
40
+ provider: "claude-cli",
41
+ cli: Object.freeze({
42
+ bin: "claude",
43
+ npmPackage: null,
44
+ installHint: "Install Claude Code first: https://claude.com/claude-code",
45
+ statusArgs: ["auth", "status"],
46
+ loginArgs: ["auth", "login"],
47
+ }),
48
+ plugin: null,
49
+ credential: Object.freeze({ kind: "cli-session" }),
50
+ defaultModel: null,
51
+ }),
52
+ codex: Object.freeze({
53
+ label: "OpenAI Codex (ChatGPT plan)",
54
+ provider: "openai",
55
+ cli: Object.freeze({
56
+ bin: "codex",
57
+ npmPackage: "@openai/codex",
58
+ installHint: "npm install -g @openai/codex",
59
+ statusArgs: ["login", "status"],
60
+ loginArgs: ["login"],
61
+ }),
62
+ plugin: Object.freeze({ id: "codex", spec: "clawhub:@openclaw/codex", minOpenclaw: "2026.9.5" }),
63
+ credential: Object.freeze({ kind: "openclaw-login", loginProvider: "codex" }),
64
+ // The top entry in Codex's own ~/.codex/models_cache.json.
65
+ defaultModel: "gpt-6-astra",
66
+ }),
67
+ meta: Object.freeze({
68
+ label: "Meta Muse Code (subscription)",
69
+ provider: "meta",
70
+ cli: Object.freeze({
71
+ bin: "muse",
72
+ npmPackage: null,
73
+ installHint: "Install Muse Code first (Meta's developer site, dev.meta.ai)",
74
+ // No status subcommand exists; `muse login` records a non-secret
75
+ // descriptor here (mechanism/storage/obtained_via -- confirmed live),
76
+ // and the credential itself goes to the OS keychain.
77
+ statusArgs: null,
78
+ statusFile: "~/.config/muse/auth.json",
79
+ loginArgs: ["login"],
80
+ }),
81
+ // providerConfig: the plugin's own step that writes Meta's entry into
82
+ // openclaw.json (lib/openclaw-config.mjs). Without it every Muse model is
83
+ // listed but fails "Unknown model" (confirmed live).
84
+ plugin: Object.freeze({ id: "meta", spec: "clawhub:@openclaw/meta-provider", minOpenclaw: "2026.9.3",
85
+ providerConfig: Object.freeze({ module: "dist/onboard.js", exportName: "applyMetaConfig" }) }),
86
+ credential: Object.freeze({
87
+ kind: "minted-key",
88
+ keychain: Object.freeze({ service: "ai.meta.dev.credentials", account: "meta", field: "api_key" }),
89
+ profileId: "meta:subscription",
90
+ }),
91
+ // The plugin manifest's own defaultModel; Meta models don't appear in
92
+ // OpenClaw's catalog until a credential is linked.
93
+ defaultModel: "muse-spark-1.3",
94
+ }),
95
+ });
96
+
97
+ /** "OpenClaw 2026.9.5 (ec9c1a1)" -> [2026, 9, 5], or null if unparseable. */
98
+ export function parseOpenclawVersion(text) {
99
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ""));
100
+ return match ? match.slice(1, 4).map(Number) : null;
101
+ }
102
+
103
+ /** True when `have` ([a,b,c]) is at least the dotted `min` string. */
104
+ export function versionAtLeast(have, min) {
105
+ const want = parseOpenclawVersion(min);
106
+ if (!have || !want) return false;
107
+ for (let i = 0; i < 3; i++) {
108
+ if (have[i] > want[i]) return true;
109
+ if (have[i] < want[i]) return false;
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Model ids for one provider from `openclaw models list --refresh` output,
116
+ * which prints one `<provider>/<model> <capabilities> ...` row per model.
117
+ * Returned without the provider prefix, in catalog order, de-duplicated.
118
+ */
119
+ export function parseCatalogModels(listOutput, provider) {
120
+ const prefix = `${provider}/`;
121
+ const models = [];
122
+ for (const line of String(listOutput ?? "").split(/\r?\n/)) {
123
+ const first = line.trim().split(/\s+/)[0] ?? "";
124
+ if (first.startsWith(prefix) && first.length > prefix.length) models.push(first.slice(prefix.length));
125
+ }
126
+ return [...new Set(models)];
127
+ }
128
+
129
+ /**
130
+ * Whether a vendor CLI's own status command says it's logged in, plus the
131
+ * account email when the CLI reports one (used as the default owner, so the
132
+ * operator doesn't retype who they are). Claude prints JSON
133
+ * (`{"loggedIn":true,"email":...}`); Codex prints a line like
134
+ * "Logged in using ChatGPT". Anything unrecognized is "not logged in" --
135
+ * running the login again is harmless, silently skipping a needed one isn't.
136
+ */
137
+ export function parseCliLoginStatus(vendorKey, output) {
138
+ const text = String(output ?? "");
139
+ if (vendorKey === "claude") {
140
+ try {
141
+ // stdout and stderr arrive merged (see bin/nomarmy.mjs's runQuiet), so
142
+ // parse just the JSON object, not the whole blob.
143
+ const parsed = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
144
+ return { loggedIn: parsed.loggedIn === true, email: typeof parsed.email === "string" ? parsed.email : null, subscriptionType: parsed.subscriptionType ?? null };
145
+ } catch {
146
+ return { loggedIn: false, email: null, subscriptionType: null };
147
+ }
148
+ }
149
+ if (vendorKey === "codex") {
150
+ return { loggedIn: /^\s*logged in\b/im.test(text) && !/not logged in/i.test(text), email: null, subscriptionType: /chatgpt/i.test(text) ? "chatgpt" : null };
151
+ }
152
+ return { loggedIn: false, email: null, subscriptionType: null };
153
+ }
154
+
155
+ /**
156
+ * The one-token probe envelope `openclaw agent exec --json` returns; true
157
+ * only on a real completion. Tolerates log lines around the envelope (it
158
+ * is parsed from its first "{" line to its last "}"), since OpenClaw
159
+ * writes a colored run log to stderr and callers may have merged the two.
160
+ */
161
+ export function probeSucceeded(stdout) {
162
+ const text = String(stdout ?? "");
163
+ const start = text.search(/^\{/m), end = text.lastIndexOf("}");
164
+ if (start === -1 || end < start) return false;
165
+ try {
166
+ const parsed = JSON.parse(text.slice(start, end + 1));
167
+ return parsed?.ok === true && typeof parsed.final === "string" && parsed.final.length > 0;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * A test call's outcome, from both streams: { ok, reason }.
175
+ *
176
+ * It must read the way a job does. A model the provider refuses fails
177
+ * with a model_not_found reason in the run log; a run that answered and
178
+ * then hit OpenClaw's known cleanup error (Codex: "Agent runtime cleanup did
179
+ * not settle", after stopReason=stop) worked, and jobs salvage it too.
180
+ */
181
+ export function probeOutcome({ stdout = "", stderr = "" } = {}) {
182
+ if (probeSucceeded(stdout)) return { ok: true, reason: null };
183
+ const rejected = modelRejection(`${stderr}\n${stdout}`);
184
+ if (rejected) return { ok: false, reason: rejected.message };
185
+ if (/ended with stopReason=stop\b/.test(stderr) && /cleanup/i.test(`${stdout}\n${stderr}`)) return { ok: true, reason: null };
186
+ const message = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(stdout)?.[1];
187
+ return { ok: false, reason: message ? message.replace(/\\"/g, '"').slice(0, 300) : null };
188
+ }
189
+
190
+ /**
191
+ * Muse Code's non-secret login descriptor (~/.config/muse/auth.json) ->
192
+ * { loggedIn, email }. Reads only descriptor fields; the credential itself
193
+ * is never in this file (storage: "keychain").
194
+ */
195
+ export function parseMuseAuthDescriptor(text) {
196
+ try {
197
+ const meta = JSON.parse(String(text ?? ""))?.providers?.meta;
198
+ return { loggedIn: Boolean(meta?.mechanism), email: typeof meta?.user_email === "string" ? meta.user_email : null, subscriptionType: meta?.mechanism ? "muse-code" : null };
199
+ } catch {
200
+ return { loggedIn: false, email: null, subscriptionType: null };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The minted API key out of a keychain JSON blob, or null. Muse's blob holds
206
+ * both an `api_key` ("LLM|<id>|<secret>") and an OAuth `access_token`; only
207
+ * the api_key works as a bearer against api.meta.ai, so anything that isn't
208
+ * that exact three-part shape is refused rather than passed on.
209
+ */
210
+ export function extractMintedKey(blob, field) {
211
+ try {
212
+ const value = JSON.parse(String(blob ?? ""))?.[field];
213
+ return typeof value === "string" && /^LLM\|[^|\s]+\|[^|\s]+$/.test(value) ? value : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }