Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 96 additions & 55 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ impl Agent {
}
}

/// Does this agent take the prompt on stdin? Most do; vibe and grok want it
/// as a command-line argument (the value of `-p`, appended by the runner).
pub fn reads_stdin(&self) -> bool {
!matches!(self, Agent::Vibe | Agent::Grok)
/// Does this agent take the prompt on stdin? It depends on the CLI that runs
/// the leg (grok borrows codex on OpenRouter). Vibe and grok's own CLI take
/// the prompt as `-p`'s value; every other harness reads stdin.
pub fn reads_stdin(&self, openrouter: bool) -> bool {
let runner = if openrouter { self.openrouter_runner() } else { *self };
!matches!(runner, Agent::Vibe | Agent::Grok)
}

/// The tool name in gg's registry. Antigravity is pulled straight from its
Expand All @@ -92,9 +94,10 @@ impl Agent {
Agent::Codex => "openai/gpt-5",
Agent::Qwen => "qwen/qwen3-coder",
Agent::Vibe => "mistralai/mistral-medium-3.1",
// Antigravity and Grok are native-only (no OpenRouter route), so
// this is never used for provenance; kept honest in case it leaks.
Agent::Antigravity | Agent::Grok => "native-only (no OpenRouter)",
// Antigravity is native-only, so this is never used for it.
Agent::Antigravity => "native-only (no OpenRouter)",
// Grok's OpenRouter leg drives its own model through codex's harness.
Agent::Grok => "x-ai/grok-build-0.1",
}
}

Expand Down Expand Up @@ -123,10 +126,11 @@ impl Agent {
}
}

/// Headless, read-only flags. The prompt itself is delivered on stdin:
/// it is large and multiline, and Windows .cmd shims reject
/// newline-containing arguments outright.
fn args(&self, openrouter: bool) -> Vec<&'static str> {
/// Headless, read-only flags for an agent run through its own CLI (i.e. not
/// codex, whose command is built in `codex_exec_command`). The prompt is
/// delivered on stdin where possible - it is large and multiline, and
/// Windows .cmd shims reject newline-containing arguments outright.
fn args(&self) -> Vec<&'static str> {
match self {
// -p: headless print mode. `dontAsk` keeps it read-only WITHOUT
// diverting the review into a plan. Plan mode delivers the model's
Expand All @@ -146,33 +150,11 @@ impl Agent {
"--allowedTools",
"Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(git rev-parse:*)",
],
// `-`: read the prompt from stdin. For OpenRouter, the `-c`
// overrides defining the provider must sit right after `exec`
// (codex's built-in openai provider can't be repointed by env
// alone for the Responses wire API).
Agent::Codex => {
// --ignore-user-config: run a clean one-shot. The user's
// config.toml (MCP servers, custom tools, extra headers) is
// irrelevant to a read-only review and can inject malformed
// tool schemas that upstream providers reject. Auth still
// resolves from CODEX_HOME.
let mut a = vec!["exec", "--ignore-user-config"];
if openrouter {
a.extend_from_slice(&CODEX_OPENROUTER_ARGS);
a.push("-m");
a.push(self.openrouter_model());
}
a.extend_from_slice(&[
"--sandbox",
"read-only",
// Run in any directory, not just a git repo; the sandbox
// already enforces read-only, so the git-trust gate is
// redundant here and just blocks non-repo cwds.
"--skip-git-repo-check",
"-",
]);
a
}
// Codex (and grok's OpenRouter leg, which borrows it) build their
// command in `codex_exec_command`; `command()` never routes codex
// through here. Panic loudly rather than silently launch codex's
// interactive TUI with no args if that ever changes.
Agent::Codex => unreachable!("codex builds its command in codex_exec_command"),
// -p -: single-prompt headless mode reading the prompt from stdin
// (the `-` operand), like the other stdin agents - so the large,
// multiline review prompt is never passed as a CLI argument (which
Expand Down Expand Up @@ -225,9 +207,19 @@ impl Agent {
/// key; otherwise it runs on the user's own login. The caller pipes the
/// prompt to stdin.
pub fn command(&self, repo: &Path, openrouter: bool) -> Command {
let mut cmd = self.base_command();
cmd.args(self.args(openrouter));
cmd.current_dir(repo);
// The CLI that runs this leg: normally self, but on the OpenRouter leg an
// agent may borrow another's harness (grok -> codex).
let runner = if openrouter { self.openrouter_runner() } else { *self };
let mut cmd = if runner == Agent::Codex {
// Codex's exec harness; the model is passed only on the OpenRouter
// leg (grok supplies its own model, codex supplies gpt-5).
codex_exec_command(repo, openrouter.then(|| self.openrouter_model()))
} else {
let mut cmd = runner.base_command();
cmd.args(self.args());
cmd.current_dir(repo);
cmd
};
if openrouter && let Some(key) = openrouter::key() {
for (name, value) in self.openrouter_env(key) {
cmd.env(name, value);
Expand All @@ -254,11 +246,13 @@ impl Agent {
}

/// A native-only agent is a closed-source CLI bound to its vendor's own
/// backend with no endpoint to repoint at OpenRouter (Antigravity -> Google,
/// Grok -> xAI). The single source of truth for "has no OpenRouter route";
/// every OpenRouter capability below derives from it.
/// backend with no way to reach OpenRouter at all - only Antigravity
/// (Google). Grok's own CLI also can't be repointed, but grok's *model*
/// (x-ai/grok-build-0.1) is on OpenRouter, so grok's OpenRouter leg borrows
/// codex's harness (see `command`) - hence grok is NOT native-only. The
/// single source of truth for "has no OpenRouter route".
pub fn is_native_only(&self) -> bool {
matches!(self, Agent::Antigravity | Agent::Grok)
matches!(self, Agent::Antigravity)
}

/// Can this agent reach OpenRouter in principle? Most can: Claude via the
Expand Down Expand Up @@ -288,14 +282,39 @@ impl Agent {
}
}

/// Can this agent reach OpenRouter *right now*? As `supports_openrouter`,
/// except Vibe additionally needs its scratch VIBE_HOME to be written
/// (main.rs prepares it before the fan-out).
fn openrouter_capable(&self) -> bool {
/// The agent whose CLI actually executes this agent's OpenRouter leg.
/// Normally itself; grok is the exception - its own CLI can't reach
/// OpenRouter, so grok's OpenRouter leg runs through codex's harness pointed
/// at grok's model (x-ai/grok-build-0.1). The single place that fact lives.
pub fn openrouter_runner(&self) -> Agent {
match self {
Agent::Vibe => vibe::home().is_some(),
_ => self.supports_openrouter(),
Agent::Grok => Agent::Codex,
other => *other,
}
}

/// Could this agent use OpenRouter in this environment? supports_openrouter()
/// plus, for an agent that borrows another's harness (grok -> codex), that
/// runner being installed/bootstrappable. Does NOT check per-run scratch
/// state (Vibe's VIBE_HOME), which is only ready after selection - so this is
/// the check for selection and planning. See openrouter_capable for run time.
pub fn openrouter_reachable(&self) -> bool {
if !self.supports_openrouter() {
return false;
}
let runner = self.openrouter_runner();
runner == *self || runner.via().is_some()
}

/// Can this agent reach OpenRouter *right now* (at attempt time)? As
/// openrouter_reachable, but Vibe also needs its scratch VIBE_HOME written
/// (main.rs prepares it before the fan-out).
fn openrouter_capable(&self) -> bool {
self.openrouter_reachable()
&& match self {
Agent::Vibe => vibe::home().is_some(),
_ => true,
}
}

/// Args that run this agent's interactive login, or None when it has none.
Expand Down Expand Up @@ -335,10 +354,11 @@ impl Agent {
("ANTHROPIC_MODEL", self.openrouter_model().into()),
("MAX_THINKING_TOKENS", "0".into()),
],
Agent::Codex => vec![("OPENROUTER_API_KEY", key.to_string())],
// Antigravity and Grok have no OpenRouter route (native-login only),
// so they are never run with `openrouter` set - no env to inject.
Agent::Antigravity | Agent::Grok => vec![],
// Grok's OpenRouter leg runs through codex, which reads the same key.
Agent::Codex | Agent::Grok => vec![("OPENROUTER_API_KEY", key.to_string())],
// Antigravity has no OpenRouter route, so it is never run with
// `openrouter` set - no env to inject.
Agent::Antigravity => vec![],
// Qwen Code speaks the OpenAI-compatible API directly, so it needs
// no bridge: point its OpenAI client at OpenRouter on the key.
Agent::Qwen => vec![
Expand Down Expand Up @@ -477,6 +497,27 @@ impl Agent {
}
}

/// Codex's read-only `exec` harness. `or_model = Some(model)` points it at
/// OpenRouter on that model (the `-c` provider overrides go right after `exec`,
/// which codex's built-in openai provider needs for the Responses wire API);
/// None runs codex on its own login. Grok's OpenRouter leg reuses this - the
/// grok CLI can't reach OpenRouter, but codex can drive grok's model there.
/// --ignore-user-config runs a clean one-shot: the user's config.toml (MCP
/// servers, custom tools) is irrelevant to a read-only review and can inject
/// malformed tool schemas that upstream providers reject. --skip-git-repo-check
/// lets it run outside a git repo; the sandbox already enforces read-only.
fn codex_exec_command(repo: &Path, or_model: Option<&str>) -> Command {
let mut cmd = Agent::Codex.base_command();
cmd.arg("exec").arg("--ignore-user-config");
if let Some(model) = or_model {
cmd.args(CODEX_OPENROUTER_ARGS);
cmd.arg("-m").arg(model);
}
cmd.args(["--sandbox", "read-only", "--skip-git-repo-check", "-"]);
cmd.current_dir(repo);
cmd
}

/// Claude Code on macOS stores OAuth credentials in the Keychain, not in
/// ~/.claude. Querying item metadata (no -w) never prints the secret and
/// does not prompt.
Expand Down
61 changes: 41 additions & 20 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,10 @@ fn run(args: RunArgs) -> Result<()> {
if !args.no_update
&& let Some(gg) = gg::locate()
{
// Scoped: update only the agents this run uses, in parallel - not the
// user's whole gg toolchain, and never postmortemthis itself.
let tools: Vec<&str> = selected
.iter()
.filter(|a| a.via() == Some(Via::Gg))
.map(|a| a.gg_tool())
.collect();
// Scoped: update only the agents this run uses (plus any borrowed
// OpenRouter runner, e.g. codex for grok), in parallel - not the user's
// whole gg toolchain, and never postmortemthis itself.
let tools = gg_tools_for(&selected);
if !tools.is_empty() {
eprintln!("postmortemthis: updating {} ...", tools.join(", "));
let children: Vec<_> = tools
Expand Down Expand Up @@ -292,7 +289,7 @@ fn run_notes(reports: &[Report], selected: &[Agent], settings: &settings::Settin
// Agents that exist but were not run for lack of usable credentials. An
// OpenRouter-capable agent is runnable whenever a key is present (whether or
// not it was requested), so flag it only when there is no key. A native-only
// agent (antigravity, grok) can't use the key at all, so flag it whenever it
// agent (antigravity) can't use the key at all, so flag it whenever it
// lacks its own login - even with a key set - and point it at that login.
let has_key = openrouter::key().is_some();
for agent in agents::ALL {
Expand Down Expand Up @@ -399,11 +396,14 @@ fn plan_run(
// is actually reachable (a key is present and the agent supports it).
// Otherwise leave the native login in play - forcing a route that can't run
// would turn a working agent into a guaranteed empty-plan failure every run.
let or_reachable = openrouter::key().is_some();
let has_key = openrouter::key().is_some();
for &agent in &selected {
// openrouter_reachable (not supports_openrouter) so a borrowed-harness
// agent (grok -> codex) isn't forced onto OpenRouter when its runner is
// missing - that would drop the working native leg for an empty plan.
if settings.mode(agent) == settings::Mode::Openrouter
&& or_reachable
&& agent.supports_openrouter()
&& has_key
&& agent.openrouter_reachable()
&& !skip.contains(&agent)
{
skip.push(agent);
Expand Down Expand Up @@ -463,20 +463,20 @@ fn select_agents(requested: &[String], settings: &settings::Settings) -> Result<
// Auto-pick an available agent (native on PATH or gg-bootstrappable)
// only if it can actually run: explicitly requested, its own login, or
// an OpenRouter key it can actually use. The key check is gated on
// supports_openrouter() so a native-only agent (antigravity, grok) is
// supports_openrouter() so a native-only agent (antigravity) is
// not auto-selected on key-presence alone only to fail with an empty
// attempt plan. Native and gg share this gate - otherwise an
// installed-but-logged-out native-only agent (grok) would be selected
// installed-but-logged-out native-only agent would be selected
// unconditionally and fail on every default run. --agents overrides.
Some(_)
if explicit
|| agent.authed()
|| (openrouter::key().is_some() && agent.supports_openrouter()) =>
|| (openrouter::key().is_some() && agent.openrouter_reachable()) =>
{
selected.push(agent)
}
Some(_) => {
// Native-only agents (antigravity, grok) have no OpenRouter
// Native-only agents (antigravity) have no OpenRouter
// route, so --key can't help them - don't suggest it.
let how = if agent.supports_openrouter() {
"log in once, or pass --key"
Expand All @@ -501,14 +501,35 @@ fn select_agents(requested: &[String], settings: &settings::Settings) -> Result<
Ok(selected)
}

/// One chained gg invocation prepares every needed tool in parallel before the
/// fan-out, so the per-agent timeout is spent running, not bootstrapping.
fn prewarm(selected: &[Agent], dir: &std::path::Path) {
let tools: Vec<&str> = selected
/// The gg tool names needed to run `selected`: each agent's own tool plus, for
/// an agent that borrows another's OpenRouter harness (grok -> codex), that
/// runner's tool - so the borrowed CLI is prewarmed too, not downloaded inside
/// the per-agent timeout on fallback. Deduped, gg-bootstrappable agents only.
fn gg_tools_for(selected: &[Agent]) -> Vec<&'static str> {
// Only prewarm a borrowed OpenRouter runner (codex for grok) when a key makes
// that leg reachable - otherwise it's a wasted download for a keyless run.
let borrow_runners = openrouter::key().is_some();
let mut tools: Vec<&str> = selected
.iter()
.filter(|a| a.via() == Some(Via::Gg))
.map(|a| a.gg_tool())
.flat_map(|a| {
let mut v = vec![a.gg_tool()];
let runner = a.openrouter_runner();
if borrow_runners && runner != *a {
v.push(runner.gg_tool());
}
v
})
.collect();
tools.sort_unstable();
tools.dedup();
tools
}

/// One chained gg invocation prepares every needed tool in parallel before the
/// fan-out, so the per-agent timeout is spent running, not bootstrapping.
fn prewarm(selected: &[Agent], dir: &std::path::Path) {
let tools = gg_tools_for(selected);
let Some(gg) = gg::locate() else { return };
if tools.is_empty() {
return;
Expand Down
8 changes: 4 additions & 4 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ fn run_one(agent: Agent, prompt: &str, repo: &Path, timeout: Duration, skip_nati
let started = Instant::now();
let plan = agent.attempt_plan(skip_native);
if plan.is_empty() {
// A native-only agent (antigravity, grok) has no OpenRouter fallback, so
// A native-only agent (antigravity) has no OpenRouter fallback, so
// the failure is purely "not logged in" - don't mention a key it can't use.
let why = if agent.supports_openrouter() {
"no usable login and no OpenRouter key"
Expand Down Expand Up @@ -151,9 +151,9 @@ fn run_attempt(
) -> Report {
let started = Instant::now();
let mut cmd = agent.command(repo, openrouter);
// Most agents read the prompt on stdin; the rest (vibe, grok) take it as a
// trailing argument and get no stdin.
let on_stdin = agent.reads_stdin();
// Most read the prompt on stdin; vibe (always) and grok's native leg take it
// as a trailing argument and get no stdin.
let on_stdin = agent.reads_stdin(openrouter);
if !on_stdin {
cmd.arg(prompt);
}
Expand Down
Loading