cruo-agent 0.1.19 → 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 +438 -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
|
@@ -46078,6 +46078,7 @@ var init_options = __esm({
|
|
|
46078
46078
|
"cwd",
|
|
46079
46079
|
"function",
|
|
46080
46080
|
"harness",
|
|
46081
|
+
"harness-kind",
|
|
46081
46082
|
"harness-timeout",
|
|
46082
46083
|
"interval",
|
|
46083
46084
|
"limit",
|
|
@@ -46087,6 +46088,7 @@ var init_options = __esm({
|
|
|
46087
46088
|
"model",
|
|
46088
46089
|
"prepare",
|
|
46089
46090
|
"prepare-timeout",
|
|
46091
|
+
"provider",
|
|
46090
46092
|
"project",
|
|
46091
46093
|
"push-remote",
|
|
46092
46094
|
"repo",
|
|
@@ -46703,6 +46705,223 @@ var init_claim = __esm({
|
|
|
46703
46705
|
}
|
|
46704
46706
|
});
|
|
46705
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
|
+
|
|
46706
46925
|
// src/harness-signal.ts
|
|
46707
46926
|
function refusalIn(stdout) {
|
|
46708
46927
|
if (!stdout.trim()) return null;
|
|
@@ -46855,7 +47074,19 @@ var init_harness_signal = __esm({
|
|
|
46855
47074
|
// ("the endpoint is rate limited"), and the note above is explicit that a
|
|
46856
47075
|
// false positive is the worse error: it credits an issue an attempt it
|
|
46857
47076
|
// should have been charged.
|
|
46858
|
-
[/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"]
|
|
46859
47090
|
];
|
|
46860
47091
|
num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
46861
47092
|
}
|
|
@@ -47144,6 +47375,7 @@ var supervisor_exports = {};
|
|
|
47144
47375
|
import { spawn } from "node:child_process";
|
|
47145
47376
|
import { createRequire } from "node:module";
|
|
47146
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";
|
|
47147
47379
|
import { homedir as homedir2, tmpdir } from "node:os";
|
|
47148
47380
|
import { join as join3 } from "node:path";
|
|
47149
47381
|
import { fileURLToPath } from "node:url";
|
|
@@ -47535,6 +47767,138 @@ async function preflight() {
|
|
|
47535
47767
|
await cleanup();
|
|
47536
47768
|
}
|
|
47537
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
|
+
}
|
|
47538
47902
|
function harnessEnv() {
|
|
47539
47903
|
const env = { ...process.env };
|
|
47540
47904
|
for (const name of CRUO_OWNED_ENV) delete env[name];
|
|
@@ -47642,7 +48006,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47642
48006
|
const cwd = worktree?.path ?? options.cwd;
|
|
47643
48007
|
const { path: mcpConfig, cleanup, keepSessionFresh } = await writeMcpConfig();
|
|
47644
48008
|
const stopSessionRefresh = keepSessionFresh();
|
|
47645
|
-
const streaming = options.usage === true;
|
|
48009
|
+
const streaming = options.usage === true && options.harnessKind === "claude";
|
|
47646
48010
|
const builtinTools = [
|
|
47647
48011
|
...new Set(
|
|
47648
48012
|
options.allow.split(",").map((t) => t.trim()).filter((t) => t !== "" && !t.startsWith("mcp__")).map((t) => t.replace(/\(.*$/, ""))
|
|
@@ -47650,7 +48014,8 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47650
48014
|
].join(",");
|
|
47651
48015
|
const userPromptText = userPrompt(hit);
|
|
47652
48016
|
const systemPromptText = systemPrompt(ctx, identity, worktree);
|
|
47653
|
-
const
|
|
48017
|
+
const alt = await prepareAltHarness({ mcpConfig, cwd, worktree, hit, systemPromptText, userPromptText });
|
|
48018
|
+
const args = alt ? alt.args : [
|
|
47654
48019
|
"-p",
|
|
47655
48020
|
userPromptText,
|
|
47656
48021
|
"--mcp-config",
|
|
@@ -47685,7 +48050,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47685
48050
|
];
|
|
47686
48051
|
try {
|
|
47687
48052
|
return await new Promise((resolve2) => {
|
|
47688
|
-
const child = spawn(options.harness, args, {
|
|
48053
|
+
const child = spawn(alt?.command ?? options.harness, args, {
|
|
47689
48054
|
// `pipe`, not `inherit`, so the streams can be counted on the way past.
|
|
47690
48055
|
// Watching a live run is the whole point of a foreground supervisor, so
|
|
47691
48056
|
// both are written straight through — this tees, it does not capture.
|
|
@@ -47693,7 +48058,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47693
48058
|
cwd,
|
|
47694
48059
|
// CRA-131: marks everything the harness runs as inside an agent run,
|
|
47695
48060
|
// which is what makes `pnpm supabase:reset` refuse with no override.
|
|
47696
|
-
env: { ...harnessEnv(), CRUO_AGENT_RUN: hit.ref }
|
|
48061
|
+
env: { ...harnessEnv(), CRUO_AGENT_RUN: hit.ref, ...alt?.env ?? {} }
|
|
47697
48062
|
});
|
|
47698
48063
|
const startedAtMs = Date.now();
|
|
47699
48064
|
let stdoutBytes = 0;
|
|
@@ -47793,6 +48158,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
|
|
|
47793
48158
|
} finally {
|
|
47794
48159
|
stopSessionRefresh();
|
|
47795
48160
|
await cleanup();
|
|
48161
|
+
if (alt) await alt.cleanup();
|
|
47796
48162
|
}
|
|
47797
48163
|
}
|
|
47798
48164
|
async function loadAttempts(ctx, hits) {
|
|
@@ -48098,7 +48464,9 @@ async function tick(ctx, identity, deadTicks, allowance) {
|
|
|
48098
48464
|
log(`\u2192 ${hit.ref} "${hit.issue.title}" (${why})`);
|
|
48099
48465
|
worked += 1;
|
|
48100
48466
|
if (options.dryRun) {
|
|
48101
|
-
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
|
+
);
|
|
48102
48470
|
continue;
|
|
48103
48471
|
}
|
|
48104
48472
|
invoked += 1;
|
|
@@ -48278,6 +48646,41 @@ async function main() {
|
|
|
48278
48646
|
if (sessionEndpoint()) {
|
|
48279
48647
|
log(` sessions from ${sessionEndpoint()} \u2014 no service-role key needed here`);
|
|
48280
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
|
+
}
|
|
48281
48684
|
const staleConfigs = await sweepStaleConfigs();
|
|
48282
48685
|
if (staleConfigs > 0) {
|
|
48283
48686
|
log(` cleared ${staleConfigs} temp config(s) left by a killed run`);
|
|
@@ -48418,7 +48821,7 @@ to catch: a second supervisor nothing could see or stop.
|
|
|
48418
48821
|
if (stopping) return;
|
|
48419
48822
|
}
|
|
48420
48823
|
}
|
|
48421
|
-
var argv, flag, opt, num2, ms, readOptions, options, log, sessionRenewal, worktreeConfig, lastRefusal, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, DB_RESET_DENY, 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;
|
|
48422
48825
|
var init_supervisor = __esm({
|
|
48423
48826
|
"src/supervisor.ts"() {
|
|
48424
48827
|
"use strict";
|
|
@@ -48429,6 +48832,9 @@ var init_supervisor = __esm({
|
|
|
48429
48832
|
init_auth();
|
|
48430
48833
|
init_session_renewal();
|
|
48431
48834
|
init_claim();
|
|
48835
|
+
init_hermes();
|
|
48836
|
+
init_codex();
|
|
48837
|
+
init_antigravity();
|
|
48432
48838
|
init_options();
|
|
48433
48839
|
init_harness_signal();
|
|
48434
48840
|
init_process();
|
|
@@ -48441,6 +48847,13 @@ var init_supervisor = __esm({
|
|
|
48441
48847
|
opt = (name, fallback) => optIn(argv, name, fallback);
|
|
48442
48848
|
num2 = (name, spec) => numericIn(argv, name, spec);
|
|
48443
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
|
+
};
|
|
48444
48857
|
readOptions = () => ({
|
|
48445
48858
|
/** Override the function to poll for. Defaults to this agent's own. */
|
|
48446
48859
|
function: opt("function"),
|
|
@@ -48459,8 +48872,19 @@ var init_supervisor = __esm({
|
|
|
48459
48872
|
intervalMs: ms("interval", 20),
|
|
48460
48873
|
once: flag("once"),
|
|
48461
48874
|
dryRun: flag("dry-run"),
|
|
48462
|
-
/**
|
|
48463
|
-
|
|
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"),
|
|
48464
48888
|
/**
|
|
48465
48889
|
* Tools the harness may use. Cruo-only by default: the harness gets the
|
|
48466
48890
|
* board and nothing else — no shell, no filesystem. That makes a PM agent
|
|
@@ -48646,6 +49070,7 @@ cruo-supervisor: ${error51.message}
|
|
|
48646
49070
|
// stands in for. Cheap to list, and wrong to discover the hard way.
|
|
48647
49071
|
"CRUO_SESSION"
|
|
48648
49072
|
];
|
|
49073
|
+
hermesPython = null;
|
|
48649
49074
|
DB_RESET_DENY = [
|
|
48650
49075
|
"Bash(npx supabase db reset:*)",
|
|
48651
49076
|
"Bash(supabase db reset:*)",
|
|
@@ -48734,6 +49159,10 @@ Common options
|
|
|
48734
49159
|
--push publish the branch after a run that commits
|
|
48735
49160
|
--interval <seconds> how often to poll (default 20)
|
|
48736
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
|
|
48737
49166
|
|
|
48738
49167
|
Full list and what each one is for: https://cruo.space/docs#agents
|
|
48739
49168
|
|
package/package.json
CHANGED