diff --git a/Cargo.lock b/Cargo.lock index 9d0190868d..362a64b91f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "buzz-sdk", "chrono", "clap", + "dirs", "evalexpr", "futures-util", "hex", diff --git a/README.md b/README.md index 6aa3eeff0b..b7fe6d73a6 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Agents are part of the room, not haunted cron jobs. |---|---|---| | Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays | | Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications | -| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features | +| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code, Antigravity) | Huddle lifecycle events | Culture features | | YAML workflows: message / reaction / schedule / webhook triggers | | | | Git events (NIP-34: patches, repo announcements, status) | | | | Git hosting backend | | | @@ -204,7 +204,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH **Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace. -**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) +**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code plus an Antigravity compatibility bridge) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) **Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing) diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..474cf16f9b 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -25,7 +25,7 @@ buzz-persona = { path = "../buzz-persona" } nostr = { workspace = true } # Async runtime -tokio = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-std"] } # WebSocket tokio-tungstenite = { workspace = true } @@ -45,6 +45,7 @@ serde_json = { workspace = true } # IDs uuid = { workspace = true } chrono = { workspace = true } +dirs = "6" # URL parsing url = { workspace = true } diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d9cd362cb8..ad02e55f20 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -9,7 +9,13 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +Supports agents that speak [ACP](https://agentclientprotocol.com/) over stdio: +**goose**, **codex** (via +[codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude +code** (via +[claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +It also includes a compatibility bridge for **Google Antigravity CLI (`agy`)** +while native protocol support is pending upstream. ## Prerequisites @@ -89,6 +95,68 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with Google Antigravity CLI + +Install and authenticate the official CLI first: + +```bash +curl -fsSL https://antigravity.google/cli/install.sh | bash +agy # complete Google sign-in, then exit +``` + +Buzz Desktop discovers `agy` automatically and uses the bundled compatibility +bridge. For a standalone harness: + +```bash +export BUZZ_ACP_AGENT_COMMAND="buzz-acp" +export BUZZ_ACP_AGENT_ARGS="agy-acp" + +buzz-acp +``` + +The bridge runs each turn through AGY's non-interactive `--print` mode in the +ACP session workspace. The child inherits `BUZZ_PRIVATE_KEY`, relay variables, +and the rest of the managed agent environment, so AGY can use the installed +Buzz CLI skill as the same Buzz identity. + +Buzz-owned nests (`~/.buzz` and `~/.buzz-dev`) include documented Antigravity +`PreToolUse`, `PostToolUse`, and `Stop` hooks. The bridge registers the ACP +workspace with `agy --add-dir`, which makes print mode load its `.agents` +customizations, and maps hook events to ACP tool updates, including file-edit +arguments and completion/error status. Hook metadata retains AGY's documented +`transcriptPath` and `artifactDirectoryPath`; Buzz deliberately does not parse +the currently undocumented transcript JSONL record format. + +As a fail-closed fallback if a future or locally customized AGY build does not +deliver the `Stop` hook, the bridge detects the single new documented +`brain//.system_generated/logs/transcript.jsonl` path +created by the turn. It uses only the UUID-bearing directory name; if zero or +multiple trajectories appear, it refuses to guess. + +Until Antigravity exposes a native agent protocol: + +- The first prompt in an ACP session starts a new AGY trajectory. The `Stop` + hook supplies its conversation UUID, and later prompts resume it with + `agy --conversation`. Rotating the Buzz session starts a fresh trajectory. +- Buzz still supplies the current channel/thread context on every turn, so the + relay remains the canonical conversation record. +- AGY stdout becomes the agent message stream; hooks provide tool and patch + observability. +- ACP cancellation terminates the active AGY process. +- The bridge uses `--dangerously-skip-permissions`, matching the managed + harness's unattended auto-approval behavior. Run managed agents only in + workspaces and environments you trust. + +Optional bridge settings: + +| Variable | Default | Description | +|----------|---------|-------------| +| `BUZZ_AGY_COMMAND` | `agy` | Antigravity executable or absolute path. | +| `BUZZ_AGY_MODEL` | AGY default | Value passed to `agy --model`. | +| `BUZZ_AGY_EFFORT` | AGY default | `low`, `medium`, or `high`, passed to `agy --effort`. | +| `BUZZ_AGY_PRINT_TIMEOUT` | `2h` | Value passed to `agy --print-timeout`. | +| `BUZZ_AGY_APP_DATA_DIR` | `~/.gemini/antigravity-cli` | Alternate AGY app-data root used only for trajectory discovery. | + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/crates/buzz-acp/src/agy_adapter.rs b/crates/buzz-acp/src/agy_adapter.rs new file mode 100644 index 0000000000..726221e36d --- /dev/null +++ b/crates/buzz-acp/src/agy_adapter.rs @@ -0,0 +1,1215 @@ +//! ACP compatibility bridge for the Antigravity CLI. +//! +//! Antigravity does not currently expose a native ACP transport. This module +//! presents the small ACP surface that `buzz-acp` needs and executes each turn +//! through AGY's official non-interactive `--print` mode. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use futures_util::{Stream, StreamExt}; +use serde_json::{json, Value}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufWriter}; +use tokio::process::{Child, Command}; +use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; +use uuid::Uuid; + +const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +const MAX_HOOK_INPUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PROCESS_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const DEFAULT_PRINT_TIMEOUT: &str = "2h"; +const HOOK_POLL_INTERVAL: Duration = Duration::from_millis(50); +const MODEL_LIST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone)] +struct Session { + cwd: PathBuf, + system_prompt: Option, + conversation_id: Option, +} + +struct AgyOutput { + status: ExitStatus, + stdout: String, + stderr: String, +} + +enum TurnResult { + Completed(AgyOutput), + Cancelled, + InputClosed, +} + +/// Receive one documented Antigravity hook payload. +/// +/// Hook commands must always emit valid JSON on stdout. Logging is therefore +/// intentionally best-effort: a missing or malformed observer directory must +/// never break an AGY tool call. +pub(crate) async fn run_hook() -> Result<()> { + let event = std::env::args() + .nth(2) + .unwrap_or_else(|| "unknown".to_string()); + let mut input = Vec::new(); + tokio::io::stdin() + .take((MAX_HOOK_INPUT_BYTES + 1) as u64) + .read_to_end(&mut input) + .await + .context("failed reading Antigravity hook input")?; + + let hook_dir = non_empty_env("BUZZ_AGY_HOOK_DIR").map(PathBuf::from); + if input.len() <= MAX_HOOK_INPUT_BYTES { + if let (Some(hook_dir), Ok(payload)) = + (hook_dir.as_deref(), serde_json::from_slice::(&input)) + { + let _ = persist_hook_event(hook_dir, &event, payload).await; + } + } + + let response = hook_response(&event, hook_dir.is_some()); + let mut stdout = BufWriter::new(tokio::io::stdout()); + send_hook_json(&mut stdout, &response).await +} + +fn hook_response(event: &str, observer_attached: bool) -> Value { + match event { + "pre-tool-use" if observer_attached => json!({ "decision": "allow" }), + "pre-tool-use" => json!({ + "decision": "ask", + "reason": "Buzz hook observer is not attached to a managed AGY turn" + }), + // Antigravity's Stop hook contract requires a decision. Any value + // other than "continue" allows the completed execution to stop. + "stop" => json!({ "decision": "stop" }), + _ => json!({}), + } +} + +/// Run the Antigravity ACP compatibility bridge over stdin/stdout. +pub(crate) async fn run() -> Result<()> { + let stdin = tokio::io::stdin(); + let mut frames = FramedRead::new(stdin, LinesCodec::new_with_max_length(MAX_FRAME_BYTES)); + let mut stdout = BufWriter::new(tokio::io::stdout()); + let mut sessions = HashMap::::new(); + + while let Some(frame) = frames.next().await { + let frame = match frame { + Ok(frame) => frame, + Err(error) => { + send_error( + &mut stdout, + Value::Null, + -32700, + &format!("invalid JSON-RPC frame: {error}"), + ) + .await?; + continue; + } + }; + let request = match serde_json::from_str::(&frame) { + Ok(request) => request, + Err(error) => { + send_error( + &mut stdout, + Value::Null, + -32700, + &format!("invalid JSON: {error}"), + ) + .await?; + continue; + } + }; + + let Some(method) = request.get("method").and_then(Value::as_str) else { + if request.get("id").is_some() { + send_error( + &mut stdout, + request.get("id").cloned().unwrap_or(Value::Null), + -32600, + "JSON-RPC request is missing method", + ) + .await?; + } + continue; + }; + let id = request.get("id").cloned(); + let params = request.get("params").cloned().unwrap_or(Value::Null); + + match (method, id) { + ("initialize", Some(id)) => { + let requested_version = params + .get("protocolVersion") + .and_then(Value::as_u64) + .unwrap_or(1); + send_result( + &mut stdout, + id, + json!({ + "protocolVersion": requested_version.min(2), + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { + "image": false, + "audio": false, + "embeddedContext": false + }, + "mcpCapabilities": { + "http": false, + "sse": false + } + }, + "agentInfo": { + "name": "Antigravity", + "version": env!("CARGO_PKG_VERSION") + } + }), + ) + .await?; + } + ("session/new", Some(id)) => { + let Some(cwd) = params.get("cwd").and_then(Value::as_str) else { + send_error( + &mut stdout, + id, + -32602, + "session/new requires an absolute cwd", + ) + .await?; + continue; + }; + let cwd = PathBuf::from(cwd); + if !cwd.is_absolute() { + send_error( + &mut stdout, + id, + -32602, + "session/new requires an absolute cwd", + ) + .await?; + continue; + } + + let session_id = Uuid::new_v4().to_string(); + sessions.insert( + session_id.clone(), + Session { + cwd, + system_prompt: params + .get("systemPrompt") + .and_then(Value::as_str) + .map(str::to_owned), + conversation_id: None, + }, + ); + send_result(&mut stdout, id, json!({ "sessionId": session_id })).await?; + } + ("session/prompt", Some(id)) => { + let Some(session_id) = params.get("sessionId").and_then(Value::as_str) else { + send_error(&mut stdout, id, -32602, "session/prompt requires sessionId") + .await?; + continue; + }; + let Some(mut session) = sessions.get(session_id).cloned() else { + send_error(&mut stdout, id, -32602, "unknown sessionId").await?; + continue; + }; + let prompt = match render_prompt(&session, ¶ms) { + Ok(prompt) => prompt, + Err(error) => { + send_error(&mut stdout, id, -32602, &error.to_string()).await?; + continue; + } + }; + + let turn_result = + run_turn(&mut frames, &mut stdout, session_id, &mut session, prompt).await?; + sessions.insert(session_id.to_string(), session); + + match turn_result { + TurnResult::Completed(output) if output.status.success() => { + if !output.stdout.is_empty() { + send_json( + &mut stdout, + &json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": output.stdout + } + } + } + }), + ) + .await?; + } + send_result(&mut stdout, id, json!({ "stopReason": "end_turn" })).await?; + } + TurnResult::Completed(output) => { + let message = process_failure_message(&output); + send_error(&mut stdout, id, -32000, &message).await?; + } + TurnResult::Cancelled => { + send_result(&mut stdout, id, json!({ "stopReason": "cancelled" })).await?; + } + TurnResult::InputClosed => return Ok(()), + } + } + ("session/cancel", _) => { + // There is no active process while handling requests in this loop. + } + (_, Some(id)) => { + send_error(&mut stdout, id, -32601, "method not found").await?; + } + (_, None) => {} + } + } + + Ok(()) +} + +fn render_prompt(session: &Session, params: &Value) -> Result { + let blocks = params + .get("prompt") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("session/prompt requires a prompt array"))?; + let user_prompt = blocks + .iter() + .filter_map(|block| { + (block.get("type").and_then(Value::as_str) == Some("text")) + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + .collect::>() + .join("\n\n"); + if user_prompt.trim().is_empty() { + return Err(anyhow!("session/prompt requires at least one text block")); + } + + match session.system_prompt.as_deref().map(str::trim) { + Some(system_prompt) if !system_prompt.is_empty() => Ok(format!( + "System instructions:\n{system_prompt}\n\nBuzz conversation and request:\n{user_prompt}" + )), + _ => Ok(user_prompt), + } +} + +fn agy_args(conversation_id: Option<&str>) -> Vec { + let mut args = vec![ + "--dangerously-skip-permissions".to_string(), + "--print-timeout".to_string(), + std::env::var("BUZZ_AGY_PRINT_TIMEOUT") + .unwrap_or_else(|_| DEFAULT_PRINT_TIMEOUT.to_string()), + ]; + if let Some(model) = non_empty_env("BUZZ_AGY_MODEL") { + args.extend(["--model".to_string(), model]); + } + if let Some(effort) = non_empty_env("BUZZ_AGY_EFFORT") { + args.extend(["--effort".to_string(), effort]); + } + if let Some(conversation_id) = conversation_id { + args.extend(["--conversation".to_string(), conversation_id.to_string()]); + } + args +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Whether a lightweight `buzz-acp` helper is targeting the bundled AGY bridge. +/// +/// Model discovery receives the resolved executable path from Desktop, so this +/// must recognize both a bare `buzz-acp` command and an absolute sidecar path. +pub(crate) fn is_bridge_invocation(command: &str, args: &[String]) -> bool { + let executable = command.rsplit(['/', '\\']).next().unwrap_or(command); + let executable = executable.to_ascii_lowercase(); + let executable = executable.strip_suffix(".exe").unwrap_or(&executable); + + executable == "buzz-acp" + && args + .first() + .is_some_and(|arg| arg.eq_ignore_ascii_case("agy-acp")) +} + +/// Query AGY's native model catalog. +/// +/// The compatibility bridge cannot advertise ACP live model switching because +/// AGY applies `--model` when each print process starts. Its native `models` +/// command is nevertheless authoritative for configuration-time selection. +pub(crate) async fn discover_models() -> Result> { + let command = non_empty_env("BUZZ_AGY_COMMAND").unwrap_or_else(|| "agy".to_string()); + let mut command_builder = Command::new(&command); + command_builder + .arg("models") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(path) = path_with_current_executable() { + command_builder.env("PATH", path); + } + + let mut child = command_builder + .spawn() + .with_context(|| format!("failed to start Antigravity CLI `{command} models`"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Antigravity model-list stdout pipe was unavailable"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("Antigravity model-list stderr pipe was unavailable"))?; + let stdout_task = tokio::spawn(read_process_output(stdout)); + let stderr_task = tokio::spawn(read_process_output(stderr)); + + let status = match tokio::time::timeout(MODEL_LIST_TIMEOUT, child.wait()).await { + Ok(status) => status.context("failed waiting for Antigravity model list")?, + Err(_) => { + stop_child(&mut child).await; + let _ = finish_output_tasks(stdout_task, stderr_task).await; + return Err(anyhow!( + "Antigravity model discovery timed out after {MODEL_LIST_TIMEOUT:?}" + )); + } + }; + let (stdout, stderr) = finish_output_tasks(stdout_task, stderr_task).await?; + if !status.success() { + return Err(anyhow!(process_failure_message(&AgyOutput { + status, + stdout, + stderr, + }))); + } + + let models = parse_model_list(&stdout); + if models.is_empty() { + return Err(anyhow!("Antigravity CLI returned no models")); + } + Ok(models) +} + +fn parse_model_list(output: &str) -> Vec { + let mut seen = HashSet::new(); + output + .lines() + .map(str::trim) + .filter(|model| !model.is_empty()) + .filter(|model| seen.insert((*model).to_string())) + .map(str::to_string) + .collect() +} + +async fn persist_hook_event(hook_dir: &Path, event: &str, payload: Value) -> Result<()> { + let metadata = tokio::fs::metadata(hook_dir) + .await + .with_context(|| format!("hook directory {} is unavailable", hook_dir.display()))?; + if !metadata.is_dir() { + return Err(anyhow!( + "hook event target {} is not a directory", + hook_dir.display() + )); + } + + let event_id = Uuid::new_v4(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let filename = format!("{timestamp:020}-{event_id}"); + let pending_path = hook_dir.join(format!(".{filename}.tmp")); + let completed_path = hook_dir.join(format!("{filename}.json")); + let encoded = serde_json::to_vec(&json!({ + "event": event, + "payload": payload, + })) + .context("failed serializing Antigravity hook event")?; + tokio::fs::write(&pending_path, encoded) + .await + .with_context(|| format!("failed writing {}", pending_path.display()))?; + tokio::fs::rename(&pending_path, &completed_path) + .await + .with_context(|| format!("failed publishing hook event {}", completed_path.display())) +} + +async fn run_turn( + frames: &mut S, + writer: &mut W, + session_id: &str, + session: &mut Session, + prompt: String, +) -> Result +where + S: Stream> + Unpin, + W: AsyncWrite + Unpin, +{ + let command = non_empty_env("BUZZ_AGY_COMMAND").unwrap_or_else(|| "agy".to_string()); + let trajectories_before = snapshot_trajectory_ids().await; + let hook_dir = std::env::temp_dir().join(format!("buzz-agy-hooks-{}", Uuid::new_v4())); + tokio::fs::create_dir(&hook_dir) + .await + .with_context(|| format!("failed creating hook directory {}", hook_dir.display()))?; + + let mut command_builder = Command::new(&command); + command_builder + .args(agy_args(session.conversation_id.as_deref())) + // AGY print mode does not infer workspace customizations from the + // process cwd alone. Register the ACP workspace explicitly so + // `.agents/hooks.json`, skills, and rules are loaded. + .arg("--add-dir") + .arg(&session.cwd) + .arg("--print") + .arg(prompt) + .current_dir(&session.cwd) + .env("BUZZ_AGY_HOOK_DIR", &hook_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(path) = path_with_current_executable() { + command_builder.env("PATH", path); + } + let mut child = command_builder + .spawn() + .with_context(|| format!("failed to start Antigravity CLI `{command}`"))?; + + let child_stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Antigravity stdout pipe was unavailable"))?; + let child_stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("Antigravity stderr pipe was unavailable"))?; + let stdout_task = tokio::spawn(read_process_output(child_stdout)); + let stderr_task = tokio::spawn(read_process_output(child_stderr)); + let mut hook_interval = tokio::time::interval(HOOK_POLL_INTERVAL); + hook_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut seen_hook_events = HashSet::new(); + let mut active_tool_calls = HashSet::new(); + + let status = loop { + tokio::select! { + status = child.wait() => break status.context("failed waiting for Antigravity CLI")?, + _ = hook_interval.tick() => { + emit_hook_events( + writer, + session_id, + &hook_dir, + &mut seen_hook_events, + &mut active_tool_calls, + &mut session.conversation_id, + ) + .await?; + } + frame = frames.next() => { + match frame { + Some(Ok(frame)) if is_cancel_for_session(&frame, session_id) => { + stop_child(&mut child).await; + emit_hook_events( + writer, + session_id, + &hook_dir, + &mut seen_hook_events, + &mut active_tool_calls, + &mut session.conversation_id, + ) + .await?; + finish_output_tasks(stdout_task, stderr_task).await?; + remove_hook_dir(&hook_dir).await; + return Ok(TurnResult::Cancelled); + } + Some(Ok(frame)) => { + reject_while_busy(writer, &frame).await?; + } + Some(Err(error)) => { + send_error( + writer, + Value::Null, + -32700, + &format!("invalid JSON-RPC frame: {error}"), + ) + .await?; + } + None => { + stop_child(&mut child).await; + emit_hook_events( + writer, + session_id, + &hook_dir, + &mut seen_hook_events, + &mut active_tool_calls, + &mut session.conversation_id, + ) + .await?; + finish_output_tasks(stdout_task, stderr_task).await?; + remove_hook_dir(&hook_dir).await; + return Ok(TurnResult::InputClosed); + } + } + } + } + }; + + emit_hook_events( + writer, + session_id, + &hook_dir, + &mut seen_hook_events, + &mut active_tool_calls, + &mut session.conversation_id, + ) + .await?; + if session.conversation_id.is_none() { + capture_new_trajectory(trajectories_before.as_ref(), &mut session.conversation_id).await; + } + let (stdout, stderr) = finish_output_tasks(stdout_task, stderr_task).await?; + remove_hook_dir(&hook_dir).await; + Ok(TurnResult::Completed(AgyOutput { + status, + stdout, + stderr, + })) +} + +fn path_with_current_executable() -> Option { + let executable_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + let mut paths = vec![executable_dir]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).ok() +} + +async fn emit_hook_events( + writer: &mut W, + session_id: &str, + hook_dir: &Path, + seen: &mut HashSet, + active_tool_calls: &mut HashSet, + conversation_id: &mut Option, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let mut directory = tokio::fs::read_dir(hook_dir) + .await + .with_context(|| format!("failed reading hook directory {}", hook_dir.display()))?; + let mut paths = Vec::new(); + while let Some(entry) = directory + .next_entry() + .await + .context("failed reading Antigravity hook directory entry")? + { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) == Some("json") + && !seen.contains(&path) + { + paths.push(path); + } + } + paths.sort(); + + for path in paths { + let encoded = tokio::fs::read(&path) + .await + .with_context(|| format!("failed reading hook event {}", path.display()))?; + let hook = serde_json::from_slice::(&encoded) + .with_context(|| format!("invalid hook event {}", path.display()))?; + capture_completed_conversation(&hook, conversation_id); + for update in hook_session_updates(session_id, &hook, active_tool_calls) { + send_json(writer, &update).await?; + } + seen.insert(path); + } + Ok(()) +} + +fn capture_completed_conversation(hook: &Value, conversation_id: &mut Option) { + if hook.get("event").and_then(Value::as_str) != Some("stop") { + return; + } + let payload = &hook["payload"]; + if payload.get("fullyIdle").and_then(Value::as_bool) != Some(true) { + return; + } + if let Some(id) = payload + .get("conversationId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + *conversation_id = Some(id.to_string()); + } +} + +fn agy_brain_dir() -> Option { + non_empty_env("BUZZ_AGY_APP_DATA_DIR") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".gemini").join("antigravity-cli"))) + .map(|app_data| app_data.join("brain")) +} + +async fn snapshot_trajectory_ids() -> Option> { + let brain_dir = agy_brain_dir()?; + let mut entries = match tokio::fs::read_dir(&brain_dir).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Some(HashSet::new()); + } + Err(error) => { + tracing::debug!( + %error, + path = %brain_dir.display(), + "failed reading AGY trajectory directory" + ); + return None; + } + }; + let mut ids = HashSet::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(error) => { + tracing::debug!( + %error, + path = %brain_dir.display(), + "failed reading AGY trajectory directory entry" + ); + return None; + } + }; + let Some(id) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if Uuid::parse_str(&id).is_err() { + continue; + } + let transcript = entry + .path() + .join(".system_generated") + .join("logs") + .join("transcript.jsonl"); + if tokio::fs::metadata(transcript) + .await + .is_ok_and(|metadata| metadata.is_file()) + { + ids.insert(id); + } + } + Some(ids) +} + +async fn capture_new_trajectory( + before: Option<&HashSet>, + conversation_id: &mut Option, +) { + let (Some(before), Some(after)) = (before, snapshot_trajectory_ids().await) else { + return; + }; + let mut new_ids = after.difference(before); + let Some(id) = new_ids.next() else { + tracing::debug!("AGY did not publish a new trajectory transcript"); + return; + }; + if new_ids.next().is_some() { + tracing::debug!( + "AGY published multiple new trajectories; refusing to guess which one to resume" + ); + return; + } + *conversation_id = Some(id.clone()); +} + +fn hook_session_updates( + session_id: &str, + hook: &Value, + active_tool_calls: &mut HashSet, +) -> Vec { + let event = hook + .get("event") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let payload = hook.get("payload").cloned().unwrap_or(Value::Null); + let conversation_id = payload + .get("conversationId") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let step = payload.get("stepIdx").and_then(Value::as_u64).unwrap_or(0); + let tool_call_id = format!("agy-{conversation_id}-{step}"); + let metadata = json!({ + "antigravity": { + "event": event, + "conversationId": payload.get("conversationId"), + "stepIdx": payload.get("stepIdx"), + "transcriptPath": payload.get("transcriptPath"), + "artifactDirectoryPath": payload.get("artifactDirectoryPath") + } + }); + + let update = match event { + "pre-tool-use" => { + active_tool_calls.insert(tool_call_id.clone()); + let tool_call = payload.get("toolCall").cloned().unwrap_or(Value::Null); + let title = tool_call + .get("name") + .and_then(Value::as_str) + .unwrap_or("Antigravity tool"); + json!({ + "sessionUpdate": "tool_call", + "toolCallId": tool_call_id, + "title": title, + "kind": acp_tool_kind(title), + "status": "in_progress", + "rawInput": tool_call.get("args").cloned().unwrap_or(Value::Null), + "_meta": metadata + }) + } + "post-tool-use" => { + // AGY 1.1.x can emit PostToolUse payloads with `toolCall: null` + // for internal model steps. Only complete calls for which Buzz + // observed the matching PreToolUse event. + if !active_tool_calls.remove(&tool_call_id) { + return Vec::new(); + } + let error = payload.get("error").and_then(Value::as_str).unwrap_or(""); + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "status": if error.is_empty() { "completed" } else { "failed" }, + "rawOutput": if error.is_empty() { + json!({ "isError": false }) + } else { + json!({ "isError": true, "error": error }) + }, + "_meta": metadata + }) + } + _ => { + json!({ + "sessionUpdate": "session_info_update", + "_meta": metadata + }) + } + }; + + vec![json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": update + } + })] +} + +fn acp_tool_kind(name: &str) -> &'static str { + match name { + "write_to_file" | "replace_file_content" | "multi_replace_file_content" => "edit", + "view_file" | "list_dir" => "read", + "grep_search" | "find_by_name" | "codebase_search" | "search_web" => "search", + "run_command" => "execute", + name if name.starts_with("browser_") => "fetch", + _ => "other", + } +} + +async fn remove_hook_dir(path: &Path) { + if let Err(error) = tokio::fs::remove_dir_all(path).await { + tracing::debug!(%error, path = %path.display(), "failed removing AGY hook directory"); + } +} + +fn is_cancel_for_session(frame: &str, session_id: &str) -> bool { + serde_json::from_str::(frame).is_ok_and(|request| { + request.get("method").and_then(Value::as_str) == Some("session/cancel") + && request + .get("params") + .and_then(|params| params.get("sessionId")) + .and_then(Value::as_str) + == Some(session_id) + }) +} + +async fn reject_while_busy(writer: &mut W, frame: &str) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + match serde_json::from_str::(frame) { + Ok(request) => { + if let Some(id) = request.get("id").cloned() { + send_error( + writer, + id, + -32000, + "Antigravity is already processing a turn", + ) + .await?; + } + } + Err(error) => { + send_error( + writer, + Value::Null, + -32700, + &format!("invalid JSON: {error}"), + ) + .await?; + } + } + Ok(()) +} + +async fn stop_child(child: &mut Child) { + if let Err(error) = child.kill().await { + tracing::debug!(%error, "Antigravity process had already exited"); + } + let _ = child.wait().await; +} + +async fn read_process_output(reader: R) -> Result +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + reader + .take((MAX_PROCESS_OUTPUT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .await + .context("failed reading Antigravity process output")?; + if bytes.len() > MAX_PROCESS_OUTPUT_BYTES { + return Err(anyhow!( + "Antigravity process output exceeded {} bytes", + MAX_PROCESS_OUTPUT_BYTES + )); + } + String::from_utf8(bytes).context("Antigravity process output was not UTF-8") +} + +async fn finish_output_tasks( + stdout_task: tokio::task::JoinHandle>, + stderr_task: tokio::task::JoinHandle>, +) -> Result<(String, String)> { + let stdout = stdout_task + .await + .context("Antigravity stdout reader task failed")??; + let stderr = stderr_task + .await + .context("Antigravity stderr reader task failed")??; + Ok((stdout, stderr)) +} + +fn process_failure_message(output: &AgyOutput) -> String { + let stderr = output.stderr.trim(); + if stderr.is_empty() { + format!("Antigravity CLI exited with {}", output.status) + } else { + format!("Antigravity CLI exited with {}: {stderr}", output.status) + } +} + +async fn send_result(writer: &mut W, id: Value, result: Value) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + send_json( + writer, + &json!({ "jsonrpc": "2.0", "id": id, "result": result }), + ) + .await +} + +async fn send_error(writer: &mut W, id: Value, code: i32, message: &str) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + send_json( + writer, + &json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }), + ) + .await +} + +async fn send_json(writer: &mut W, value: &Value) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let line = serde_json::to_vec(value).context("failed serializing JSON-RPC response")?; + writer + .write_all(&line) + .await + .context("failed writing JSON-RPC response")?; + writer + .write_all(b"\n") + .await + .context("failed terminating JSON-RPC response")?; + writer + .flush() + .await + .context("failed flushing JSON-RPC response") +} + +async fn send_hook_json(writer: &mut W, value: &Value) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let encoded = serde_json::to_vec(value).context("failed serializing hook response")?; + writer + .write_all(&encoded) + .await + .context("failed writing hook response")?; + writer + .flush() + .await + .context("failed flushing hook response") +} + +#[cfg(test)] +mod tests { + use super::{ + acp_tool_kind, agy_args, capture_completed_conversation, hook_response, + hook_session_updates, is_bridge_invocation, parse_model_list, process_failure_message, + render_prompt, AgyOutput, Session, + }; + use serde_json::json; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn renders_system_prompt_and_all_text_blocks() { + let session = Session { + cwd: PathBuf::from("/tmp"), + system_prompt: Some("Be concise.".to_string()), + conversation_id: None, + }; + let rendered = render_prompt( + &session, + &json!({ + "prompt": [ + { "type": "text", "text": "First" }, + { "type": "image", "data": "ignored" }, + { "type": "text", "text": "Second" } + ] + }), + ) + .expect("prompt should render"); + + assert_eq!( + rendered, + "System instructions:\nBe concise.\n\nBuzz conversation and request:\nFirst\n\nSecond" + ); + } + + #[test] + fn agy_arguments_enable_noninteractive_permissions() { + let args = agy_args(None); + + assert!(args + .iter() + .any(|arg| arg == "--dangerously-skip-permissions")); + } + + #[test] + fn resumed_conversation_is_passed_before_prompt() { + let args = agy_args(Some("conversation-1")); + let conversation_flag = args + .iter() + .position(|arg| arg == "--conversation") + .expect("conversation flag"); + + assert_eq!( + args.get(conversation_flag + 1).map(String::as_str), + Some("conversation-1") + ); + } + + #[test] + fn recognizes_resolved_antigravity_bridge_invocation() { + assert!(is_bridge_invocation( + "/Applications/Buzz.app/Contents/MacOS/buzz-acp", + &["agy-acp".to_string()] + )); + assert!(is_bridge_invocation( + r"C:\Program Files\Buzz\buzz-acp.exe", + &["AGY-ACP".to_string()] + )); + assert!(!is_bridge_invocation( + "buzz-acp", + &["other-adapter".to_string()] + )); + assert!(!is_bridge_invocation("agy", &["agy-acp".to_string()])); + } + + #[test] + fn parses_and_deduplicates_native_model_list() { + assert_eq!( + parse_model_list( + "gemini-3.6-flash-high\n\n claude-sonnet-4-6 \ngemini-3.6-flash-high\n" + ), + vec!["gemini-3.6-flash-high", "claude-sonnet-4-6"] + ); + } + + #[test] + fn failure_message_includes_stderr() { + let output = AgyOutput { + status: std::process::Command::new("sh") + .args(["-c", "exit 7"]) + .status() + .expect("shell should run"), + stdout: String::new(), + stderr: "login required\n".to_string(), + }; + + let message = process_failure_message(&output); + assert!(message.contains("status: 7")); + assert!(message.contains("login required")); + } + + #[test] + fn maps_antigravity_edit_hook_to_acp_tool_updates() { + let mut active_tool_calls = HashSet::new(); + let updates = hook_session_updates( + "session-1", + &json!({ + "event": "pre-tool-use", + "payload": { + "conversationId": "conversation-1", + "stepIdx": 4, + "transcriptPath": "/tmp/transcript.jsonl", + "artifactDirectoryPath": "/tmp/artifacts", + "toolCall": { + "name": "replace_file_content", + "args": { + "TargetFile": "/workspace/src/lib.rs", + "ReplacementContent": "replacement" + } + } + } + }), + &mut active_tool_calls, + ); + + assert_eq!(updates.len(), 1); + let update = &updates[0]["params"]["update"]; + assert_eq!(update["sessionUpdate"], "tool_call"); + assert_eq!(update["toolCallId"], "agy-conversation-1-4"); + assert_eq!(update["kind"], "edit"); + assert_eq!(update["rawInput"]["ReplacementContent"], "replacement"); + assert_eq!( + update["_meta"]["antigravity"]["transcriptPath"], + "/tmp/transcript.jsonl" + ); + } + + #[test] + fn maps_antigravity_post_hook_error_to_failed_update() { + let mut active_tool_calls = HashSet::new(); + let pre_hook = json!({ + "event": "pre-tool-use", + "payload": { + "conversationId": "conversation-1", + "stepIdx": 4, + "toolCall": { "name": "run_command", "args": {} } + } + }); + hook_session_updates("session-1", &pre_hook, &mut active_tool_calls); + let updates = hook_session_updates( + "session-1", + &json!({ + "event": "post-tool-use", + "payload": { + "conversationId": "conversation-1", + "stepIdx": 4, + "error": "exit status 1" + } + }), + &mut active_tool_calls, + ); + + let update = &updates[0]["params"]["update"]; + assert_eq!(update["sessionUpdate"], "tool_call_update"); + assert_eq!(update["status"], "failed"); + assert_eq!(update["rawOutput"]["error"], "exit status 1"); + } + + #[test] + fn ignores_unmatched_antigravity_post_hook() { + let updates = hook_session_updates( + "session-1", + &json!({ + "event": "post-tool-use", + "payload": { + "conversationId": "conversation-1", + "stepIdx": 4, + "toolCall": null, + "error": "" + } + }), + &mut HashSet::new(), + ); + + assert!(updates.is_empty()); + } + + #[test] + fn maps_known_antigravity_tool_kinds() { + assert_eq!(acp_tool_kind("view_file"), "read"); + assert_eq!(acp_tool_kind("run_command"), "execute"); + assert_eq!(acp_tool_kind("browser_click"), "fetch"); + assert_eq!(acp_tool_kind("invoke_subagent"), "other"); + } + + #[test] + fn emits_documented_hook_decisions() { + assert_eq!(hook_response("pre-tool-use", true)["decision"], "allow"); + assert_eq!(hook_response("pre-tool-use", false)["decision"], "ask"); + assert_eq!(hook_response("stop", true)["decision"], "stop"); + assert_eq!(hook_response("post-tool-use", true), json!({})); + } + + #[test] + fn captures_only_fully_idle_stop_conversation() { + let mut conversation_id = None; + capture_completed_conversation( + &json!({ + "event": "pre-tool-use", + "payload": { "conversationId": "wrong", "fullyIdle": true } + }), + &mut conversation_id, + ); + capture_completed_conversation( + &json!({ + "event": "stop", + "payload": { "conversationId": "also-wrong", "fullyIdle": false } + }), + &mut conversation_id, + ); + assert_eq!(conversation_id, None); + + capture_completed_conversation( + &json!({ + "event": "stop", + "payload": { "conversationId": "conversation-1", "fullyIdle": true } + }), + &mut conversation_id, + ); + assert_eq!(conversation_id.as_deref(), Some("conversation-1")); + } +} diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..0c6e513428 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -617,6 +617,7 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "buzz-acp" => Some(vec!["agy-acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -694,7 +695,9 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec Result<()> { rustls::crypto::ring::default_provider() .install_default() .expect("failed to install rustls crypto provider"); + if is_subcommand("agy-hook") { + return agy_adapter::run_hook().await; + } + if is_subcommand("agy-acp") { + return agy_adapter::run().await; + } if is_subcommand("models") { // Strip the subcommand token so clap doesn't reject it as a positional. // Keeps argv[0] (binary name) and passes everything after the subcommand. @@ -3962,6 +3969,45 @@ async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); + if agy_adapter::is_bridge_invocation(&args.agent.agent_command, &agent_args) { + let models = agy_adapter::discover_models().await?; + if args.json { + let options = models + .iter() + .map(|model| { + serde_json::json!({ + "value": model, + "displayName": model, + }) + }) + .collect::>(); + let output = serde_json::json!({ + "agent": { + "name": "Antigravity", + "version": env!("CARGO_PKG_VERSION"), + }, + "stable": { + "configOptions": [{ + "category": "model", + "configId": "model", + "displayName": "Model", + "options": options, + }], + }, + "unstable": serde_json::Value::Null, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Agent: Antigravity v{}", env!("CARGO_PKG_VERSION")); + println!(); + println!("Models (Antigravity CLI):"); + for model in models { + println!(" - {model}"); + } + } + return Ok(()); + } + let cwd = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) .to_string_lossy() diff --git a/crates/buzz-acp/tests/agy_adapter.rs b/crates/buzz-acp/tests/agy_adapter.rs new file mode 100644 index 0000000000..23cf33d2f1 --- /dev/null +++ b/crates/buzz-acp/tests/agy_adapter.rs @@ -0,0 +1,387 @@ +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +fn test_directory() -> PathBuf { + std::env::temp_dir().join(format!("buzz-agy-adapter-test-{}", uuid::Uuid::new_v4())) +} + +fn write_executable(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write fake AGY executable"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("mark fake AGY executable"); +} + +async fn spawn_adapter(fake_agy: &Path) -> Child { + spawn_adapter_with_app_data(fake_agy, None).await +} + +async fn spawn_adapter_with_app_data(fake_agy: &Path, app_data: Option<&Path>) -> Child { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp")); + command + .arg("agy-acp") + .env("BUZZ_AGY_COMMAND", fake_agy) + .env("BUZZ_PRIVATE_KEY", "nsec-test-identity") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(app_data) = app_data { + command.env("BUZZ_AGY_APP_DATA_DIR", app_data); + } + command.spawn().expect("spawn AGY adapter") +} + +#[tokio::test] +async fn model_discovery_uses_native_antigravity_catalog() { + let directory = test_directory(); + std::fs::create_dir_all(&directory).expect("create test directory"); + let fake_agy = directory.join("agy"); + write_executable( + &fake_agy, + r#"#!/bin/sh +set -eu +test "${1:-}" = "models" +printf '%s\n' \ + gemini-3.6-flash-high \ + claude-sonnet-4-6 \ + gemini-3.6-flash-high +"#, + ); + + let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .arg("models") + .arg("--json") + .arg("--agent-command") + .arg(env!("CARGO_BIN_EXE_buzz-acp")) + .arg("--agent-args") + .arg("agy-acp") + .env("BUZZ_AGY_COMMAND", &fake_agy) + .output() + .await + .expect("run model discovery"); + + assert!( + output.status.success(), + "model discovery failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let response: Value = serde_json::from_slice(&output.stdout).expect("model discovery JSON"); + let options = response["stable"]["configOptions"][0]["options"] + .as_array() + .expect("model options"); + assert_eq!(response["agent"]["name"], "Antigravity"); + assert_eq!(options.len(), 2); + assert_eq!(options[0]["value"], "gemini-3.6-flash-high"); + assert_eq!(options[1]["value"], "claude-sonnet-4-6"); + + let _ = std::fs::remove_dir_all(directory); +} + +async fn write_request(child: &mut Child, request: Value) { + let stdin = child.stdin.as_mut().expect("adapter stdin"); + stdin + .write_all( + serde_json::to_string(&request) + .expect("serialize") + .as_bytes(), + ) + .await + .expect("write request"); + stdin.write_all(b"\n").await.expect("terminate request"); + stdin.flush().await.expect("flush request"); +} + +async fn read_response(reader: &mut BufReader) -> Value { + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + reader.read_line(&mut line), + ) + .await + .expect("adapter response timed out") + .expect("read adapter response"); + assert!(!line.is_empty(), "adapter exited before responding"); + serde_json::from_str(&line).expect("valid JSON-RPC response") +} + +async fn read_until_id( + reader: &mut BufReader, + expected_id: u64, +) -> Vec { + let mut messages = Vec::new(); + loop { + let message = read_response(reader).await; + let done = message.get("id") == Some(&json!(expected_id)); + messages.push(message); + if done { + return messages; + } + } +} + +async fn initialize_session( + child: &mut Child, + reader: &mut BufReader, + cwd: &Path, +) -> String { + write_request( + child, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": 2, "clientCapabilities": {} } + }), + ) + .await; + let initialize = read_response(reader).await; + assert_eq!(initialize["result"]["agentInfo"]["name"], "Antigravity"); + + write_request( + child, + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": cwd, + "mcpServers": [], + "systemPrompt": "Use the Buzz CLI." + } + }), + ) + .await; + read_response(reader).await["result"]["sessionId"] + .as_str() + .expect("session id") + .to_string() +} + +#[tokio::test] +async fn bridges_print_output_identity_and_hooks_to_acp() { + let directory = test_directory(); + std::fs::create_dir_all(&directory).expect("create test directory"); + let fake_agy = directory.join("agy"); + write_executable( + &fake_agy, + r#"#!/bin/sh +set -eu +last="" +previous="" +resume="none" +workspace="missing" +for arg in "$@"; do + if [ "$previous" = "--conversation" ]; then resume="$arg"; fi + if [ "$previous" = "--add-dir" ]; then workspace="$arg"; fi + previous="$arg" + last="$arg" +done +printf '%s' '{"conversationId":"conv-1","stepIdx":3,"transcriptPath":"/tmp/transcript.jsonl","artifactDirectoryPath":"/tmp/artifacts","toolCall":{"name":"replace_file_content","args":{"TargetFile":"src/lib.rs","ReplacementContent":"new"}}}' \ + | buzz-acp agy-hook pre-tool-use >/dev/null +printf '%s' '{"conversationId":"conv-1","stepIdx":3,"transcriptPath":"/tmp/transcript.jsonl","artifactDirectoryPath":"/tmp/artifacts","error":""}' \ + | buzz-acp agy-hook post-tool-use >/dev/null +printf '%s' '{"conversationId":"conv-1","executionNum":1,"terminationReason":"model_stop","error":"","fullyIdle":true,"transcriptPath":"/tmp/transcript.jsonl","artifactDirectoryPath":"/tmp/artifacts"}' \ + | buzz-acp agy-hook stop >/dev/null +printf 'fake response (%s, resume=%s, workspace=%s): %s\n' \ + "${BUZZ_PRIVATE_KEY:-missing}" "$resume" "$workspace" "$last" +"#, + ); + + let mut child = spawn_adapter(&fake_agy).await; + let stdout = child.stdout.take().expect("adapter stdout"); + let mut reader = BufReader::new(stdout); + let session_id = initialize_session(&mut child, &mut reader, &directory).await; + + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "Make the change." }] + } + }), + ) + .await; + + let messages = read_until_id(&mut reader, 3).await; + + let tool_start = messages + .iter() + .position(|message| { + message["params"]["update"]["sessionUpdate"] == "tool_call" + && message["params"]["update"]["kind"] == "edit" + }) + .expect("edit tool start"); + let tool_end = messages + .iter() + .position(|message| { + message["params"]["update"]["sessionUpdate"] == "tool_call_update" + && message["params"]["update"]["status"] == "completed" + }) + .expect("edit tool completion"); + assert!(tool_start < tool_end); + assert_eq!( + messages[tool_start]["params"]["update"]["_meta"]["antigravity"]["transcriptPath"], + "/tmp/transcript.jsonl" + ); + assert!(messages.iter().any(|message| { + message["params"]["update"]["content"]["text"] + .as_str() + .is_some_and(|text| { + text.contains("fake response (nsec-test-identity,") + && text.contains("resume=none") + && text.contains(&format!("workspace={}", directory.display())) + && text.contains("Make the change.") + }) + })); + assert_eq!( + messages.last().expect("prompt response")["result"]["stopReason"], + "end_turn" + ); + + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "Continue." }] + } + }), + ) + .await; + let resumed_messages = read_until_id(&mut reader, 4).await; + assert!(resumed_messages.iter().any(|message| { + message["params"]["update"]["content"]["text"] + .as_str() + .is_some_and(|text| text.contains("resume=conv-1") && text.contains("Continue.")) + })); + + child.kill().await.expect("stop adapter"); + let _ = std::fs::remove_dir_all(directory); +} + +#[tokio::test] +async fn cancellation_terminates_the_active_antigravity_process() { + let directory = test_directory(); + std::fs::create_dir_all(&directory).expect("create test directory"); + let fake_agy = directory.join("agy"); + write_executable(&fake_agy, "#!/bin/sh\nexec sleep 30\n"); + + let mut child = spawn_adapter(&fake_agy).await; + let stdout = child.stdout.take().expect("adapter stdout"); + let mut reader = BufReader::new(stdout); + let session_id = initialize_session(&mut child, &mut reader, &directory).await; + + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "Wait." }] + } + }), + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { "sessionId": session_id } + }), + ) + .await; + + let response = read_response(&mut reader).await; + assert_eq!(response["id"], 3); + assert_eq!(response["result"]["stopReason"], "cancelled"); + + child.kill().await.expect("stop adapter"); + let _ = std::fs::remove_dir_all(directory); +} + +#[tokio::test] +async fn resumes_new_trajectory_from_documented_transcript_path_without_hooks() { + let directory = test_directory(); + std::fs::create_dir_all(&directory).expect("create test directory"); + let app_data = directory.join("agy-data"); + let fake_agy = directory.join("agy"); + write_executable( + &fake_agy, + r#"#!/bin/sh +set -eu +previous="" +resume="none" +for arg in "$@"; do + if [ "$previous" = "--conversation" ]; then resume="$arg"; fi + previous="$arg" +done +trajectory_id="814807b5-4002-4f04-82b6-d85c0ed87d04" +transcript_dir="$BUZZ_AGY_APP_DATA_DIR/brain/$trajectory_id/.system_generated/logs" +mkdir -p "$transcript_dir" +: > "$transcript_dir/transcript.jsonl" +printf 'resume=%s\n' "$resume" +"#, + ); + + let mut child = spawn_adapter_with_app_data(&fake_agy, Some(&app_data)).await; + let stdout = child.stdout.take().expect("adapter stdout"); + let mut reader = BufReader::new(stdout); + let session_id = initialize_session(&mut child, &mut reader, &directory).await; + + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "First." }] + } + }), + ) + .await; + let first = read_until_id(&mut reader, 3).await; + assert!(first + .iter() + .any(|message| { message["params"]["update"]["content"]["text"] == "resume=none\n" })); + + write_request( + &mut child, + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "Second." }] + } + }), + ) + .await; + let second = read_until_id(&mut reader, 4).await; + assert!(second.iter().any(|message| { + message["params"]["update"]["content"]["text"] + == "resume=814807b5-4002-4f04-82b6-d85c0ed87d04\n" + })); + + child.kill().await.expect("stop adapter"); + let _ = std::fs::remove_dir_all(directory); +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..7477529494 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,5 +1,43 @@ use super::*; +#[test] +fn antigravity_native_catalog_normalizes_to_selectable_models() { + let raw = serde_json::json!({ + "agent": { "name": "Antigravity", "version": "0.1.0" }, + "stable": { + "configOptions": [{ + "category": "model", + "configId": "model", + "displayName": "Model", + "options": [ + { + "value": "gemini-3.6-flash-high", + "displayName": "gemini-3.6-flash-high" + }, + { + "value": "claude-sonnet-4-6", + "displayName": "claude-sonnet-4-6" + } + ] + }] + }, + "unstable": null + }); + + let response = normalize_agent_models(&raw, None); + + assert_eq!(response.agent_name, "Antigravity"); + assert!(response.supports_switching); + assert_eq!( + response + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["gemini-3.6-flash-high", "claude-sonnet-4-6"] + ); +} + #[test] fn openai_model_normalization_keeps_agent_text_models() { let models = normalize_openai_compatible_models( diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..50cc25afba 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -16,6 +16,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const ANTIGRAVITY_AVATAR_URL: &str = "https://github.com/google-antigravity.png"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; @@ -49,6 +50,7 @@ fn common_binary_paths() -> &'static [PathBuf] { paths.push(PathBuf::from(appdata).join("npm")); } if let Some(local) = std::env::var_os("LOCALAPPDATA") { + paths.push(PathBuf::from(local.clone()).join("agy").join("bin")); paths.push( PathBuf::from(local) .join("Programs") @@ -157,6 +159,48 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "antigravity", + label: "Antigravity", + // Antigravity has no native ACP transport yet. The bundled buzz-acp + // sidecar provides a compatibility bridge around `agy --print`. + commands: &["buzz-acp"], + aliases: &["agy", "google-antigravity"], + avatar_url: ANTIGRAVITY_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("agy"), + cli_install_commands: &[ + "curl -fsSL https://antigravity.google/cli/install.sh | bash", + ], + cli_install_commands_windows: &[ + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://antigravity.google/cli/install.ps1 | iex\"", + ], + adapter_install_commands: &[], + install_instructions_url: "https://antigravity.google/docs/cli/install", + cli_install_hint: "Install the Antigravity CLI via the official install script.", + adapter_install_hint: "The Antigravity ACP bridge ships with Buzz.", + // AGY reads the canonical `.agents/skills` path directly. + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("BUZZ_AGY_MODEL"), + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.gemini/antigravity-cli/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + // AGY accepts low/medium/high, but the shared effort picker currently + // has a broader provider-derived catalog. Keep it in Advanced env vars + // until the catalog can express a runtime-owned fixed value set. + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `agy` once in a terminal to complete Google authentication."), + // AGY 1.1.x has no reliable non-interactive auth-status command. + auth_probe_args: None, + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -343,6 +387,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), + "buzz-acp" => Some(vec!["agy-acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -364,7 +409,9 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec Result<(), String> { /// - Creates the root directory and all subdirectories. /// - Writes `AGENTS.md` only if it doesn't already exist. /// - Writes `.agents/skills/buzz-cli/SKILL.md` only if it doesn't already exist. -/// - Creates harness-specific symlinks pointing to the canonical -/// `.agents/skills/buzz-cli` directory for each known provider. +/// - Creates provider skill symlinks and merges the Antigravity observer into +/// `.agents/hooks.json`. /// - Sets 700 permissions on the root, all subdirectories, and the skill /// directory tree (Unix). /// @@ -234,10 +234,9 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } - // Create harness-specific symlinks for all known providers. - // Migration of the old .claude/skills/buzz-cli real dir is handled in - // refresh_skill_md_if_stale; ensure_skill_symlinks skips paths that already exist. + // Create provider skill symlinks and AGY hooks. ensure_skill_symlinks(root)?; + agy_hooks::ensure_agy_hooks(root)?; // Refresh static content if the embedded template version is newer. refresh_agents_md_if_stale(root)?; @@ -699,5 +698,6 @@ pub fn try_regenerate_nest(app: &AppHandle) { } } +mod agy_hooks; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/agy_hooks.rs b/desktop/src-tauri/src/managed_agents/nest/agy_hooks.rs new file mode 100644 index 0000000000..64abf86263 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/agy_hooks.rs @@ -0,0 +1,52 @@ +use std::fs; +use std::io; +use std::path::Path; + +/// Antigravity hooks that translate documented tool lifecycle events into +/// Buzz ACP observer updates. +const AGY_HOOKS_JSON: &str = include_str!("../nest_agy_hooks.json"); +pub(super) const AGY_HOOKS_KEY: &str = "buzz-antigravity-observer"; + +/// Add Buzz's Antigravity hook observer without replacing user-defined hooks. +/// +/// A malformed existing hooks file is preserved unchanged. Antigravity will +/// report that configuration problem itself, and Buzz must not destroy a +/// user's customizations while trying to repair it. +pub(super) fn ensure_agy_hooks(root: &Path) -> Result<(), String> { + let agents_dir = root.join(".agents"); + fs::create_dir_all(&agents_dir).map_err(|e| format!("create {}: {e}", agents_dir.display()))?; + let hooks_path = agents_dir.join("hooks.json"); + let template: serde_json::Value = serde_json::from_str(AGY_HOOKS_JSON) + .map_err(|e| format!("parse bundled Antigravity hooks: {e}"))?; + let Some(template_hook) = template.get(AGY_HOOKS_KEY).cloned() else { + return Err("bundled Antigravity hooks are missing their managed key".to_string()); + }; + + let mut hooks = match fs::read_to_string(&hooks_path) { + Ok(existing) => match serde_json::from_str::(&existing) { + Ok(serde_json::Value::Object(hooks)) => hooks, + Ok(_) | Err(_) => return Ok(()), + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => serde_json::Map::new(), + Err(error) => return Err(format!("read {}: {error}", hooks_path.display())), + }; + if hooks.contains_key(AGY_HOOKS_KEY) { + return Ok(()); + } + hooks.insert(AGY_HOOKS_KEY.to_string(), template_hook); + + let encoded = serde_json::to_vec_pretty(&serde_json::Value::Object(hooks)) + .map_err(|e| format!("serialize Antigravity hooks: {e}"))?; + let mut tmp = tempfile::NamedTempFile::new_in(&agents_dir) + .map_err(|e| format!("tempfile in {}: {e}", agents_dir.display()))?; + { + use std::io::Write; + tmp.write_all(&encoded) + .map_err(|e| format!("write Antigravity hooks tempfile: {e}"))?; + tmp.write_all(b"\n") + .map_err(|e| format!("finish Antigravity hooks tempfile: {e}"))?; + } + tmp.persist(&hooks_path) + .map_err(|e| format!("persist {}: {e}", hooks_path.display()))?; + Ok(()) +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a959381603..d9580fc344 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -1,3 +1,4 @@ +use super::agy_hooks::AGY_HOOKS_KEY; use super::*; #[test] @@ -48,6 +49,11 @@ fn ensure_nest_creates_all_dirs_and_agents_md() { let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); assert_eq!(content, AGENTS_MD); + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(root.join(".agents/hooks.json")).unwrap()) + .unwrap(); + assert!(hooks.get(AGY_HOOKS_KEY).is_some()); + // Permissions are 700 on Unix for root and all subdirs. #[cfg(unix)] { @@ -149,6 +155,39 @@ fn ensure_nest_does_not_overwrite_skill_file() { assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content"); } +#[test] +fn ensure_nest_merges_antigravity_hook_with_user_hooks() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/hooks.json"), + r#"{"my-hook":{"Stop":[{"command":"./notify.sh"}]}}"#, + ) + .unwrap(); + + ensure_nest_at(&root).unwrap(); + + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(root.join(".agents/hooks.json")).unwrap()) + .unwrap(); + assert!(hooks.get("my-hook").is_some()); + assert!(hooks.get(AGY_HOOKS_KEY).is_some()); +} + +#[test] +fn ensure_nest_preserves_malformed_antigravity_hooks_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(root.join(".agents")).unwrap(); + let hooks_path = root.join(".agents/hooks.json"); + fs::write(&hooks_path, "not-json").unwrap(); + + ensure_nest_at(&root).unwrap(); + + assert_eq!(fs::read_to_string(hooks_path).unwrap(), "not-json"); +} + #[cfg(unix)] #[test] fn ensure_nest_skill_dir_has_700_permissions() { diff --git a/desktop/src-tauri/src/managed_agents/nest_agy_hooks.json b/desktop/src-tauri/src/managed_agents/nest_agy_hooks.json new file mode 100644 index 0000000000..a9c5cd0f01 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest_agy_hooks.json @@ -0,0 +1,35 @@ +{ + "buzz-antigravity-observer": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "buzz-acp agy-hook pre-tool-use", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "buzz-acp agy-hook post-tool-use", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "type": "command", + "command": "buzz-acp agy-hook stop", + "timeout": 10 + } + ] + } +} diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index a410914377..0c7f29540f 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -22,7 +22,11 @@ function isBuzzRuntime(runtime: AcpRuntimeCatalogEntry): boolean { export function getRuntimeDisplayLabel( runtime: AcpRuntimeCatalogEntry, ): string { - return isBuzzRuntime(runtime) ? "Buzz" : runtime.label; + if (isBuzzRuntime(runtime)) return "Buzz"; + if (runtime.id.trim().toLowerCase() === "antigravity") { + return "Antigravity CLI"; + } + return runtime.label; } function getRuntimeLogoUrl(runtime: AcpRuntimeCatalogEntry): string | null { diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index aa02486706..6441fc88b5 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -612,7 +612,7 @@ function RuntimeProvidersSection({
{orderedItems.length > 0 ? ( -
+
{orderedItems.map((runtime) => ( - No supported agent harnesses were detected yet. Install Claude Code - or Codex, then check again. + No supported agent harnesses were detected yet. Install Claude Code, + Codex, or Antigravity, then check again.

)} diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa19154..66161d3801 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -12,9 +12,10 @@ function runtime(id, availability, status) { return { id, availability, authStatus: { status } }; } -test("only Claude Code and Codex are visible in onboarding", () => { +test("only Claude Code, Codex, and Antigravity are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); + assert.equal(runtimeIsVisibleInOnboarding("antigravity"), true); assert.equal(runtimeIsVisibleInOnboarding("goose"), false); assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); @@ -26,11 +27,12 @@ test("visible onboarding runtimes use the product order", () => { runtime("codex", "available", "logged_in"), runtime("goose", "available", "not_applicable"), runtime("claude", "available", "logged_in"), + runtime("antigravity", "available", "not_applicable"), ]; assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["claude", "codex", "antigravity"], ); }); @@ -61,10 +63,11 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => { runtime("codex", "available", "logged_out"), runtime("buzz-agent", "available", "not_applicable"), runtime("claude", "available", "logged_in"), + runtime("antigravity", "available", "not_applicable"), ]; assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude"], + ["claude", "antigravity"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..02303824ae 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,6 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex", "antigravity"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a7d58e70cc..25fe9f0f8d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6902,7 +6902,9 @@ function withMockRuntimeConfigMetadata( ? "BUZZ_AGENT_MODEL" : runtime.id === "goose" ? "GOOSE_MODEL" - : null, + : runtime.id === "antigravity" + ? "BUZZ_AGY_MODEL" + : null, provider_env_var: "provider_env_var" in runtime ? runtime.provider_env_var @@ -7013,6 +7015,26 @@ async function handleDiscoverAcpRuntimes( auth_status: { status: "unknown" }, login_hint: undefined, }, + { + id: "antigravity", + label: "Antigravity", + avatar_url: "", + availability: "not_installed", + command: null, + binary_path: null, + default_args: [], + mcp_command: null, + model_env_var: "BUZZ_AGY_MODEL", + install_hint: + "Install the Antigravity CLI via the official install script.", + install_instructions_url: "https://antigravity.google/docs/cli/install", + can_auto_install: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "not_applicable" }, + login_hint: + "Run `agy` once in a terminal to complete Google authentication.", + }, { id: "buzz-agent", label: "Buzz Agent",