cruo-agent 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/dist/VERSION +1 -1
- package/dist/cli.js +462 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# cruo-agent
|
|
2
2
|
|
|
3
3
|
Run a Cruo agent. It watches your board, picks up the cards you assign it, and
|
|
4
|
-
works them — on your machine, under your own
|
|
4
|
+
works them — on your machine, under your own account with the model's vendor:
|
|
5
|
+
Claude Code by default, or Codex (GPT), Antigravity (Gemini) or Hermes Agent
|
|
6
|
+
(GPT, Gemini, Grok, Hermes and open models).
|
|
5
7
|
|
|
6
8
|
```bash
|
|
7
9
|
npx cruo-agent login cruo_pat_… # the token Cruo showed you once
|
|
@@ -13,7 +15,7 @@ npx cruo-agent run # let it work
|
|
|
13
15
|
|
|
14
16
|
| you need | check it with |
|
|
15
17
|
|---|---|
|
|
16
|
-
| [Claude Code](https://claude.com/claude-code) installed and signed in — it is the harness, and the agent reaches no model without it | `claude -p 'say ok'` |
|
|
18
|
+
| [Claude Code](https://claude.com/claude-code) installed and signed in — it is the default harness, and the agent reaches no model without it. Using another model instead? See [Choosing the model](#choosing-the-model) | `claude -p 'say ok'` |
|
|
17
19
|
| Node 20 or newer | `node -v` |
|
|
18
20
|
|
|
19
21
|
Worth ten seconds, because both failures are silent. A missing harness exits 127
|
|
@@ -35,11 +37,59 @@ differently:
|
|
|
35
37
|
to nothing, and nothing runs because it exists.
|
|
36
38
|
- **The supervisor** is this program. It watches the board with a plain database
|
|
37
39
|
query, so sitting idle costs nothing, and starts a harness when there is work.
|
|
38
|
-
- **The harness** is
|
|
39
|
-
|
|
40
|
+
- **The harness** is the agent CLI that does the work — Claude Code unless you
|
|
41
|
+
choose [another](#choosing-the-model) — started fresh for one card and gone
|
|
42
|
+
when that card is done. Nothing carries over between cards but the board
|
|
43
|
+
itself.
|
|
40
44
|
|
|
41
45
|
Cruo runs no model of its own and never bills you for inference.
|
|
42
46
|
|
|
47
|
+
## Choosing the model
|
|
48
|
+
|
|
49
|
+
`--harness-kind` picks which agent CLI works the card. Each runs on your own
|
|
50
|
+
account with that vendor; Cruo bills you for no inference either way.
|
|
51
|
+
|
|
52
|
+
| `--harness-kind` | models | needs | check |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `claude` (default) | Claude | `claude` installed and signed in | `claude -p 'say ok'` |
|
|
55
|
+
| `codex` · preview | GPT, on your ChatGPT plan | `brew install --cask codex`, then `codex login` | `codex exec 'say ok'` |
|
|
56
|
+
| `antigravity` · preview | Gemini, on your Google sign-in | `brew install --cask antigravity-cli`, then sign in once with `agy` | `agy -p 'say ok'` |
|
|
57
|
+
| `hermes` · preview | GPT, Gemini, Grok, Hermes and open models | [Hermes Agent](https://hermes-agent.nousresearch.com) with a provider key in `~/.hermes/.env`; needs `--provider` and `--model` | `hermes -z 'say ok' --provider openrouter -m openai/gpt-4o-mini` |
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cruo run --worktree --harness-kind codex --allow "mcp__cruo,Read,Edit,Write,Bash"
|
|
61
|
+
cruo run --worktree --harness-kind hermes --provider openrouter --model openai/gpt-4o-mini \
|
|
62
|
+
--allow "mcp__cruo,Read,Edit,Write,Bash"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**What each can touch outside the card's checkout** — the thing to know before
|
|
66
|
+
leaving one running unattended:
|
|
67
|
+
|
|
68
|
+
- **claude** — its file tools are held to the worktree; its shell (if `--allow`
|
|
69
|
+
includes `Bash`) is not, and known dangerous commands are denied by name.
|
|
70
|
+
- **codex** — the strongest without a container. The operating system's sandbox
|
|
71
|
+
refuses writes outside the worktree and the git directories a commit needs;
|
|
72
|
+
`.git/hooks` and `.git/config` stay read-only, so a run cannot plant a hook
|
|
73
|
+
that your own git would later execute. Reads are not limited.
|
|
74
|
+
- **antigravity** — runs with `--sandbox`: its terminal cannot write outside the
|
|
75
|
+
workspace. Whether its file-editing tool is held to the worktree is not
|
|
76
|
+
verified — Gemini declines to attempt a boundary test. Each run
|
|
77
|
+
adds a `cruo-run-*` server to `~/.gemini/config/mcp_config.json` and removes
|
|
78
|
+
it after; a supervisor sweeps any a killed run left behind.
|
|
79
|
+
- **hermes** — **unconfined**: its shell and file tools reach anything your user
|
|
80
|
+
can. The supervisor says so at every start.
|
|
81
|
+
|
|
82
|
+
`--allow` stays the one knob for all four: it is mapped onto each harness's own
|
|
83
|
+
controls (Hermes toolsets; Codex and Antigravity drop to read-only / plan mode
|
|
84
|
+
when it names no write tool).
|
|
85
|
+
|
|
86
|
+
**Preview** means each has worked a real card end to end — claimed it, changed
|
|
87
|
+
the file, committed, moved it and commented, checked on the branch and the
|
|
88
|
+
board — but has not yet had the months of use Claude Code has. Codex's sandbox
|
|
89
|
+
was also checked on disk: writes outside the worktree, into `.git/hooks` and
|
|
90
|
+
into `.git/config` were refused while the commit still landed. `--usage`
|
|
91
|
+
records tokens only for Claude Code.
|
|
92
|
+
|
|
43
93
|
## Getting a token
|
|
44
94
|
|
|
45
95
|
Cruo → Settings → Members → **Add an agent**. The token is shown once; Cruo
|
|
@@ -109,6 +159,9 @@ given up on work nobody abandoned.
|
|
|
109
159
|
| `--interval <seconds>` | how often to poll (default 20) |
|
|
110
160
|
| `--worktree` | a checkout per card |
|
|
111
161
|
| `--allow <tools>` | what the harness may use |
|
|
162
|
+
| `--harness-kind <kind>` | `claude` (default), `codex`, `antigravity` or `hermes` — see [Choosing the model](#choosing-the-model) |
|
|
163
|
+
| `--model <name>` | the model the harness uses. Required for `hermes`; the others fall back to the account's default |
|
|
164
|
+
| `--provider <name>` | `hermes` only: where the model comes from, e.g. `openrouter` |
|
|
112
165
|
| `--push` | publish the branch after a run that commits |
|
|
113
166
|
| `--harness-timeout <seconds>` | kill a run that wedges (default 3000 — 50 minutes). It used to have to stay under an hour, because that is how long a run's board session lasted; since 0.1.17 the session is renewed under a running card, so what this bounds now is spend, not credentials |
|
|
114
167
|
| `--max-attempts <n>` | give up on a card after n unproductive runs (default 3) |
|
package/dist/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.20
|
package/dist/cli.js
CHANGED
|
@@ -15300,6 +15300,13 @@ var init_order = __esm({
|
|
|
15300
15300
|
}
|
|
15301
15301
|
});
|
|
15302
15302
|
|
|
15303
|
+
// ../../packages/core/dist/csv.js
|
|
15304
|
+
var init_csv = __esm({
|
|
15305
|
+
"../../packages/core/dist/csv.js"() {
|
|
15306
|
+
"use strict";
|
|
15307
|
+
}
|
|
15308
|
+
});
|
|
15309
|
+
|
|
15303
15310
|
// ../../packages/core/dist/index.js
|
|
15304
15311
|
var init_dist = __esm({
|
|
15305
15312
|
"../../packages/core/dist/index.js"() {
|
|
@@ -15315,6 +15322,7 @@ var init_dist = __esm({
|
|
|
15315
15322
|
init_tree();
|
|
15316
15323
|
init_place();
|
|
15317
15324
|
init_order();
|
|
15325
|
+
init_csv();
|
|
15318
15326
|
}
|
|
15319
15327
|
});
|
|
15320
15328
|
|
|
@@ -46070,6 +46078,7 @@ var init_options = __esm({
|
|
|
46070
46078
|
"cwd",
|
|
46071
46079
|
"function",
|
|
46072
46080
|
"harness",
|
|
46081
|
+
"harness-kind",
|
|
46073
46082
|
"harness-timeout",
|
|
46074
46083
|
"interval",
|
|
46075
46084
|
"limit",
|
|
@@ -46079,6 +46088,7 @@ var init_options = __esm({
|
|
|
46079
46088
|
"model",
|
|
46080
46089
|
"prepare",
|
|
46081
46090
|
"prepare-timeout",
|
|
46091
|
+
"provider",
|
|
46082
46092
|
"project",
|
|
46083
46093
|
"push-remote",
|
|
46084
46094
|
"repo",
|
|
@@ -46695,6 +46705,223 @@ var init_claim = __esm({
|
|
|
46695
46705
|
}
|
|
46696
46706
|
});
|
|
46697
46707
|
|
|
46708
|
+
// src/hermes.ts
|
|
46709
|
+
function hermesToolsets(allow, mcpServerName = "cruo") {
|
|
46710
|
+
const out = /* @__PURE__ */ new Set();
|
|
46711
|
+
for (const raw of allow.split(",")) {
|
|
46712
|
+
const t = raw.trim().replace(/\(.*$/, "");
|
|
46713
|
+
if (!t || t.startsWith("mcp__")) continue;
|
|
46714
|
+
const ts = TOOLSET_FOR[t];
|
|
46715
|
+
if (ts) out.add(ts);
|
|
46716
|
+
}
|
|
46717
|
+
return [...out, mcpServerName];
|
|
46718
|
+
}
|
|
46719
|
+
function hermesConfigYaml(servers) {
|
|
46720
|
+
const q = (s) => JSON.stringify(s);
|
|
46721
|
+
const lines = ["mcp_servers:"];
|
|
46722
|
+
for (const [name, s] of Object.entries(servers)) {
|
|
46723
|
+
lines.push(` ${q(name)}:`);
|
|
46724
|
+
lines.push(` command: ${q(s.command)}`);
|
|
46725
|
+
lines.push(` args: [${s.args.map(q).join(", ")}]`);
|
|
46726
|
+
lines.push(` env:`);
|
|
46727
|
+
for (const [k, v] of Object.entries(s.env)) lines.push(` ${q(k)}: ${q(v)}`);
|
|
46728
|
+
}
|
|
46729
|
+
return lines.join("\n") + "\n";
|
|
46730
|
+
}
|
|
46731
|
+
function providerKeysFrom(dotenv, already) {
|
|
46732
|
+
const out = {};
|
|
46733
|
+
for (const line of dotenv.split("\n")) {
|
|
46734
|
+
const m = /^([A-Z][A-Z0-9_]*(?:_API_KEY|_TOKEN))=(.*)$/.exec(line.trim());
|
|
46735
|
+
if (!m) continue;
|
|
46736
|
+
const [, name, raw] = m;
|
|
46737
|
+
const value = raw.replace(/^["']|["']$/g, "");
|
|
46738
|
+
if (value && !already[name]) out[name] = value;
|
|
46739
|
+
}
|
|
46740
|
+
return out;
|
|
46741
|
+
}
|
|
46742
|
+
function hermesArgs(input) {
|
|
46743
|
+
return [
|
|
46744
|
+
"-z",
|
|
46745
|
+
`${input.systemPrompt}
|
|
46746
|
+
|
|
46747
|
+
---
|
|
46748
|
+
|
|
46749
|
+
Your task for this run:
|
|
46750
|
+
|
|
46751
|
+
${input.userPrompt}`,
|
|
46752
|
+
"-t",
|
|
46753
|
+
input.toolsets.join(","),
|
|
46754
|
+
"--provider",
|
|
46755
|
+
input.provider,
|
|
46756
|
+
"-m",
|
|
46757
|
+
input.model,
|
|
46758
|
+
"--ignore-rules"
|
|
46759
|
+
];
|
|
46760
|
+
}
|
|
46761
|
+
function hermesPythonFrom(launcherText, readText) {
|
|
46762
|
+
const shebangOf = (text) => /^#!\s*(\S*python[\d.]*)\s*$/m.exec(text.split("\n")[0] ?? "")?.[1] ?? null;
|
|
46763
|
+
const direct = shebangOf(launcherText);
|
|
46764
|
+
if (direct) return direct;
|
|
46765
|
+
const target = /exec\s+"([^"]+)"/.exec(launcherText)?.[1];
|
|
46766
|
+
if (!target) return null;
|
|
46767
|
+
const targetText = readText(target);
|
|
46768
|
+
return targetText ? shebangOf(targetText) : null;
|
|
46769
|
+
}
|
|
46770
|
+
var TOOLSET_FOR, HERMES_MCP_WAIT_SECONDS, HERMES_LAUNCHER_PY;
|
|
46771
|
+
var init_hermes = __esm({
|
|
46772
|
+
"src/hermes.ts"() {
|
|
46773
|
+
"use strict";
|
|
46774
|
+
TOOLSET_FOR = {
|
|
46775
|
+
Bash: "terminal",
|
|
46776
|
+
Read: "file",
|
|
46777
|
+
Glob: "file",
|
|
46778
|
+
Grep: "file",
|
|
46779
|
+
Edit: "file",
|
|
46780
|
+
Write: "file",
|
|
46781
|
+
NotebookEdit: "file"
|
|
46782
|
+
};
|
|
46783
|
+
HERMES_MCP_WAIT_SECONDS = 20;
|
|
46784
|
+
HERMES_LAUNCHER_PY = [
|
|
46785
|
+
"import sys",
|
|
46786
|
+
"try:",
|
|
46787
|
+
" from tools.mcp_tool import discover_mcp_tools",
|
|
46788
|
+
" discover_mcp_tools()",
|
|
46789
|
+
" import hermes_cli.mcp_startup as _m",
|
|
46790
|
+
" _m._mcp_discovery_started = True",
|
|
46791
|
+
" _orig = _m.wait_for_mcp_discovery",
|
|
46792
|
+
` _m.wait_for_mcp_discovery = lambda timeout=0.75: _orig(timeout=max(timeout, ${HERMES_MCP_WAIT_SECONDS}.0))`,
|
|
46793
|
+
"except Exception:",
|
|
46794
|
+
" pass",
|
|
46795
|
+
"from hermes_cli.main import main",
|
|
46796
|
+
"sys.argv = ['hermes'] + sys.argv[1:]",
|
|
46797
|
+
"sys.exit(main())"
|
|
46798
|
+
].join("\n");
|
|
46799
|
+
}
|
|
46800
|
+
});
|
|
46801
|
+
|
|
46802
|
+
// src/codex.ts
|
|
46803
|
+
function codexMcpOverrides(name, s) {
|
|
46804
|
+
const env = Object.entries(s.env).map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`).join(", ");
|
|
46805
|
+
return [
|
|
46806
|
+
"-c",
|
|
46807
|
+
`mcp_servers.${name}.command=${tomlString(s.command)}`,
|
|
46808
|
+
"-c",
|
|
46809
|
+
`mcp_servers.${name}.args=[${s.args.map(tomlString).join(", ")}]`,
|
|
46810
|
+
"-c",
|
|
46811
|
+
`mcp_servers.${name}.env={ ${env} }`,
|
|
46812
|
+
// Pre-approved. A headless run has nobody to approve a tool call, and
|
|
46813
|
+
// without this Codex refused every board WRITE ("requires approval, while
|
|
46814
|
+
// the approval policy is never"; seen 2026-09-23) — the agent could read
|
|
46815
|
+
// its card and do the work, but not claim it, move it or comment. These
|
|
46816
|
+
// tools act as the agent's own account under RLS, which is what Claude's
|
|
46817
|
+
// `--allowedTools mcp__cruo` already grants. Values in 0.156.1: auto,
|
|
46818
|
+
// prompt, writes, approve.
|
|
46819
|
+
"-c",
|
|
46820
|
+
`mcp_servers.${name}.default_tools_approval_mode="approve"`
|
|
46821
|
+
];
|
|
46822
|
+
}
|
|
46823
|
+
function gitWritableDirs(commonDir, gitDir) {
|
|
46824
|
+
const dirs = [`${commonDir}/objects`, `${commonDir}/refs`, `${commonDir}/logs`];
|
|
46825
|
+
if (gitDir !== commonDir) dirs.push(gitDir);
|
|
46826
|
+
return dirs;
|
|
46827
|
+
}
|
|
46828
|
+
function codexCanWrite(allow) {
|
|
46829
|
+
return allow.split(",").map((t) => t.trim().replace(/\(.*$/, "")).some((t) => ["Bash", "Edit", "Write", "NotebookEdit"].includes(t));
|
|
46830
|
+
}
|
|
46831
|
+
function codexArgs(input) {
|
|
46832
|
+
return [
|
|
46833
|
+
"exec",
|
|
46834
|
+
// No `--system-prompt` in `codex exec`: the supervisor's standing rules lead
|
|
46835
|
+
// the prompt, set apart from this run's task (same as Hermes).
|
|
46836
|
+
`${input.systemPrompt}
|
|
46837
|
+
|
|
46838
|
+
---
|
|
46839
|
+
|
|
46840
|
+
Your task for this run:
|
|
46841
|
+
|
|
46842
|
+
${input.userPrompt}`,
|
|
46843
|
+
"--sandbox",
|
|
46844
|
+
input.writable ? "workspace-write" : "read-only",
|
|
46845
|
+
"-C",
|
|
46846
|
+
input.cwd,
|
|
46847
|
+
...input.writable ? input.addDirs.flatMap((d) => ["--add-dir", d]) : [],
|
|
46848
|
+
"--ignore-user-config",
|
|
46849
|
+
"--ignore-rules",
|
|
46850
|
+
// No session files left in CODEX_HOME for a run nobody will resume.
|
|
46851
|
+
"--ephemeral",
|
|
46852
|
+
"--color",
|
|
46853
|
+
"never",
|
|
46854
|
+
...input.model ? ["-m", input.model] : [],
|
|
46855
|
+
...codexMcpOverrides(input.mcp.name, input.mcp.server)
|
|
46856
|
+
];
|
|
46857
|
+
}
|
|
46858
|
+
var tomlString;
|
|
46859
|
+
var init_codex = __esm({
|
|
46860
|
+
"src/codex.ts"() {
|
|
46861
|
+
"use strict";
|
|
46862
|
+
tomlString = (s) => JSON.stringify(s);
|
|
46863
|
+
}
|
|
46864
|
+
});
|
|
46865
|
+
|
|
46866
|
+
// src/antigravity.ts
|
|
46867
|
+
function agyServerName(runId) {
|
|
46868
|
+
return `${AGY_RUN_PREFIX}${runId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
|
|
46869
|
+
}
|
|
46870
|
+
function agyMcpAddArgs(name, s) {
|
|
46871
|
+
return [
|
|
46872
|
+
"mcp",
|
|
46873
|
+
"add",
|
|
46874
|
+
...Object.entries(s.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
46875
|
+
name,
|
|
46876
|
+
"--",
|
|
46877
|
+
s.command,
|
|
46878
|
+
...s.args
|
|
46879
|
+
];
|
|
46880
|
+
}
|
|
46881
|
+
function agyMcpRemoveArgs(name) {
|
|
46882
|
+
return ["mcp", "remove", name];
|
|
46883
|
+
}
|
|
46884
|
+
function staleAgyServers(configText) {
|
|
46885
|
+
if (!configText.trim()) return [];
|
|
46886
|
+
let parsed;
|
|
46887
|
+
try {
|
|
46888
|
+
parsed = JSON.parse(configText);
|
|
46889
|
+
} catch {
|
|
46890
|
+
return [];
|
|
46891
|
+
}
|
|
46892
|
+
const root = parsed ?? {};
|
|
46893
|
+
const servers = root.mcpServers ?? root;
|
|
46894
|
+
return Object.keys(servers).filter((n2) => n2.startsWith(AGY_RUN_PREFIX));
|
|
46895
|
+
}
|
|
46896
|
+
function agyArgs(input) {
|
|
46897
|
+
return [
|
|
46898
|
+
// No system-prompt flag; the standing rules lead the prompt (as Hermes, Codex).
|
|
46899
|
+
"-p",
|
|
46900
|
+
`${input.systemPrompt}
|
|
46901
|
+
|
|
46902
|
+
---
|
|
46903
|
+
|
|
46904
|
+
Your task for this run:
|
|
46905
|
+
|
|
46906
|
+
${input.userPrompt}`,
|
|
46907
|
+
"--dangerously-skip-permissions",
|
|
46908
|
+
"--sandbox",
|
|
46909
|
+
// Without a write tool in --allow, plan mode: it can read and reason about
|
|
46910
|
+
// the checkout and use the board, but not change files.
|
|
46911
|
+
...input.writable ? [] : ["--mode", "plan"],
|
|
46912
|
+
...input.model ? ["--model", input.model] : [],
|
|
46913
|
+
"--output-format",
|
|
46914
|
+
"text"
|
|
46915
|
+
];
|
|
46916
|
+
}
|
|
46917
|
+
var AGY_RUN_PREFIX;
|
|
46918
|
+
var init_antigravity = __esm({
|
|
46919
|
+
"src/antigravity.ts"() {
|
|
46920
|
+
"use strict";
|
|
46921
|
+
AGY_RUN_PREFIX = "cruo-run-";
|
|
46922
|
+
}
|
|
46923
|
+
});
|
|
46924
|
+
|
|
46698
46925
|
// src/harness-signal.ts
|
|
46699
46926
|
function refusalIn(stdout) {
|
|
46700
46927
|
if (!stdout.trim()) return null;
|
|
@@ -46847,7 +47074,19 @@ var init_harness_signal = __esm({
|
|
|
46847
47074
|
// ("the endpoint is rate limited"), and the note above is explicit that a
|
|
46848
47075
|
// false positive is the worse error: it credits an issue an attempt it
|
|
46849
47076
|
// should have been charged.
|
|
46850
|
-
[/rate limit(ed| exceeded|s? reached)\b.*\btry again/i, "the account is rate limited"]
|
|
47077
|
+
[/rate limit(ed| exceeded|s? reached)\b.*\btry again/i, "the account is rate limited"],
|
|
47078
|
+
// Hermes Agent (CRA-78) reports EVERY provider failure the same way — on
|
|
47079
|
+
// STDOUT, with exit 0, which is the reply position: "API call failed after 3
|
|
47080
|
+
// retries: HTTP 404: Grok 3 Mini is deprecated…" (observed 2026-09-23; 402
|
|
47081
|
+
// for a key with no credit, 401, 429 alike). Unmatched, that reads as a model
|
|
47082
|
+
// that ran and said something, and the card is charged for an outage — the
|
|
47083
|
+
// CRA-75 / CRA-89 shape exactly. Anchored to a line start and the whole
|
|
47084
|
+
// "after N retries: HTTP nnn" clause, so prose about API errors is not it.
|
|
47085
|
+
// Not only HTTP errors: a provider that does not answer at all comes out as
|
|
47086
|
+
// "API call failed after 3 retries: Connection error." (DeepSeek,
|
|
47087
|
+
// 2026-09-23), with no status code. Same position, same meaning — the run
|
|
47088
|
+
// never reached a model — so the whole "after N retries:" line is the tell.
|
|
47089
|
+
[/^API call failed after \d+ retries: /m, "the model provider refused the call"]
|
|
46851
47090
|
];
|
|
46852
47091
|
num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
46853
47092
|
}
|
|
@@ -47136,6 +47375,7 @@ var supervisor_exports = {};
|
|
|
47136
47375
|
import { spawn } from "node:child_process";
|
|
47137
47376
|
import { createRequire } from "node:module";
|
|
47138
47377
|
import { mkdtemp, readdir as readdir3, readFile as readFile2, rm as rm3, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
|
|
47378
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
47139
47379
|
import { homedir as homedir2, tmpdir } from "node:os";
|
|
47140
47380
|
import { join as join3 } from "node:path";
|
|
47141
47381
|
import { fileURLToPath } from "node:url";
|
|
@@ -47527,6 +47767,138 @@ async function preflight() {
|
|
|
47527
47767
|
await cleanup();
|
|
47528
47768
|
}
|
|
47529
47769
|
}
|
|
47770
|
+
async function prepareHermesRun(mcpConfig, cwd) {
|
|
47771
|
+
const parsed = JSON.parse(await readFile2(mcpConfig, "utf8"));
|
|
47772
|
+
const home = await mkdtemp(join3(tmpdir(), "cruo-hermes-"));
|
|
47773
|
+
await writeFile3(join3(home, "config.yaml"), hermesConfigYaml(parsed.mcpServers), { mode: 384 });
|
|
47774
|
+
const dotenv = await readFile2(join3(homedir2(), ".hermes", ".env"), "utf8").catch(() => "");
|
|
47775
|
+
return {
|
|
47776
|
+
env: {
|
|
47777
|
+
...providerKeysFrom(dotenv, process.env),
|
|
47778
|
+
HERMES_HOME: home,
|
|
47779
|
+
// Hermes's shell starts where this says; without it a run began in the
|
|
47780
|
+
// wrong directory in the spike.
|
|
47781
|
+
TERMINAL_CWD: cwd
|
|
47782
|
+
},
|
|
47783
|
+
// CRUO_KEEP_HERMES_HOME=1 keeps it, for reading Hermes's own logs
|
|
47784
|
+
// (logs/mcp-stderr.log, logs/agent.log) after a run that went wrong. It
|
|
47785
|
+
// holds the run's board session, so it is opt-in and says where it is.
|
|
47786
|
+
cleanup: async () => {
|
|
47787
|
+
if (process.env.CRUO_KEEP_HERMES_HOME) {
|
|
47788
|
+
log(` kept HERMES_HOME for inspection: ${home} (holds this run's board session \u2014 delete it)`);
|
|
47789
|
+
return;
|
|
47790
|
+
}
|
|
47791
|
+
await rm3(home, { recursive: true, force: true });
|
|
47792
|
+
}
|
|
47793
|
+
};
|
|
47794
|
+
}
|
|
47795
|
+
async function cruoServerFrom(mcpConfig) {
|
|
47796
|
+
const parsed = JSON.parse(await readFile2(mcpConfig, "utf8"));
|
|
47797
|
+
return parsed.mcpServers.cruo;
|
|
47798
|
+
}
|
|
47799
|
+
async function gitDirsOf(cwd) {
|
|
47800
|
+
try {
|
|
47801
|
+
const out = await new Promise((resolve2, reject) => {
|
|
47802
|
+
const c = spawn("git", ["rev-parse", "--path-format=absolute", "--git-common-dir", "--git-dir"], { cwd });
|
|
47803
|
+
let buf = "";
|
|
47804
|
+
c.stdout.on("data", (d) => buf += d.toString());
|
|
47805
|
+
c.on("error", reject);
|
|
47806
|
+
c.on("close", (code) => code === 0 ? resolve2(buf) : reject(new Error(`git exited ${code}`)));
|
|
47807
|
+
});
|
|
47808
|
+
const [common, gitDir] = out.trim().split("\n");
|
|
47809
|
+
return common && gitDir ? { common, gitDir } : null;
|
|
47810
|
+
} catch {
|
|
47811
|
+
return null;
|
|
47812
|
+
}
|
|
47813
|
+
}
|
|
47814
|
+
function runQuiet(command, args) {
|
|
47815
|
+
return new Promise((resolve2, reject) => {
|
|
47816
|
+
const c = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"], env: harnessEnv() });
|
|
47817
|
+
let err = "";
|
|
47818
|
+
c.stderr?.on("data", (d) => err += d.toString());
|
|
47819
|
+
c.on("error", reject);
|
|
47820
|
+
c.on(
|
|
47821
|
+
"close",
|
|
47822
|
+
(code) => code === 0 ? resolve2() : reject(new Error(`${command} ${args.slice(0, 2).join(" ")} exited ${code}: ${err.trim().split("\n")[0] ?? ""}`))
|
|
47823
|
+
);
|
|
47824
|
+
});
|
|
47825
|
+
}
|
|
47826
|
+
async function resolveHermesPython() {
|
|
47827
|
+
const read = (p) => {
|
|
47828
|
+
try {
|
|
47829
|
+
return readFileSync2(p, "utf8").slice(0, 2e3);
|
|
47830
|
+
} catch {
|
|
47831
|
+
return null;
|
|
47832
|
+
}
|
|
47833
|
+
};
|
|
47834
|
+
const found = (process.env.PATH ?? "").split(":").map((d) => join3(d, options.harness)).find((p) => read(p) !== null);
|
|
47835
|
+
if (!found) return null;
|
|
47836
|
+
return hermesPythonFrom(read(found) ?? "", read);
|
|
47837
|
+
}
|
|
47838
|
+
async function sweepAgyServers() {
|
|
47839
|
+
const text = await readFile2(join3(homedir2(), ".gemini", "config", "mcp_config.json"), "utf8").catch(() => "");
|
|
47840
|
+
const stale = staleAgyServers(text);
|
|
47841
|
+
for (const name of stale) {
|
|
47842
|
+
await runQuiet(options.harness, agyMcpRemoveArgs(name)).catch(() => {
|
|
47843
|
+
});
|
|
47844
|
+
}
|
|
47845
|
+
return stale.length;
|
|
47846
|
+
}
|
|
47847
|
+
async function prepareAltHarness(input) {
|
|
47848
|
+
const { mcpConfig, cwd, worktree, hit, systemPromptText, userPromptText } = input;
|
|
47849
|
+
switch (options.harnessKind) {
|
|
47850
|
+
case "hermes": {
|
|
47851
|
+
const run = await prepareHermesRun(mcpConfig, cwd);
|
|
47852
|
+
const args = hermesArgs({
|
|
47853
|
+
systemPrompt: systemPromptText,
|
|
47854
|
+
userPrompt: userPromptText,
|
|
47855
|
+
toolsets: hermesToolsets(options.allow),
|
|
47856
|
+
model: options.model,
|
|
47857
|
+
provider: options.provider
|
|
47858
|
+
});
|
|
47859
|
+
return hermesPython ? { command: hermesPython, args: ["-c", HERMES_LAUNCHER_PY, ...args], ...run } : { command: options.harness, args, ...run };
|
|
47860
|
+
}
|
|
47861
|
+
case "codex": {
|
|
47862
|
+
const server = await cruoServerFrom(mcpConfig);
|
|
47863
|
+
const dirs = worktree ? await gitDirsOf(cwd) : null;
|
|
47864
|
+
return {
|
|
47865
|
+
command: options.harness,
|
|
47866
|
+
args: codexArgs({
|
|
47867
|
+
systemPrompt: systemPromptText,
|
|
47868
|
+
userPrompt: userPromptText,
|
|
47869
|
+
cwd,
|
|
47870
|
+
model: options.model,
|
|
47871
|
+
writable: codexCanWrite(options.allow),
|
|
47872
|
+
addDirs: dirs ? gitWritableDirs(dirs.common, dirs.gitDir) : [],
|
|
47873
|
+
mcp: { name: "cruo", server }
|
|
47874
|
+
}),
|
|
47875
|
+
env: {},
|
|
47876
|
+
cleanup: async () => {
|
|
47877
|
+
}
|
|
47878
|
+
};
|
|
47879
|
+
}
|
|
47880
|
+
case "antigravity": {
|
|
47881
|
+
const server = await cruoServerFrom(mcpConfig);
|
|
47882
|
+
const name = agyServerName(`${hit.ref}-${Date.now().toString(36)}`);
|
|
47883
|
+
await runQuiet(options.harness, agyMcpAddArgs(name, server));
|
|
47884
|
+
return {
|
|
47885
|
+
command: options.harness,
|
|
47886
|
+
args: agyArgs({
|
|
47887
|
+
systemPrompt: systemPromptText,
|
|
47888
|
+
userPrompt: userPromptText,
|
|
47889
|
+
model: options.model,
|
|
47890
|
+
writable: codexCanWrite(options.allow)
|
|
47891
|
+
}),
|
|
47892
|
+
env: {},
|
|
47893
|
+
cleanup: () => runQuiet(options.harness, agyMcpRemoveArgs(name)).catch(
|
|
47894
|
+
(e) => log(` could not remove ${name} from Antigravity's MCP config: ${String(e)} \u2014 the next start sweeps it`)
|
|
47895
|
+
)
|
|
47896
|
+
};
|
|
47897
|
+
}
|
|
47898
|
+
default:
|
|
47899
|
+
return null;
|
|
47900
|
+
}
|
|
47901
|
+
}
|
|
47530
47902
|
function harnessEnv() {
|
|
47531
47903
|
const env = { ...process.env };
|
|
47532
47904
|
for (const name of CRUO_OWNED_ENV) delete env[name];
|
|
@@ -47634,7 +48006,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47634
48006
|
const cwd = worktree?.path ?? options.cwd;
|
|
47635
48007
|
const { path: mcpConfig, cleanup, keepSessionFresh } = await writeMcpConfig();
|
|
47636
48008
|
const stopSessionRefresh = keepSessionFresh();
|
|
47637
|
-
const streaming = options.usage === true;
|
|
48009
|
+
const streaming = options.usage === true && options.harnessKind === "claude";
|
|
47638
48010
|
const builtinTools = [
|
|
47639
48011
|
...new Set(
|
|
47640
48012
|
options.allow.split(",").map((t) => t.trim()).filter((t) => t !== "" && !t.startsWith("mcp__")).map((t) => t.replace(/\(.*$/, ""))
|
|
@@ -47642,7 +48014,8 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47642
48014
|
].join(",");
|
|
47643
48015
|
const userPromptText = userPrompt(hit);
|
|
47644
48016
|
const systemPromptText = systemPrompt(ctx, identity, worktree);
|
|
47645
|
-
const
|
|
48017
|
+
const alt = await prepareAltHarness({ mcpConfig, cwd, worktree, hit, systemPromptText, userPromptText });
|
|
48018
|
+
const args = alt ? alt.args : [
|
|
47646
48019
|
"-p",
|
|
47647
48020
|
userPromptText,
|
|
47648
48021
|
"--mcp-config",
|
|
@@ -47659,6 +48032,14 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47659
48032
|
"--strict-mcp-config",
|
|
47660
48033
|
"--allowedTools",
|
|
47661
48034
|
options.allow,
|
|
48035
|
+
// CRA-131. The local database is shared by every agent and person on the
|
|
48036
|
+
// machine, so no agent resets it. `pnpm supabase:reset` refuses inside a
|
|
48037
|
+
// run on its own (CRUO_AGENT_RUN, below); these are the spellings that
|
|
48038
|
+
// bypass that script. A deny rule matches a prefix, so it is a fence and
|
|
48039
|
+
// not a wall — `bash -c "…"` still gets round it — but it stops the
|
|
48040
|
+
// ordinary command an agent reaches for, which is how 2026-09-19 happened.
|
|
48041
|
+
"--disallowedTools",
|
|
48042
|
+
DB_RESET_DENY.join(","),
|
|
47662
48043
|
"--system-prompt",
|
|
47663
48044
|
systemPromptText,
|
|
47664
48045
|
...options.model ? ["--model", options.model] : [],
|
|
@@ -47669,13 +48050,15 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47669
48050
|
];
|
|
47670
48051
|
try {
|
|
47671
48052
|
return await new Promise((resolve2) => {
|
|
47672
|
-
const child = spawn(options.harness, args, {
|
|
48053
|
+
const child = spawn(alt?.command ?? options.harness, args, {
|
|
47673
48054
|
// `pipe`, not `inherit`, so the streams can be counted on the way past.
|
|
47674
48055
|
// Watching a live run is the whole point of a foreground supervisor, so
|
|
47675
48056
|
// both are written straight through — this tees, it does not capture.
|
|
47676
48057
|
stdio: ["ignore", "pipe", "pipe"],
|
|
47677
48058
|
cwd,
|
|
47678
|
-
|
|
48059
|
+
// CRA-131: marks everything the harness runs as inside an agent run,
|
|
48060
|
+
// which is what makes `pnpm supabase:reset` refuse with no override.
|
|
48061
|
+
env: { ...harnessEnv(), CRUO_AGENT_RUN: hit.ref, ...alt?.env ?? {} }
|
|
47679
48062
|
});
|
|
47680
48063
|
const startedAtMs = Date.now();
|
|
47681
48064
|
let stdoutBytes = 0;
|
|
@@ -47775,6 +48158,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47775
48158
|
} finally {
|
|
47776
48159
|
stopSessionRefresh();
|
|
47777
48160
|
await cleanup();
|
|
48161
|
+
if (alt) await alt.cleanup();
|
|
47778
48162
|
}
|
|
47779
48163
|
}
|
|
47780
48164
|
async function loadAttempts(ctx, hits) {
|
|
@@ -48080,7 +48464,9 @@ async function tick(ctx, identity, deadTicks, allowance) {
|
|
|
48080
48464
|
log(`\u2192 ${hit.ref} "${hit.issue.title}" (${why})`);
|
|
48081
48465
|
worked += 1;
|
|
48082
48466
|
if (options.dryRun) {
|
|
48083
|
-
log(
|
|
48467
|
+
log(
|
|
48468
|
+
options.harnessKind === "claude" ? ` dry run \u2014 would invoke: ${options.harness} -p \u2026 --allowedTools ${options.allow}` : ` dry run \u2014 would invoke ${options.harnessKind} (${options.harness}) with --allow ${options.allow}`
|
|
48469
|
+
);
|
|
48084
48470
|
continue;
|
|
48085
48471
|
}
|
|
48086
48472
|
invoked += 1;
|
|
@@ -48260,6 +48646,41 @@ async function main() {
|
|
|
48260
48646
|
if (sessionEndpoint()) {
|
|
48261
48647
|
log(` sessions from ${sessionEndpoint()} \u2014 no service-role key needed here`);
|
|
48262
48648
|
}
|
|
48649
|
+
if (!HARNESS_KINDS.includes(options.harnessKind)) {
|
|
48650
|
+
console.error(`--harness-kind must be one of ${HARNESS_KINDS.join(", ")}, not "${options.harnessKind}".`);
|
|
48651
|
+
process.exit(2);
|
|
48652
|
+
}
|
|
48653
|
+
if (options.harnessKind === "hermes") {
|
|
48654
|
+
if (!options.model || !options.provider) {
|
|
48655
|
+
console.error(
|
|
48656
|
+
"--harness-kind hermes needs --provider and --model, e.g. --provider openrouter --model openai/gpt-4o-mini"
|
|
48657
|
+
);
|
|
48658
|
+
process.exit(2);
|
|
48659
|
+
}
|
|
48660
|
+
log(`harness: hermes \xB7 ${options.provider} \xB7 ${options.model}`);
|
|
48661
|
+
log(`! hermes runs UNCONFINED: its shell and file tools can reach anything this user can.`);
|
|
48662
|
+
log(` Claude Code's --restricted has no equivalent here; see CRA-78.`);
|
|
48663
|
+
hermesPython = await resolveHermesPython();
|
|
48664
|
+
log(
|
|
48665
|
+
hermesPython ? ` launched through ${hermesPython}, with MCP discovered before the run (hermes -z otherwise races the board tools)` : `! could not find Hermes's own Python \u2014 running plain \`${options.harness}\`; the Cruo board tools may be missing from some runs`
|
|
48666
|
+
);
|
|
48667
|
+
}
|
|
48668
|
+
if (options.harnessKind === "codex") {
|
|
48669
|
+
log(`harness: codex${options.model ? ` \xB7 ${options.model}` : " \xB7 the account's default model"}`);
|
|
48670
|
+
log(
|
|
48671
|
+
codexCanWrite(options.allow) ? ` sandbox: workspace-write \u2014 writes only to the worktree and the git dirs a commit needs; hooks and config stay read-only; reads are not limited` : ` sandbox: read-only \u2014 --allow names no write tool`
|
|
48672
|
+
);
|
|
48673
|
+
}
|
|
48674
|
+
if (options.harnessKind === "antigravity") {
|
|
48675
|
+
log(`harness: antigravity (agy)${options.model ? ` \xB7 ${options.model}` : " \xB7 the account's default model"}`);
|
|
48676
|
+
log(`! agy --sandbox restricts its terminal; whether its FILE edits are held to the worktree is not verified yet (CRA-78).`);
|
|
48677
|
+
log(` Each run registers the Cruo server in ~/.gemini/config/mcp_config.json as cruo-run-* and removes it after.`);
|
|
48678
|
+
const swept = await sweepAgyServers();
|
|
48679
|
+
if (swept > 0) log(` removed ${swept} cruo-run-* server(s) a killed run left in Antigravity's MCP config`);
|
|
48680
|
+
}
|
|
48681
|
+
if (options.harnessKind !== "claude" && options.usage) {
|
|
48682
|
+
log(` --usage is Claude Code's and is ignored for ${options.harnessKind}; runs record no tokens.`);
|
|
48683
|
+
}
|
|
48263
48684
|
const staleConfigs = await sweepStaleConfigs();
|
|
48264
48685
|
if (staleConfigs > 0) {
|
|
48265
48686
|
log(` cleared ${staleConfigs} temp config(s) left by a killed run`);
|
|
@@ -48400,7 +48821,7 @@ to catch: a second supervisor nothing could see or stop.
|
|
|
48400
48821
|
if (stopping) return;
|
|
48401
48822
|
}
|
|
48402
48823
|
}
|
|
48403
|
-
var argv, flag, opt, num2, ms, readOptions, options, log, sessionRenewal, worktreeConfig, lastRefusal, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, HEAD_STORED, lastHolding, warnedAboutSpendTable, stopping, paused, stopRequestedAt, currentRun, pendingWait;
|
|
48824
|
+
var argv, flag, opt, num2, ms, HARNESS_KINDS, DEFAULT_HARNESS_COMMAND, readOptions, options, log, sessionRenewal, worktreeConfig, lastRefusal, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, hermesPython, DB_RESET_DENY, HEAD_STORED, lastHolding, warnedAboutSpendTable, stopping, paused, stopRequestedAt, currentRun, pendingWait;
|
|
48404
48825
|
var init_supervisor = __esm({
|
|
48405
48826
|
"src/supervisor.ts"() {
|
|
48406
48827
|
"use strict";
|
|
@@ -48411,6 +48832,9 @@ var init_supervisor = __esm({
|
|
|
48411
48832
|
init_auth();
|
|
48412
48833
|
init_session_renewal();
|
|
48413
48834
|
init_claim();
|
|
48835
|
+
init_hermes();
|
|
48836
|
+
init_codex();
|
|
48837
|
+
init_antigravity();
|
|
48414
48838
|
init_options();
|
|
48415
48839
|
init_harness_signal();
|
|
48416
48840
|
init_process();
|
|
@@ -48423,6 +48847,13 @@ var init_supervisor = __esm({
|
|
|
48423
48847
|
opt = (name, fallback) => optIn(argv, name, fallback);
|
|
48424
48848
|
num2 = (name, spec) => numericIn(argv, name, spec);
|
|
48425
48849
|
ms = (name, fallbackSeconds) => secondsIn(argv, name, fallbackSeconds);
|
|
48850
|
+
HARNESS_KINDS = ["claude", "hermes", "codex", "antigravity"];
|
|
48851
|
+
DEFAULT_HARNESS_COMMAND = {
|
|
48852
|
+
claude: "claude",
|
|
48853
|
+
hermes: "hermes",
|
|
48854
|
+
codex: "codex",
|
|
48855
|
+
antigravity: "agy"
|
|
48856
|
+
};
|
|
48426
48857
|
readOptions = () => ({
|
|
48427
48858
|
/** Override the function to poll for. Defaults to this agent's own. */
|
|
48428
48859
|
function: opt("function"),
|
|
@@ -48441,8 +48872,19 @@ var init_supervisor = __esm({
|
|
|
48441
48872
|
intervalMs: ms("interval", 20),
|
|
48442
48873
|
once: flag("once"),
|
|
48443
48874
|
dryRun: flag("dry-run"),
|
|
48444
|
-
/**
|
|
48445
|
-
|
|
48875
|
+
/**
|
|
48876
|
+
* Which harness this is, which decides how it is called — CRA-78. `claude`
|
|
48877
|
+
* (Claude Code, the default, unchanged) or `hermes` (Hermes Agent, which runs
|
|
48878
|
+
* GPT, Gemini, Grok, Hermes and other models; see ./hermes.ts). Validated in
|
|
48879
|
+
* `main`, where an unknown kind is refused rather than guessed at.
|
|
48880
|
+
*/
|
|
48881
|
+
harnessKind: opt("harness-kind", "claude"),
|
|
48882
|
+
/** The harness command. Anything that takes a prompt and exits works. The
|
|
48883
|
+
* default follows the kind, so `--harness-kind codex` needs nothing else. */
|
|
48884
|
+
harness: opt("harness", DEFAULT_HARNESS_COMMAND[opt("harness-kind") ?? "claude"] ?? "claude"),
|
|
48885
|
+
/** The inference provider, for harnesses that take one — `hermes` requires it
|
|
48886
|
+
* (e.g. `openrouter`), since its per-run home has no default to fall back on. */
|
|
48887
|
+
provider: opt("provider"),
|
|
48446
48888
|
/**
|
|
48447
48889
|
* Tools the harness may use. Cruo-only by default: the harness gets the
|
|
48448
48890
|
* board and nothing else — no shell, no filesystem. That makes a PM agent
|
|
@@ -48628,6 +49070,13 @@ cruo-supervisor: ${error51.message}
|
|
|
48628
49070
|
// stands in for. Cheap to list, and wrong to discover the hard way.
|
|
48629
49071
|
"CRUO_SESSION"
|
|
48630
49072
|
];
|
|
49073
|
+
hermesPython = null;
|
|
49074
|
+
DB_RESET_DENY = [
|
|
49075
|
+
"Bash(npx supabase db reset:*)",
|
|
49076
|
+
"Bash(supabase db reset:*)",
|
|
49077
|
+
"Bash(pnpm exec supabase db reset:*)",
|
|
49078
|
+
"Bash(pnpm dlx supabase db reset:*)"
|
|
49079
|
+
];
|
|
48631
49080
|
HEAD_STORED = 200;
|
|
48632
49081
|
lastHolding = 0;
|
|
48633
49082
|
warnedAboutSpendTable = false;
|
|
@@ -48710,6 +49159,10 @@ Common options
|
|
|
48710
49159
|
--push publish the branch after a run that commits
|
|
48711
49160
|
--interval <seconds> how often to poll (default 20)
|
|
48712
49161
|
--usage record what each run costs (needs a Claude Code harness)
|
|
49162
|
+
--harness-kind <kind> which agent CLI works the card: claude (default), codex,
|
|
49163
|
+
antigravity or hermes \u2014 see cruo.space/docs#agents
|
|
49164
|
+
--model <name> the model it uses (hermes needs one; the others default)
|
|
49165
|
+
--provider <name> hermes only: where the model comes from, e.g. openrouter
|
|
48713
49166
|
|
|
48714
49167
|
Full list and what each one is for: https://cruo.space/docs#agents
|
|
48715
49168
|
|
package/package.json
CHANGED