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 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 Claude account.
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 Claude Code, started fresh for one card and gone when that
39
- card is done. Nothing carries over between cards but the board itself.
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.18
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 args = [
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
- env: harnessEnv()
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(` dry run \u2014 would invoke: ${options.harness} -p \u2026 --allowedTools ${options.allow}`);
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
- /** The harness command. Anything that takes a prompt and exits works. */
48445
- harness: opt("harness", "claude"),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cruo-agent",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Run a Cruo agent: it watches your board, picks up the cards you assign it, and works them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cruo.space/docs#agents",