From 766f82e3097ea0fbda46526382e4758566feb739 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Thu, 17 Sep 2026 00:06:38 -0300 Subject: [PATCH 1/5] fix(reader): resolve peak_hour ties deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hour_counts` is a HashMap and `max_by_key` keeps the last maximum it sees, so when two hours tied the winner depended on hash iteration order and changed between runs on identical data — the Summary tab's "Peak hour" flipped on every refresh. Ties now resolve to the earliest hour. Agent-agnostic, but sparse histories hit it constantly: a real 13-session profile had a four-way tie at three sessions each. --- crates/aura-core/src/reader/claude_code.rs | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/aura-core/src/reader/claude_code.rs b/crates/aura-core/src/reader/claude_code.rs index 6f407cc..8f1d150 100644 --- a/crates/aura-core/src/reader/claude_code.rs +++ b/crates/aura-core/src/reader/claude_code.rs @@ -178,9 +178,12 @@ pub(crate) fn build_snapshot(accum: ScanAccum, cache: Option<&StatsCache>) -> Us active_dates.sort_unstable(); let (current_streak, longest_streak) = compute_streaks(&active_dates); + // `hour_counts` is a HashMap, and `max_by_key` keeps the last maximum it + // sees — so on a tie the answer used to depend on hash order and changed + // between runs on the same data. Ties now resolve to the earliest hour. let peak_hour = hour_counts .iter() - .max_by_key(|(_, count)| *count) + .max_by_key(|(hour, count)| (**count, std::cmp::Reverse(**hour))) .map(|(h, _)| *h); // ── Daily breakdown vectors ─────────────────────────────────────────────── @@ -237,6 +240,29 @@ mod tests { use std::{fs, io::Write}; use tempfile::tempdir; + #[test] + fn peak_hour_breaks_ties_deterministically() { + // Two hours tied at two starts each. Whichever we pick, we must pick + // the same one every time — a stat that flips between refreshes on + // unchanged data reads as a bug. Sparse histories (a handful of + // sessions) hit this constantly. + let mut accum = ScanAccum::default(); + accum.hour_counts.insert(9, 2); + accum.hour_counts.insert(17, 2); + accum.hour_counts.insert(3, 1); + + let first = build_snapshot(accum, None).peak_hour; + assert_eq!(first, Some(9), "ties resolve to the earliest hour"); + + for _ in 0..64 { + let mut accum = ScanAccum::default(); + accum.hour_counts.insert(9, 2); + accum.hour_counts.insert(17, 2); + accum.hour_counts.insert(3, 1); + assert_eq!(build_snapshot(accum, None).peak_hour, first); + } + } + fn write_jsonl(dir: &std::path::Path, name: &str, lines: &[&str]) -> PathBuf { let path = dir.join(name); let mut f = fs::File::create(&path).unwrap(); From c02141ff6689db302b425e3f0fe27bf5ac808ae8 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Thu, 17 Sep 2026 00:07:23 -0300 Subject: [PATCH 2/5] refactor(core): share executable lookup between plugins and agents The plugin runner already knew that Aura's inherited PATH is the wrong one: it runs from a GUI launcher, a systemd user unit or a launchd agent, none of which source the user's shell rc files, so PATH routinely omits the per-user bin directories where CLI tools install themselves. Its `augmented_path()` helper compensated when spawning plugins. Move that into `bin_path`, and add `resolve_executable`, which returns an absolute path instead. Widening only the child's PATH is not enough on its own: Unix resolves a bare program name against the PATH handed to the child, but Windows resolves it against the parent's. Resolving up front behaves the same everywhere and lets callers report which directories were searched. No behaviour change for plugins. --- crates/aura-core/src/bin_path.rs | 352 ++++++++++++++++++++++++++ crates/aura-core/src/lib.rs | 1 + crates/aura-core/src/plugin/runner.rs | 94 +------ 3 files changed, 354 insertions(+), 93 deletions(-) create mode 100644 crates/aura-core/src/bin_path.rs diff --git a/crates/aura-core/src/bin_path.rs b/crates/aura-core/src/bin_path.rs new file mode 100644 index 0000000..ffa8d21 --- /dev/null +++ b/crates/aura-core/src/bin_path.rs @@ -0,0 +1,352 @@ +//! Finding executables when Aura's own `PATH` is the wrong one. +//! +//! Aura normally runs from a GUI launcher — a Linux `.desktop` file, a macOS +//! `.app` bundle, a systemd user unit — and none of those source the user's +//! shell rc files. The inherited `PATH` is whatever the session manager set, +//! which routinely omits the per-user bin directories where CLI tools install +//! themselves. On the reference machine the systemd user manager exports: +//! +//! ```text +//! /home/u/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:… +//! ``` +//! +//! — no `~/.local/bin`, which is exactly where `agy` (and plenty else) lands. +//! Running the same binary from an interactive shell works, which is what +//! makes this failure mode confusing to hit. +//! +//! Two consumers, two different needs: +//! +//! - [`augmented_path`] builds the `PATH` handed to a *child* process, so a +//! tool Aura spawns can find the tools *it* shells out to. +//! - [`resolve_executable`] finds a binary for Aura itself, returning an +//! absolute path. Setting the child's `PATH` is not enough on its own: +//! Unix resolves a bare program name against the `PATH` you hand the child, +//! but Windows resolves it against the *parent's*. Resolving up front works +//! the same way everywhere, and lets the caller say which directories it +//! looked in when nothing turns up. + +use std::{ + collections::HashSet, + ffi::OsString, + path::{Path, PathBuf}, +}; + +/// Per-user bin directories to look in ahead of the inherited `PATH`. +/// Relative to the user's home. +const HOME_BIN_DIRS: &[&str] = &[".local/bin", ".cargo/bin", ".bun/bin", "bin"]; + +/// System bin directories a GUI `PATH` may omit. `/opt/homebrew/bin` is Apple +/// Silicon Homebrew; harmless elsewhere, since a missing directory is skipped. +const SYSTEM_BIN_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"]; + +/// Every directory to search, most specific first: the per-user bin dirs, the +/// system ones, then whatever `PATH` already had. +pub fn search_dirs() -> Vec { + search_dirs_in(&[]) +} + +/// [`search_dirs`] with caller-supplied directories tried first — for a tool +/// whose installer uses a location no generic list would guess. Directories +/// that don't exist are dropped, so a caller can pass a platform's path +/// unconditionally. +pub fn search_dirs_in(extra: &[PathBuf]) -> Vec { + search_dirs_from(extra, dirs::home_dir(), std::env::var_os("PATH")) +} + +fn search_dirs_from( + extra: &[PathBuf], + home: Option, + existing: Option, +) -> Vec { + let mut entries: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let mut push = |p: PathBuf| { + if p.is_dir() && seen.insert(p.clone()) { + entries.push(p); + } + }; + + for dir in extra { + push(dir.clone()); + } + if let Some(home) = home { + for sub in HOME_BIN_DIRS { + push(home.join(sub)); + } + } + for p in SYSTEM_BIN_DIRS { + push(PathBuf::from(p)); + } + if let Some(existing) = existing.as_ref() { + for p in std::env::split_paths(existing) { + if !p.as_os_str().is_empty() { + push(p); + } + } + } + entries +} + +/// The `PATH` to hand a spawned child process. +pub fn augmented_path() -> OsString { + augmented_path_from(dirs::home_dir(), std::env::var_os("PATH")) +} + +pub(crate) fn augmented_path_from(home: Option, existing: Option) -> OsString { + let entries = search_dirs_from(&[], home, existing.clone()); + std::env::join_paths(entries).unwrap_or_else(|_| existing.unwrap_or_default()) +} + +/// A short human summary of where [`resolve_executable`] looked, for an error +/// message. The full list runs to dozens of entries on a developer machine and +/// reads as noise in a tray tooltip, so this names the first few directories — +/// the per-user ones Aura adds, which are the interesting part — and counts the +/// rest. Paths under the user's home are shortened back to `~`. +pub fn search_summary(extra: &[PathBuf]) -> String { + const SHOWN: usize = 3; + let home = dirs::home_dir(); + let dirs = search_dirs_in(extra); + + let pretty = |p: &Path| match home.as_ref().and_then(|h| p.strip_prefix(h).ok()) { + Some(rest) => format!("~/{}", rest.display()), + None => p.display().to_string(), + }; + + let head: Vec = dirs.iter().take(SHOWN).map(|p| pretty(p)).collect(); + match dirs.len().saturating_sub(head.len()) { + 0 => head.join(", "), + n => format!("{} and {n} more on PATH", head.join(", ")), + } +} + +/// Find `command` as an absolute path. +/// +/// - A leading `~` is expanded, and anything that already looks like a path +/// (contains a separator) is taken at its word — existence is still checked, +/// so a stale `command =` in config reports as "not found" rather than as a +/// spawn error later. +/// - A bare name is searched for across [`search_dirs`]. +/// +/// Returns `None` when nothing matches; [`search_dirs`] is what the caller +/// should name in the resulting error. +pub fn resolve_executable(command: &str) -> Option { + resolve_executable_in(command, &[]) +} + +/// [`resolve_executable`] with extra directories searched first. See +/// [`search_dirs_in`]. +pub fn resolve_executable_in(command: &str, extra: &[PathBuf]) -> Option { + let expanded = expand_tilde(command); + if expanded.components().count() > 1 || command.starts_with('~') { + return is_executable(&expanded).then_some(expanded); + } + search_dirs_in(extra) + .into_iter() + .flat_map(|dir| candidate_names(command).map(move |name| dir.join(name))) + .find(|p| is_executable(p)) +} + +/// Filenames to try for a bare command name. Unix has exactly one; Windows +/// needs the `PATHEXT` suffixes, since `foo` on disk is `foo.exe` or `foo.cmd`. +fn candidate_names(command: &str) -> impl Iterator + '_ { + #[cfg(windows)] + let exts: &[&str] = &["", ".exe", ".cmd", ".bat", ".com"]; + #[cfg(not(windows))] + let exts: &[&str] = &[""]; + exts.iter().map(move |ext| format!("{command}{ext}")) +} + +fn expand_tilde(path: &str) -> PathBuf { + let home = || dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + if let Some(rest) = path.strip_prefix("~/") { + home().join(rest) + } else if path == "~" { + home() + } else { + PathBuf::from(path) + } +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[cfg(unix)] + fn write_exe(dir: &Path, name: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + fs::create_dir_all(dir).unwrap(); + let path = dir.join(name); + fs::write(&path, "#!/bin/sh\ntrue\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[test] + fn home_bin_dirs_come_before_the_inherited_path() { + // The whole point: a GUI `PATH` that omits `~/.local/bin` still finds + // what the user installed there. + let home = tempdir().unwrap(); + fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + + let dirs = search_dirs_from( + &[], + Some(home.path().to_path_buf()), + Some(OsString::from("/usr/bin:/bin")), + ); + assert_eq!(dirs.first(), Some(&home.path().join(".local/bin"))); + } + + #[test] + fn missing_directories_are_skipped() { + let home = tempdir().unwrap(); + // None of HOME_BIN_DIRS exist under this home. + let dirs = search_dirs_from( + &[], + Some(home.path().to_path_buf()), + Some(OsString::from("")), + ); + assert!(dirs.iter().all(|d| !d.starts_with(home.path()))); + } + + #[test] + fn duplicate_entries_are_collapsed() { + let home = tempdir().unwrap(); + let local = home.path().join(".local/bin"); + fs::create_dir_all(&local).unwrap(); + + // The same dir arrives twice: once from HOME_BIN_DIRS, once from PATH. + let dirs = search_dirs_from( + &[], + Some(home.path().to_path_buf()), + Some(OsString::from(local.to_str().unwrap())), + ); + assert_eq!(dirs.iter().filter(|d| **d == local).count(), 1); + } + + #[test] + fn augmented_path_keeps_the_inherited_entries() { + let home = tempdir().unwrap(); + fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + + let merged = augmented_path_from( + Some(home.path().to_path_buf()), + Some(OsString::from("/usr/bin:/bin")), + ); + let entries: Vec = std::env::split_paths(&merged).collect(); + assert!(entries.contains(&PathBuf::from("/usr/bin"))); + assert!(entries.contains(&home.path().join(".local/bin"))); + } + + #[cfg(unix)] + #[test] + fn resolve_finds_a_bare_name_in_a_home_bin_dir() { + let home = tempdir().unwrap(); + let expected = write_exe(&home.path().join(".local/bin"), "aura-test-tool"); + + // Point the resolver's notion of home at the tempdir, and give it a + // PATH that deliberately doesn't contain the tool. + let prev_home = std::env::var_os("HOME"); + let prev_path = std::env::var_os("PATH"); + std::env::set_var("HOME", home.path()); + std::env::set_var("PATH", "/nonexistent-for-this-test"); + + let found = resolve_executable("aura-test-tool"); + + match prev_home { + Some(h) => std::env::set_var("HOME", h), + None => std::env::remove_var("HOME"), + } + match prev_path { + Some(p) => std::env::set_var("PATH", p), + None => std::env::remove_var("PATH"), + } + + assert_eq!(found.as_deref(), Some(expected.as_path())); + } + + #[cfg(unix)] + #[test] + fn resolve_rejects_a_non_executable_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("not-executable"); + fs::write(&path, "data").unwrap(); + assert_eq!(resolve_executable(path.to_str().unwrap()), None); + } + + #[cfg(unix)] + #[test] + fn resolve_takes_an_absolute_path_as_given() { + let dir = tempdir().unwrap(); + let exe = write_exe(dir.path(), "tool"); + assert_eq!( + resolve_executable(exe.to_str().unwrap()).as_deref(), + Some(exe.as_path()) + ); + } + + #[test] + fn extra_dirs_are_searched_before_everything_else() { + // How a caller reaches an installer-specific location no generic list + // would guess — e.g. `%LOCALAPPDATA%\\agy\\bin` on Windows. + let home = tempdir().unwrap(); + let extra = home.path().join("vendor-specific"); + fs::create_dir_all(&extra).unwrap(); + fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + + let dirs = search_dirs_from( + std::slice::from_ref(&extra), + Some(home.path().to_path_buf()), + Some(OsString::from("/usr/bin")), + ); + assert_eq!(dirs.first(), Some(&extra)); + } + + #[test] + fn extra_dirs_that_do_not_exist_are_dropped() { + // Callers pass a platform's path unconditionally; the other platforms + // must not end up naming a directory that cannot exist there. + let home = tempdir().unwrap(); + let missing = home.path().join("not-installed"); + let dirs = search_dirs_from( + std::slice::from_ref(&missing), + Some(home.path().to_path_buf()), + Some(OsString::from("/usr/bin")), + ); + assert!(!dirs.contains(&missing)); + } + + #[test] + fn search_summary_stays_short_enough_for_a_tooltip() { + // A developer machine has dozens of PATH entries; the note this feeds + // has to stay one readable line, so at most three are named and the + // rest are counted. + let summary = search_summary(&[]); + assert!(!summary.is_empty()); + assert!( + summary.matches(", ").count() <= 2, + "too many directories listed: {summary}" + ); + } + + #[test] + fn resolve_reports_a_missing_binary_rather_than_guessing() { + assert_eq!(resolve_executable("aura-no-such-binary-9f3c1d"), None); + assert_eq!(resolve_executable("/nope/aura-no-such-binary-9f3c1d"), None); + } +} diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index c98e7b6..b89f0dd 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bin_path; pub mod config; pub mod config_migrate; pub mod config_schema; diff --git a/crates/aura-core/src/plugin/runner.rs b/crates/aura-core/src/plugin/runner.rs index 16f5c10..ee550e1 100644 --- a/crates/aura-core/src/plugin/runner.rs +++ b/crates/aura-core/src/plugin/runner.rs @@ -1,6 +1,4 @@ use std::{ - collections::HashSet, - ffi::OsString, path::PathBuf, process::{Command, Stdio}, sync::mpsc, @@ -8,6 +6,7 @@ use std::{ time::Duration, }; +use crate::bin_path::augmented_path; use crate::config::PluginConfig; use crate::reader::Period; @@ -57,46 +56,6 @@ fn resolve_command(cmd: &str) -> PathBuf { PathBuf::from(cmd) } -/// Build the `PATH` to hand to spawned plugins. GUI launchers (Linux .desktop -/// files, macOS .app bundles, systemd user units) do not source the user's -/// shell rc files, so the inherited `PATH` is often missing `~/.local/bin`, -/// `~/.cargo/bin`, `/opt/homebrew/bin`, etc. — exactly the dirs plugins need -/// to find tools they shell out to (e.g. `aura-plugin-rtk` running `rtk`). -fn augmented_path() -> OsString { - augmented_path_from(dirs::home_dir(), std::env::var_os("PATH")) -} - -fn augmented_path_from(home: Option, existing: Option) -> OsString { - let mut entries: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - - let push = |p: PathBuf, entries: &mut Vec, seen: &mut HashSet| { - if p.is_dir() && seen.insert(p.clone()) { - entries.push(p); - } - }; - - if let Some(home) = home { - for sub in [".local/bin", ".cargo/bin", ".bun/bin", "bin"] { - push(home.join(sub), &mut entries, &mut seen); - } - } - // /opt/homebrew/bin is Apple Silicon Homebrew; harmless on Linux (won't exist). - for p in ["/opt/homebrew/bin", "/usr/local/bin"] { - push(PathBuf::from(p), &mut entries, &mut seen); - } - - if let Some(existing) = existing.as_ref() { - for p in std::env::split_paths(existing) { - if !p.as_os_str().is_empty() && seen.insert(p.clone()) { - entries.push(p); - } - } - } - - std::env::join_paths(entries).unwrap_or_else(|_| existing.unwrap_or_default()) -} - impl PluginRunner { /// Spawn `config.command` (default period: AllTime). pub fn run(config: &PluginConfig) -> PluginPanel { @@ -336,57 +295,6 @@ EOF .contains("invalid plugin JSON")); } - #[cfg(unix)] - #[test] - fn augmented_path_prepends_existing_user_dirs() { - let home = tempdir().unwrap(); - let local_bin = home.path().join(".local/bin"); - let cargo_bin = home.path().join(".cargo/bin"); - std::fs::create_dir_all(&local_bin).unwrap(); - std::fs::create_dir_all(&cargo_bin).unwrap(); - // `~/.bun/bin` deliberately missing — must be skipped. - - let existing = OsString::from("/usr/bin:/bin"); - let merged = augmented_path_from(Some(home.path().to_path_buf()), Some(existing)); - let parts: Vec = std::env::split_paths(&merged).collect(); - - assert!( - parts.contains(&local_bin), - "missing ~/.local/bin: {parts:?}" - ); - assert!( - parts.contains(&cargo_bin), - "missing ~/.cargo/bin: {parts:?}" - ); - assert!(parts.contains(&PathBuf::from("/usr/bin"))); - assert!(parts.contains(&PathBuf::from("/bin"))); - // Augmented dirs must come before the inherited entries. - let local_idx = parts.iter().position(|p| p == &local_bin).unwrap(); - let usr_idx = parts - .iter() - .position(|p| p == &PathBuf::from("/usr/bin")) - .unwrap(); - assert!(local_idx < usr_idx); - // Non-existent ~/.bun/bin should not appear. - assert!(!parts.contains(&home.path().join(".bun/bin"))); - } - - #[cfg(unix)] - #[test] - fn augmented_path_dedupes_existing_entries() { - let home = tempdir().unwrap(); - let local_bin = home.path().join(".local/bin"); - std::fs::create_dir_all(&local_bin).unwrap(); - - // Existing PATH already contains ~/.local/bin — should not duplicate. - let existing = OsString::from(format!("{}:/usr/bin", local_bin.display())); - let merged = augmented_path_from(Some(home.path().to_path_buf()), Some(existing)); - let count = std::env::split_paths(&merged) - .filter(|p| p == &local_bin) - .count(); - assert_eq!(count, 1); - } - #[cfg(unix)] #[test] fn returns_error_panel_on_nonzero_exit() { From bdec11c4ee51a3dfc55eb07ef69b145948286752 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Thu, 17 Sep 2026 00:07:36 -0300 Subject: [PATCH 3/5] feat(agent): add Antigravity (agy) as a first-class agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's Antigravity CLI is a fourth agent in daily use with its own rate-limit windows that nothing else surfaces. Adds `AgentKind:: Antigravity` with quota and forecast parity, plus an activity-only reader. Quota comes from `agy -p "/usage" --output-format json`. A direct call to the underlying RPC was investigated and rejected: `agy` keeps its OAuth token in the OS keyring, and `v1internal:retrieveUserQuotaSummary` is gated on the CLI's own client identity — a valid bearer token alone returns 403. The slash command is answered locally from a cached backend reading, so it starts no LLM turn and consumes no quota; a 20s TTL cache absorbs the ~3s of Go-binary startup. Windows are reordered so position 0 is the session and position 1 the week, matching every other agent and keeping the `tray_*_source` defaults meaningful. Activity comes from `conversation_summaries.db` via diesel. Opened read-only through a `file:…?mode=ro` URI so a running `agy` is never blocked — not `immutable=1`, which reads the main file alone and would miss anything still in the WAL. Rows from both the CLI and the Antigravity IDE count; they share one account and one table. Antigravity publishes no token counts (its trajectories are schema-less protobuf with no plaintext model names), so `AgentKind::reports_tokens` and `UsageSnapshot::tokens_unreported` carry that fact: the Models tab is dropped entirely rather than rendered blank, and token stats read "not reported" instead of `0`. Also adds an optional `command` key on agent profiles, for an install outside PATH. --- .agent/context/glossary.md | 2 +- .agent/context/stack.md | 34 + .design/agents.md | 2 + Cargo.lock | 156 ++++- Cargo.toml | 6 + README.md | 8 +- assets/icons/antigravity.svg | 1 + crates/aura-core/Cargo.toml | 2 + crates/aura-core/src/config.rs | 60 +- crates/aura-core/src/config_schema.rs | 13 +- crates/aura-core/src/lexicon.rs | 11 + crates/aura-core/src/quota/antigravity.rs | 669 ++++++++++++++++++ crates/aura-core/src/quota/mod.rs | 2 + crates/aura-core/src/reader/antigravity.rs | 748 +++++++++++++++++++++ crates/aura-core/src/reader/claude_code.rs | 1 + crates/aura-core/src/reader/mod.rs | 15 + crates/aura-core/src/theme.rs | 8 + crates/aura/src/app.rs | 168 ++++- crates/aura/src/assets.rs | 1 + crates/aura/src/cli/quota.rs | 13 +- crates/aura/src/cli/resolve.rs | 1 + crates/aura/src/cli/usage.rs | 23 +- crates/aura/src/tray_status.rs | 37 +- docs/cli.md | 20 +- docs/configuration.md | 63 +- docs/plans/antigravity-agent.md | 435 ++++++++++++ 26 files changed, 2454 insertions(+), 45 deletions(-) create mode 100644 assets/icons/antigravity.svg create mode 100644 crates/aura-core/src/quota/antigravity.rs create mode 100644 crates/aura-core/src/reader/antigravity.rs create mode 100644 docs/plans/antigravity-agent.md diff --git a/.agent/context/glossary.md b/.agent/context/glossary.md index 816f3a2..0642a62 100644 --- a/.agent/context/glossary.md +++ b/.agent/context/glossary.md @@ -15,7 +15,7 @@ Domain terms specific to Aura. Add as you encounter unfamiliar terminology. ## Terms -**Agent** — an AI coding assistant whose usage Aura monitors. Currently: Claude Code, Codex. Future: custom command agents. +**Agent** — an AI coding assistant whose usage Aura monitors. Currently: Claude Code, Codex, Gemini, Antigravity. Future: custom command agents. **Agent profile** — a named configuration entry pointing at a specific agent kind and config path. One user can have multiple profiles for the same agent kind (e.g., personal vs. enterprise). diff --git a/.agent/context/stack.md b/.agent/context/stack.md index d03a6f7..9bd4858 100644 --- a/.agent/context/stack.md +++ b/.agent/context/stack.md @@ -47,6 +47,30 @@ Periodic rollup. Stale by months in practice (observed: last updated 2026-02-16 _No `claude usage` CLI subcommand exists._ +## Antigravity data source + +Two sources, neither of them shaped like the others. + +**Activity:** `~/.gemini/antigravity-cli/conversation_summaries.db` — SQLite, +one row per conversation (`step_count`, `last_modified_time`, +`last_user_input_time`, `app_data_dir`). Covers both the CLI and the +Antigravity IDE; Aura counts every row. Opened read-only through a +`file:…?mode=ro` URI so a running `agy` is never blocked, falling back to +`immutable=1` when the `-shm` file can't be created. + +**Quota:** `agy -p "/usage" --output-format json`, a documented public flag. +The slash command is answered locally from a cached backend reading, so it +starts no LLM turn and consumes no quota; it costs ~3 s of Go-binary startup, +which a 20 s TTL cache absorbs. The underlying RPC +(`v1internal:retrieveUserQuotaSummary`) is not usable directly: `agy` keeps +its OAuth token in the OS keyring and the call is license-gated on the CLI's +own client identity. + +_No token counts are recoverable._ The trajectories in +`conversations/.db` are schema-less protobuf with no plaintext model +names, so `UsageSnapshot::tokens_unreported` marks the token fields absent +rather than zero. + ## Plugin loading **Subprocess + JSON IPC** — Aura spawns the plugin binary and reads a JSON panel payload from stdout. Any language can author plugins. 500ms timeout; plugins that exceed it are shown in an error state. @@ -55,6 +79,16 @@ _No `claude usage` CLI subcommand exists._ `serde` + `toml` for config; `serde` + `serde_json` for state persistence and plugin IPC. +## SQLite + +`diesel` (sqlite backend, no default features) with `libsqlite3-sys/bundled`, for +Antigravity's `conversation_summaries.db`. Bundled so no host `libsqlite3` is +required on any release target. Five of the six build natively; only +`aarch64-pc-windows-msvc` cross-compiles, on an x86_64 `windows-latest` runner +that carries `Microsoft.VisualStudio.Component.VC.Tools.ARM64` — and that +target is already `experimental: true`. The workspace already compiles C +(`cc` arrives via the gpui tree), so this adds no new class of dependency. + ## Error handling `anyhow` for binary crates; `thiserror` for library crates. diff --git a/.design/agents.md b/.design/agents.md index 2c091fe..6cfa969 100644 --- a/.design/agents.md +++ b/.design/agents.md @@ -15,6 +15,7 @@ Source: `crates/aura/src/app.rs:25-27` and `app.rs:848-862`. | `AgentKind::ClaudeCode` | `#d97757` | Anthropic's published Claude orange. | `app.rs:854` | | `AgentKind::Codex` | `#ffffff` | OpenAI's pure-white mark — **needs fallback**. | `app.rs:855` | | `AgentKind::Gemini` | `#4285f4` | Google Blue — readable as-is on dark surfaces. | `app.rs:856` | +| `AgentKind::Antigravity` | `#7c5cff` | The mark is a monochrome arc with no brand color to borrow; violet keeps it distinct from the Google blue beside it. | `theme.rs:248` | ## The luminance fallback rule @@ -56,6 +57,7 @@ fn agent_accent(kind: AgentKind) -> u32 { AgentKind::ClaudeCode => COLOR_CLAUDE, // 0xd97757 AgentKind::Codex => COLOR_OPENAI, // 0xffffff AgentKind::Gemini => COLOR_GEMINI, // 0x4285f4 + AgentKind::Antigravity => COLOR_ANTIGRAVITY, // 0x7c5cff }; if relative_luminance(brand) > 0.85 { 0xb8b8c0 diff --git a/Cargo.lock b/Cargo.lock index 7c1e7e9..51eed34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,8 +516,10 @@ version = "0.1.18" dependencies = [ "anyhow", "chrono", + "diesel", "dirs 7.0.0", "keyring", + "libsqlite3-sys", "notify-debouncer-mini", "security-framework", "serde", @@ -1472,6 +1474,41 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1506,6 +1543,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diesel" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -1639,12 +1711,32 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dtor" version = "0.0.6" @@ -1957,6 +2049,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "font-types" version = "0.11.3" @@ -2644,7 +2742,16 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -2929,6 +3036,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3342,6 +3455,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4854,6 +4978,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -5427,6 +5561,18 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6536,6 +6682,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6720,7 +6872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.4", "scoped-tls", "smallvec", diff --git a/Cargo.toml b/Cargo.toml index f3675a5..c7c822e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,13 @@ anyhow = "1" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde", "unstable-locales"] } clap = { version = "4", features = ["derive", "wrap_help"] } clap_complete = "4" +# Antigravity keeps its conversation summaries in a SQLite DB +# (`~/.gemini/antigravity-cli/conversation_summaries.db`) rather than JSONL. +# `libsqlite3-sys/bundled` compiles the amalgamation in-tree so no host +# libsqlite3 is required on any of the six release targets. +diesel = { version = "2.3", default-features = false, features = ["sqlite"] } dirs = "7" +libsqlite3-sys = { version = "0.38", features = ["bundled"] } notify-debouncer-mini = "0.7" semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 8b71895..186ace2 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ running a CLI command. ## Features -- **Multi-agent support** — Claude Code, Codex, and Gemini out of the box; custom command agents on the roadmap. +- **Multi-agent support** — Claude Code, Codex, Gemini, and Antigravity out of the box; custom command agents on the roadmap. - **Agent profiles** — configure multiple instances of the same agent (e.g. personal vs. enterprise workspaces) and toggle between them; last selection is persisted across sessions. - **Plugin system** — extend Aura with custom metrics panels; anyone can author a plugin. First-party plugins (incl. RTK Gains for [RTK](https://github.com/rtk) token-savings) are installed separately. - **Single-click activation** — left-click the tray icon to open / close the modal; right-click for Show / Quit; Escape closes. @@ -198,6 +198,11 @@ kind = "codex" name = "Gemini" kind = "gemini" +[[agents]] +name = "Antigravity" +kind = "antigravity" +# command = "agy" # only when `agy` isn't on $PATH + # Optional tweaks. All keys are optional; defaults shown. # Where the window goes and how big it gets. @@ -683,6 +688,7 @@ Shipped - [x] Claude Code usage integration (`~/.claude` JSONL scan, OAuth via Keychain / Credential Manager) - [x] Codex usage integration (`~/.codex` session scan) - [x] Gemini usage integration (`~/.gemini` session scan) +- [x] Antigravity usage integration (`~/.gemini/antigravity-cli` activity; live quota via `agy -p "/usage"`) - [x] Multi-profile config + persisted selection across sessions - [x] Plugin runner (subprocess + JSON IPC); RTK Gains shipped as opt-in plugin - [x] Linux support (systemd user service · ksni StatusNotifierItem · KDE / GNOME / sway compatible) diff --git a/assets/icons/antigravity.svg b/assets/icons/antigravity.svg new file mode 100644 index 0000000..3ed10ab --- /dev/null +++ b/assets/icons/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/crates/aura-core/Cargo.toml b/crates/aura-core/Cargo.toml index 2581bb1..1bc1c00 100644 --- a/crates/aura-core/Cargo.toml +++ b/crates/aura-core/Cargo.toml @@ -7,7 +7,9 @@ license.workspace = true [dependencies] anyhow.workspace = true chrono.workspace = true +diesel.workspace = true dirs.workspace = true +libsqlite3-sys.workspace = true notify-debouncer-mini.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/aura-core/src/config.rs b/crates/aura-core/src/config.rs index 8f32a1d..c04784a 100644 --- a/crates/aura-core/src/config.rs +++ b/crates/aura-core/src/config.rs @@ -14,6 +14,29 @@ pub enum AgentKind { ClaudeCode, Codex, Gemini, + /// Google's Antigravity CLI (`agy`). Quota comes from the binary itself + /// rather than a file or an endpoint — see `quota::AntigravityQuota`. + Antigravity, +} + +impl AgentKind { + /// Whether this agent publishes token counts. + /// + /// Antigravity does not: its trajectories are schema-less protobuf with + /// no plaintext model names, so there are no per-model tokens to attribute + /// and no totals to sum. Callers use this to tell "absent" from "zero" — + /// the modal drops the Models tab entirely rather than render it empty, + /// and the token stat cards read "not reported" instead of `0`. + /// + /// Paired with [`crate::reader::UsageSnapshot::tokens_unreported`], which + /// carries the same fact on a loaded snapshot. This one is available + /// synchronously, before any read, which is what the tab row needs. + pub fn reports_tokens(self) -> bool { + match self { + Self::ClaudeCode | Self::Codex | Self::Gemini => true, + Self::Antigravity => false, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -23,6 +46,14 @@ pub struct AgentConfig { /// Path to the agent's config directory. Falls back to the agent's default /// when absent (e.g. `~/.claude` for `claude-code`). pub config_path: Option, + /// The agent's executable, for an install that isn't on `PATH`. Only + /// meaningful for agents Aura reaches by running them: today that is + /// `antigravity`, whose quota comes from `agy -p "/usage"` because the + /// CLI keeps its credentials in the OS keyring and its quota RPC is + /// gated on the CLI's own client identity. Unset means "the agent's + /// usual binary name, resolved on `PATH`". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, /// Optional override for the agent's accent color. Hex string with a /// leading `#` (3- or 6-digit). When absent, the per-kind default applies. #[serde(default)] @@ -63,6 +94,10 @@ impl AgentConfig { AgentKind::ClaudeCode => home_dir().join(".claude"), AgentKind::Codex => home_dir().join(".codex"), AgentKind::Gemini => home_dir().join(".gemini"), + // A sibling of the Gemini CLI's own subtree, not a parent of + // it: `~/.gemini/tmp//chats/` belongs to `gemini`, + // so the two agents share `~/.gemini` without colliding. + AgentKind::Antigravity => home_dir().join(".gemini").join("antigravity-cli"), }, } } @@ -643,6 +678,7 @@ pub fn known_agent_profiles() -> Vec { name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -651,6 +687,7 @@ pub fn known_agent_profiles() -> Vec { name: "Claude Code (Enterprise)".to_string(), kind: AgentKind::ClaudeCode, config_path: Some("~/.claude-enterprise".to_string()), + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -659,6 +696,7 @@ pub fn known_agent_profiles() -> Vec { name: "Codex".to_string(), kind: AgentKind::Codex, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -667,6 +705,16 @@ pub fn known_agent_profiles() -> Vec { name: "Gemini".to_string(), kind: AgentKind::Gemini, config_path: None, + command: None, + color: None, + tray_progress_source: None, + tray_color_source: None, + }, + AgentConfig { + name: "Antigravity".to_string(), + kind: AgentKind::Antigravity, + config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -838,11 +886,12 @@ dismiss_all = true // File should now exist. assert!(path.exists()); - // Should have the four default profiles. - assert_eq!(cfg.agents.len(), 4); + // Should have the five default profiles. + assert_eq!(cfg.agents.len(), 5); assert_eq!(cfg.agents[0].kind, AgentKind::ClaudeCode); assert_eq!(cfg.agents[2].kind, AgentKind::Codex); assert_eq!(cfg.agents[3].kind, AgentKind::Gemini); + assert_eq!(cfg.agents[4].kind, AgentKind::Antigravity); } #[test] @@ -852,6 +901,7 @@ dismiss_all = true name: "User-renamed Claude".to_string(), kind: AgentKind::ClaudeCode, config_path: None, // resolves to ~/.claude + command: None, color: Some("#abcdef".to_string()), tray_progress_source: None, tray_color_source: None, @@ -868,6 +918,7 @@ dismiss_all = true name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -876,6 +927,7 @@ dismiss_all = true name: "Codex".to_string(), kind: AgentKind::Codex, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -898,6 +950,7 @@ dismiss_all = true name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -913,6 +966,7 @@ dismiss_all = true name: "Claude Code (Enterprise)".to_string(), kind: AgentKind::ClaudeCode, config_path: Some("~/.claude-enterprise".to_string()), + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -1150,6 +1204,7 @@ plugin_order = ["RTK Gains"] name: "test".to_string(), kind: AgentKind::ClaudeCode, config_path: Some("~/.claude-test".to_string()), + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -1165,6 +1220,7 @@ plugin_order = ["RTK Gains"] name: "test".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, diff --git a/crates/aura-core/src/config_schema.rs b/crates/aura-core/src/config_schema.rs index e0a2718..8a48fc6 100644 --- a/crates/aura-core/src/config_schema.rs +++ b/crates/aura-core/src/config_schema.rs @@ -303,14 +303,22 @@ pub fn agent_fields() -> &'static [SectionField] { SectionField { key: "kind", type_label: "string", - allowed: &["claude-code", "codex", "gemini"], + allowed: &["claude-code", "codex", "gemini", "antigravity"], summary: "Which agent this profile reads.", }, SectionField { key: "config_path", type_label: "string?", allowed: &[], - summary: "Agent config dir; defaults to ~/.claude, ~/.codex, ~/.gemini per kind.", + summary: "Agent config dir; defaults to ~/.claude, ~/.codex, ~/.gemini, \ + ~/.gemini/antigravity-cli per kind.", + }, + SectionField { + key: "command", + type_label: "string?", + allowed: &[], + summary: "Executable for agents Aura reads by running them (antigravity). \ + Unset = the agent's usual binary name on $PATH.", }, SectionField { key: "color", @@ -915,6 +923,7 @@ mod tests { name: "Work Claude".to_string(), kind: AgentKind::ClaudeCode, config_path: Some("~/.claude-work".to_string()), + command: None, color: Some("#abcdef".to_string()), tray_progress_source: None, tray_color_source: None, diff --git a/crates/aura-core/src/lexicon.rs b/crates/aura-core/src/lexicon.rs index 090602b..95d7875 100644 --- a/crates/aura-core/src/lexicon.rs +++ b/crates/aura-core/src/lexicon.rs @@ -34,6 +34,10 @@ pub struct Lexicon { pub no_quota_data: &'static str, pub no_plugins_configured: &'static str, pub no_plugin_selected: &'static str, + /// Stat-card value for a token figure the active agent never publishes, + /// in place of a `0` that would read as "you used nothing". Set for the + /// agents whose readers flag `UsageSnapshot::tokens_unreported`. + pub tokens_not_reported: &'static str, // ── Quota row chrome ──────────────────────────────────────────────────── pub subscription_fmt: fn(sub: &str) -> String, @@ -111,6 +115,7 @@ pub const POLITE: Lexicon = Lexicon { no_quota_data: "No quota data available.", no_plugins_configured: "No plugins configured", no_plugin_selected: "No plugin selected", + tokens_not_reported: "Not reported", subscription_fmt: polite_subscription, resets_fmt: polite_resets, @@ -147,6 +152,7 @@ pub const GOBLIN: Lexicon = Lexicon { no_quota_data: "Nothing. Empty. Dry.", no_plugins_configured: "No hangers-on", no_plugin_selected: "Pick one, coward", + tokens_not_reported: "Won't say", subscription_fmt: goblin_subscription, resets_fmt: goblin_resets, @@ -223,6 +229,11 @@ mod tests { POLITE.no_plugin_selected, GOBLIN.no_plugin_selected, ), + ( + "tokens_not_reported", + POLITE.tokens_not_reported, + GOBLIN.tokens_not_reported, + ), ( "menu_open_config", POLITE.menu_open_config, diff --git a/crates/aura-core/src/quota/antigravity.rs b/crates/aura-core/src/quota/antigravity.rs new file mode 100644 index 0000000..c757712 --- /dev/null +++ b/crates/aura-core/src/quota/antigravity.rs @@ -0,0 +1,669 @@ +//! Antigravity quota windows, read out of `agy -p "/usage" --output-format json`. +//! +//! Unlike Claude Code and Codex, Antigravity is not reachable by reading a +//! token off disk and calling an endpoint: `agy` keeps its OAuth credentials +//! in the OS keyring, and the quota RPC +//! (`POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary`) +//! is gated on the CLI's own client identity — a valid bearer token alone +//! comes back `403 You do not have a valid license of this product.` So the +//! supported path is the CLI's own documented `--output-format json` flag. +//! +//! Measured on `agy` 1.2.4: the slash command is answered locally from a +//! cached backend reading, so it costs no tokens and starts no LLM turn +//! (`num_turns: 0`). It takes ~3 s, dominated by the 207 MB Go binary's +//! startup, which is why the snapshot is cached for [`CACHE_TTL`]. +//! +//! The numbers are the backend's, the same ones the Antigravity IDE shows, so +//! the snapshot is [`QuotaSource::Api`]. Any spawn, timeout, non-`SUCCESS` +//! status or parse failure sets `api_failed`, which is what makes +//! `tray_status::summarize_sticky` hold the previous reading instead of +//! degrading the tray icon. + +use std::{ + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::bin_path::{augmented_path, resolve_executable_in, search_summary}; + +use super::{QuotaSnapshot, QuotaSource, QuotaWindow}; + +/// Hard ceiling on the subprocess. `agy` normally answers in ~3 s; anything +/// past this is a hung binary, not a slow one. +const SPAWN_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long a reading stays fresh. The tray poll floor is 30 s and the modal +/// refreshes on open, so without this a modal opened right after a tray tick +/// would re-spawn the binary for a number it already has. +const CACHE_TTL: Duration = Duration::from_secs(20); + +/// Default binary name, used when the agent profile sets no `command`. +const DEFAULT_COMMAND: &str = "agy"; + +/// Where Antigravity's own installer puts the binary, for cases where it +/// won't be on Aura's `PATH`. +/// +/// On macOS and Linux that is `~/.local/bin`, which `bin_path` already +/// searches — and which the installer only adds to `PATH` by appending to the +/// user's *shell profile*, a file no GUI launcher or launchd agent ever reads. +/// +/// Windows is the gap: the installer uses `%LOCALAPPDATA%\agy\bin`, which no +/// generic bin-directory list would guess. It does register that in the user +/// `PATH`, but a process started before the install — or a user who passed +/// `--skip-path` — won't see it. +fn agy_install_dirs() -> Vec { + #[cfg(target_os = "windows")] + { + std::env::var_os("LOCALAPPDATA") + .map(|base| PathBuf::from(base).join("agy").join("bin")) + .into_iter() + .collect() + } + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} + +// ── `/usage` JSON ──────────────────────────────────────────────────────────── + +/// Top level of `agy -p "/usage" --output-format json`. +/// +/// Deliberately partial: `agy` also emits `conversation_id`, `response`, +/// `num_turns`, `usage` and `duration_seconds`, none of which we need. Unknown +/// fields are ignored so a future `agy` release adding keys doesn't break the +/// parse. +#[derive(Debug, Deserialize)] +struct UsageResponse { + #[serde(default)] + status: String, + #[serde(default)] + command: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageCommand { + #[serde(default)] + data: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageData { + #[serde(default)] + groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct UsageGroup { + #[serde(default)] + name: String, + #[serde(default)] + buckets: Vec, +} + +#[derive(Debug, Deserialize)] +struct UsageBucket { + /// `"gemini-weekly"`, `"gemini-5h"`, `"3p-weekly"`, `"3p-5h"`. + #[serde(default)] + id: String, + /// `"weekly"` or `"5h"`. + #[serde(default)] + window: String, + /// Fraction of the limit still available, 1.0 = untouched. + #[serde(default)] + remaining_fraction: f64, + #[serde(default)] + reset_time: Option, +} + +// ── Window naming and ordering ─────────────────────────────────────────────── + +/// Short label for a group, so the window reads `"Gemini · 5h"` rather than +/// repeating `agy`'s full `"Claude and GPT models"` in a narrow tray tooltip. +fn group_short_name(group: &str) -> &str { + match group { + "Gemini Models" => "Gemini", + "Claude and GPT models" => "Claude/GPT", + other => other, + } +} + +/// Human suffix for a bucket's window. +fn window_suffix(window: &str) -> &str { + match window { + "5h" => "5h", + "weekly" => "week", + other => other, + } +} + +/// Total length of a window, in minutes. Drives the Forecast tab, which +/// derives `started_at` from `resets_at - length`. +fn window_length_minutes(window: &str) -> Option { + match window { + "5h" => Some(300), + "weekly" => Some(7 * 24 * 60), + _ => None, + } +} + +/// Sort key that puts the windows in Aura's repo-wide order — session first, +/// week second — rather than the order `/usage` happens to print (which leads +/// with weekly). Position 0 and position 1 are what the `tray_progress_source` +/// / `tray_color_source` defaults read, so they have to mean the same thing on +/// this agent as on every other. +/// +/// Gemini's own models come before the third-party group because that is the +/// limit an `agy` user burns by default. +fn bucket_rank(bucket: &UsageBucket) -> u8 { + match bucket.id.as_str() { + "gemini-5h" => 0, + "gemini-weekly" => 1, + "3p-5h" => 2, + "3p-weekly" => 3, + // Unknown bucket from a future release: keep it, but after the four + // we know, and still session-before-week within its group. + _ => match bucket.window.as_str() { + "5h" => 4, + _ => 5, + }, + } +} + +// ── Public source ──────────────────────────────────────────────────────────── + +pub struct AntigravityQuota { + /// The `agy` executable as configured — the profile's `command`, or the + /// bare default. Resolved to an absolute path at snapshot time by + /// [`crate::bin_path::resolve_executable`]. + command: String, + /// The agent's config dir (`~/.gemini/antigravity-cli`). Only used to pick + /// a working directory for the subprocess. + data_dir: PathBuf, +} + +impl AntigravityQuota { + pub fn new(data_dir: PathBuf, command: Option<&str>) -> Self { + Self { + command: command.unwrap_or(DEFAULT_COMMAND).to_string(), + data_dir, + } + } + + /// Current quota windows. Never `Err` — every failure becomes an + /// `Unavailable` snapshot carrying an actionable note. + pub fn snapshot(&self) -> QuotaSnapshot { + if let Some(cached) = cache_get(&self.command) { + return cached; + } + let snap = self.snapshot_uncached(); + cache_put(&self.command, &snap); + snap + } + + fn snapshot_uncached(&self) -> QuotaSnapshot { + let stdout = match self.run_usage() { + Ok(out) => out, + Err(e) => return failed(e), + }; + parse_usage(&stdout) + } + + /// Spawn `agy -p "/usage" --output-format json` and return its stdout. + fn run_usage(&self) -> Result { + // Resolve to an absolute path before spawning rather than letting + // `Command` search. Aura usually runs from a GUI launcher or a systemd + // user unit, whose `PATH` routinely omits `~/.local/bin` — where `agy` + // installs itself — so a bare name that resolves fine in a terminal + // fails here. See `crate::bin_path`. + let extra = agy_install_dirs(); + let Some(exe) = resolve_executable_in(&self.command, &extra) else { + return Err(format!( + "`{}` not found in {} — set `command` on this agent to its full path.", + self.command, + search_summary(&extra) + )); + }; + + // Run from the data dir's parent (`~/.gemini`) rather than whatever + // directory Aura was launched in: `agy` asks for workspace trust the + // first time it sees a new project root, and a tray poll must never + // sit on a prompt. + let cwd = self + .data_dir + .parent() + .filter(|p| p.is_dir()) + .map(Path::to_path_buf); + + let mut cmd = Command::new(&exe); + cmd.args(["-p", "/usage", "--output-format", "json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + // `agy` shells out to its own helpers; hand it the same widened + // `PATH` plugins get rather than our GUI-inherited one. + .env("PATH", augmented_path()); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + // `aura` is built with `windows_subsystem = "windows"`, so Windows + // opens a console window for every console-subsystem child it spawns. + // `agy` is one, and this runs on every tray poll — without this the + // user gets a CMD window flashing at them every 30 seconds. Same + // treatment the plugin runner already applies. + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("could not start `{}`: {e}", exe.display()))?; + + // `wait_timeout` would need another dependency for one call site, so + // poll `try_wait` instead. stdout is drained on a helper thread so a + // response larger than the pipe buffer can't deadlock the wait. + let mut pipe = child.stdout.take().expect("stdout is piped"); + let reader = std::thread::spawn(move || { + let mut buf = String::new(); + pipe.read_to_string(&mut buf).map(|_| buf) + }); + + let deadline = Instant::now() + SPAWN_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "`{}` timed out after {}s", + exe.display(), + SPAWN_TIMEOUT.as_secs() + )); + } + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(e) => return Err(format!("waiting on `{}`: {e}", exe.display())), + } + }; + + let stdout = reader + .join() + .map_err(|_| "stdout reader panicked".to_string())? + .map_err(|e| format!("reading `{}` output: {e}", exe.display()))?; + + if !status.success() { + // `agy` prints its own diagnosis to the log, not stdout, so the + // exit code is all we can report. Not being logged in is the + // common case ("error getting token source: You are not logged + // into Antigravity."). + return Err(format!( + "`{}` exited with {status} — run `{} /usage` to check you are logged in", + exe.display(), + exe.display() + )); + } + + Ok(stdout) + } +} + +/// Turn `agy`'s stdout into a snapshot. +fn parse_usage(stdout: &str) -> QuotaSnapshot { + // `agy` writes a single JSON object, but a stray banner line ahead of it + // would be a cheap thing for a future release to add — find the object + // rather than assuming it starts at byte 0. + let json = match stdout.find('{') { + Some(start) => &stdout[start..], + None => return failed("`agy /usage` printed no JSON"), + }; + + let parsed: UsageResponse = match serde_json::from_str(json) { + Ok(p) => p, + Err(e) => return failed(format!("could not parse `agy /usage` output: {e}")), + }; + + if !parsed.status.eq_ignore_ascii_case("SUCCESS") { + return failed(format!( + "`agy /usage` returned status `{}` — check you are logged into Antigravity", + parsed.status + )); + } + + let Some(data) = parsed.command.and_then(|c| c.data) else { + return failed( + "`agy /usage` returned no quota data — the response shape may have \ + changed in this `agy` release", + ); + }; + + // (rank, window) so the sort below is stable across groups. + let mut ranked: Vec<(u8, QuotaWindow)> = Vec::new(); + for group in &data.groups { + let short = group_short_name(&group.name); + for bucket in &group.buckets { + ranked.push((bucket_rank(bucket), to_window(short, bucket))); + } + } + ranked.sort_by_key(|(rank, _)| *rank); + let windows: Vec = ranked.into_iter().map(|(_, w)| w).collect(); + + if windows.is_empty() { + return failed("`agy /usage` reported no quota windows"); + } + + QuotaSnapshot { + // `/usage` doesn't name the plan, only what's left of it. + subscription_type: None, + windows, + source: QuotaSource::Api, + note: None, + api_failed: false, + } +} + +fn to_window(group_short: &str, bucket: &UsageBucket) -> QuotaWindow { + QuotaWindow { + label: format!("{group_short} · {}", window_suffix(&bucket.window)), + // Antigravity reports what's *left*; every other agent reports what's + // been used, and so does the whole UI downstream. + used_percentage: Some(((1.0 - bucket.remaining_fraction) * 100.0).clamp(0.0, 100.0)), + // Quota is consumed cost-weighted, not per token — there is no token + // count behind these fractions to report. + used_tokens: None, + resets_at: bucket.reset_time.as_deref().and_then(parse_reset_time), + length_minutes: window_length_minutes(&bucket.window), + } +} + +fn parse_reset_time(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +/// An unavailable snapshot that also trips `api_failed`, so the tray keeps its +/// last good reading rather than blanking on a transient `agy` hiccup. +fn failed(reason: impl Into) -> QuotaSnapshot { + QuotaSnapshot { + api_failed: true, + ..QuotaSnapshot::unavailable(reason) + } +} + +// ── TTL cache ──────────────────────────────────────────────────────────────── + +/// Keyed by the configured command so two profiles pointing at different +/// `agy` installs don't read each other's numbers. +type Cache = Mutex>; + +fn cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Vec::new())) +} + +fn cache_get(command: &str) -> Option { + let guard = cache().lock().ok()?; + guard.iter().find_map(|(cmd, at, snap)| { + (cmd == command && at.elapsed() < CACHE_TTL).then(|| snap.clone()) + }) +} + +fn cache_put(command: &str, snap: &QuotaSnapshot) { + let Ok(mut guard) = cache().lock() else { + return; + }; + let now = Instant::now(); + match guard.iter_mut().find(|(cmd, _, _)| cmd == command) { + Some(entry) => *entry = (command.to_string(), now, snap.clone()), + None => guard.push((command.to_string(), now, snap.clone())), + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Trimmed from a live `agy` 1.2.4 run. Note the group order: `/usage` + /// prints weekly before 5h, which is the order we deliberately undo. + const HEALTHY: &str = r#"{ + "conversation_id": "", + "status": "SUCCESS", + "num_turns": 0, + "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }, + "command": { + "name": "usage", + "data": { + "description": "Within each group, models share a weekly limit and a 5-hour limit.", + "groups": [ + { + "name": "Gemini Models", + "description": "Models within this group: Gemini Flash, Gemini Pro", + "buckets": [ + { "id": "gemini-weekly", "name": "Weekly Limit Remaining", "window": "weekly", + "remaining_fraction": 0.75, "reset_time": "2026-09-23T22:57:05Z" }, + { "id": "gemini-5h", "name": "Five Hour Limit Remaining", "window": "5h", + "remaining_fraction": 0.9, "reset_time": "2026-09-17T03:57:05Z" } + ] + }, + { + "name": "Claude and GPT models", + "description": "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS", + "buckets": [ + { "id": "3p-weekly", "name": "Weekly Limit Remaining", "window": "weekly", + "remaining_fraction": 1, "reset_time": "2026-09-24T01:05:28Z" }, + { "id": "3p-5h", "name": "Five Hour Limit Remaining", "window": "5h", + "remaining_fraction": 0.5, "reset_time": "2026-09-17T06:05:28Z" } + ] + } + ] + } + } + }"#; + + #[test] + fn healthy_response_parses_as_api_source() { + let snap = parse_usage(HEALTHY); + assert_eq!(snap.source, QuotaSource::Api); + assert!(!snap.api_failed); + assert!(snap.note.is_none()); + assert!(snap.subscription_type.is_none()); + assert_eq!(snap.windows.len(), 4); + } + + #[test] + fn windows_are_reordered_session_before_week() { + let snap = parse_usage(HEALTHY); + let labels: Vec<&str> = snap.windows.iter().map(|w| w.label.as_str()).collect(); + assert_eq!( + labels, + [ + "Gemini · 5h", + "Gemini · week", + "Claude/GPT · 5h", + "Claude/GPT · week" + ] + ); + } + + #[track_caller] + fn assert_pct(actual: Option, expected: f64) { + let actual = actual.expect("window reports a percentage"); + assert!( + (actual - expected).abs() < 1e-9, + "expected ~{expected}%, got {actual}%", + ); + } + + #[test] + fn remaining_fraction_is_inverted_into_used_percentage() { + let snap = parse_usage(HEALTHY); + // remaining 0.9 → used 10%; remaining 0.75 → used 25%. + assert_pct(snap.windows[0].used_percentage, 10.0); + assert_pct(snap.windows[1].used_percentage, 25.0); + // remaining 1.0 → used 0%, not a missing reading. + assert_pct(snap.windows[3].used_percentage, 0.0); + } + + #[test] + fn windows_carry_length_so_forecast_can_project() { + let snap = parse_usage(HEALTHY); + assert_eq!(snap.windows[0].length_minutes, Some(300)); + assert_eq!(snap.windows[1].length_minutes, Some(10_080)); + // Cost-weighted fractions, not tokens. + assert!(snap.windows.iter().all(|w| w.used_tokens.is_none())); + } + + #[test] + fn reset_times_are_parsed() { + let snap = parse_usage(HEALTHY); + assert_eq!( + snap.windows[0].resets_at.map(|t| t.to_rfc3339()), + Some("2026-09-17T03:57:05+00:00".to_string()) + ); + } + + #[test] + fn forecast_projects_every_window() { + let snap = parse_usage(HEALTHY); + // A window with no `length_minutes` is silently dropped by the + // Forecast tab — assert all four survive. + let reset = snap.windows[0].resets_at.unwrap(); + let fc = crate::quota::forecast::forecast(&snap, reset - chrono::Duration::hours(1)); + assert_eq!(fc.windows.len(), 4); + } + + #[test] + fn not_logged_in_fails_without_degrading_the_tray() { + // `agy` answers a logged-out `/usage` with a non-SUCCESS status. + let out = r#"{"conversation_id":"","status":"ERROR","response":"error getting token source: You are not logged into Antigravity.","num_turns":0}"#; + let snap = parse_usage(out); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap.api_failed, "tray must hold its last good reading"); + assert!(snap.note.unwrap().contains("logged into Antigravity")); + assert!(snap.windows.is_empty()); + } + + #[test] + fn malformed_json_is_reported_not_panicked() { + let snap = parse_usage("{not json at all"); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap.api_failed); + assert!(snap.note.unwrap().contains("could not parse")); + } + + #[test] + fn no_json_at_all_is_reported() { + let snap = parse_usage("agy: command panicked\n"); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap.note.unwrap().contains("printed no JSON")); + } + + #[test] + fn missing_command_data_names_the_shape_change() { + let out = r#"{"status":"SUCCESS","command":{"name":"usage"}}"#; + let snap = parse_usage(out); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap + .note + .unwrap() + .contains("response shape may have changed")); + } + + #[test] + fn empty_groups_are_reported_rather_than_shown_as_zero_usage() { + let out = r#"{"status":"SUCCESS","command":{"name":"usage","data":{"groups":[]}}}"#; + let snap = parse_usage(out); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap.note.unwrap().contains("no quota windows")); + } + + #[test] + fn unknown_fields_and_unknown_buckets_survive() { + // A future `agy` adds a group and a key we've never seen. + let out = r#"{ + "status": "SUCCESS", + "brand_new_top_level_key": 42, + "command": { "name": "usage", "data": { "groups": [ + { "name": "Imagen Models", "buckets": [ + { "id": "imagen-5h", "window": "5h", "remaining_fraction": 0.2, + "reset_time": "2026-09-17T06:05:28Z", "brand_new_bucket_key": true } + ]} + ]}} + }"#; + let snap = parse_usage(out); + assert_eq!(snap.source, QuotaSource::Api); + assert_eq!(snap.windows.len(), 1); + // Unrecognised group name passes through verbatim. + assert_eq!(snap.windows[0].label, "Imagen Models · 5h"); + assert_pct(snap.windows[0].used_percentage, 80.0); + } + + #[test] + fn unknown_buckets_sort_after_the_known_four() { + let out = r#"{ + "status": "SUCCESS", + "command": { "name": "usage", "data": { "groups": [ + { "name": "Imagen Models", "buckets": [ + { "id": "imagen-weekly", "window": "weekly", "remaining_fraction": 1 }, + { "id": "imagen-5h", "window": "5h", "remaining_fraction": 1 } + ]}, + { "name": "Gemini Models", "buckets": [ + { "id": "gemini-weekly", "window": "weekly", "remaining_fraction": 1 }, + { "id": "gemini-5h", "window": "5h", "remaining_fraction": 1 } + ]} + ]}} + }"#; + let snap = parse_usage(out); + let labels: Vec<&str> = snap.windows.iter().map(|w| w.label.as_str()).collect(); + // Positions 0 and 1 still mean session and week on the group the + // tray defaults read. + assert_eq!( + labels, + [ + "Gemini · 5h", + "Gemini · week", + "Imagen Models · 5h", + "Imagen Models · week" + ] + ); + } + + #[test] + fn banner_before_the_json_is_skipped() { + let out = format!("Updating to agy 1.2.5...\n{HEALTHY}"); + let snap = parse_usage(&out); + assert_eq!(snap.source, QuotaSource::Api); + assert_eq!(snap.windows.len(), 4); + } + + #[test] + fn missing_binary_is_reported_without_panicking() { + // Spawning is reached only through `command`, so a path that cannot + // exist exercises the failure branch without invoking the real `agy`. + let q = AntigravityQuota::new( + std::env::temp_dir(), + Some("aura-test-no-such-binary-0e1f2a3b"), + ); + let snap = q.snapshot(); + assert_eq!(snap.source, QuotaSource::Unavailable); + assert!(snap.api_failed); + let note = snap.note.unwrap(); + assert!(note.contains("not found"), "{note}"); + // The note has to say where we looked and what to do — "not found on + // PATH" is not actionable when the failure is that our PATH is the + // wrong one. + assert!(note.contains("not found in"), "{note}"); + assert!(note.contains("`command`"), "{note}"); + } +} diff --git a/crates/aura-core/src/quota/mod.rs b/crates/aura-core/src/quota/mod.rs index b22ac50..b2e8bf1 100644 --- a/crates/aura-core/src/quota/mod.rs +++ b/crates/aura-core/src/quota/mod.rs @@ -5,6 +5,7 @@ //! then call `https://api.anthropic.com/api/oauth/usage`. If any of that //! fails, callers can fall back to local counts derived from JSONL data. +mod antigravity; mod api; mod codex; mod codex_oauth; @@ -12,6 +13,7 @@ pub mod forecast; mod gemini; mod oauth; +pub use antigravity::AntigravityQuota; pub use api::{QuotaApi, QuotaSource}; pub use codex::CodexQuota; pub use forecast::{forecast, ForecastSnapshot, ForecastStatus, ForecastWindow}; diff --git a/crates/aura-core/src/reader/antigravity.rs b/crates/aura-core/src/reader/antigravity.rs new file mode 100644 index 0000000..44006bd --- /dev/null +++ b/crates/aura-core/src/reader/antigravity.rs @@ -0,0 +1,748 @@ +//! Reader for Google's Antigravity CLI (`agy`, `~/.gemini/antigravity-cli/`). +//! +//! Activity only. Antigravity keeps its trajectories in +//! `conversations/.db` as schema-less protobuf blobs with no plaintext +//! model names and no field names, so per-model attribution and token counts +//! are not recoverable without guessing undocumented field numbers. What *is* +//! readable is `conversation_summaries.db`: one row per conversation, with a +//! step count and timestamps. That gives sessions, messages, active days, +//! streaks and peak hour; [`UsageSnapshot::tokens_unreported`] tells the UI to +//! show "not reported" rather than a misleading `0` for the rest. +//! +//! The DB covers both the CLI and the Antigravity IDE — they share one account +//! and one summaries table, distinguished by `app_data_dir` (`antigravity-cli` +//! vs `antigravity`). Every row counts: the alternative scopes a user's whole +//! Antigravity history down to whatever they have typed into `agy` since +//! installing it. (Reading the IDE's *trajectories* — the flat-protobuf +//! `~/.gemini/antigravity/conversations/.pb` files — remains out of +//! scope; these are its summary rows, which `agy` itself writes and reads.) + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use diesel::prelude::*; + +use crate::config::AgentKind; + +use super::{ + claude_code::build_snapshot, + dates::{date_from_timestamp, hour_from_timestamp, n_days_ago, today}, + scan::{ScanAccum, SessionStat}, + AgentReader, Period, UsageSnapshot, +}; + +/// The summaries DB, relative to the agent's config dir. +const SUMMARIES_DB: &str = "conversation_summaries.db"; + +/// `agy` writes Go's zero `time.Time` when a conversation has no recorded user +/// input — every row created before it started tracking that column, and every +/// row imported from the IDE. Detected by the year rather than the full string +/// so a differently-formatted zero value is still caught. +const ZERO_TIME_PREFIX: &str = "0001-01-01"; + +// ── Schema ─────────────────────────────────────────────────────────────────── + +diesel::table! { + /// Mirrors what `agy` 1.2.4's gorm model creates. Only the columns Aura + /// reads are declared — the table also carries `title`, `preview`, + /// `workspace_uris`, `raw_summary` and a dozen more we have no use for. + /// + /// Both timestamps are `Text`, not `Timestamp`: gorm writes them as + /// `2026-09-16 22:57:06.976586196+00:00`, and diesel's chrono + /// deserializer expects `%Y-%m-%d %H:%M:%S%.f` with no UTC offset. They + /// are normalised in [`to_rfc3339`] instead. + conversation_summaries (conversation_id) { + conversation_id -> Text, + step_count -> BigInt, + last_modified_time -> Text, + last_user_input_time -> Text, + app_data_dir -> Text, + } +} + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = conversation_summaries)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +struct SummaryRow { + #[allow(dead_code)] // selected as the primary key; not used in the rollup + conversation_id: String, + /// Turns in the conversation — what Aura counts as messages. + step_count: i64, + last_modified_time: String, + last_user_input_time: String, + #[allow(dead_code)] // `antigravity-cli` vs `antigravity`; every row counts + app_data_dir: String, +} + +impl SummaryRow { + /// When this conversation was last touched, as RFC 3339. + /// + /// Prefers the last user input, which is what "a session happened" means + /// to the rest of Aura, and falls back to the last modification for the + /// rows that carry Go's zero time. + fn activity_timestamp(&self) -> Option { + if !self.last_user_input_time.starts_with(ZERO_TIME_PREFIX) { + if let Some(ts) = to_rfc3339(&self.last_user_input_time) { + return Some(ts); + } + } + to_rfc3339(&self.last_modified_time) + } +} + +/// Turn gorm's `2026-09-16 22:57:06.976586196+00:00` into the RFC 3339 form +/// `dates::hour_from_timestamp` can parse. A value already using `T` passes +/// through untouched. +fn to_rfc3339(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + Some(match raw.find(' ') { + Some(i) => format!("{}T{}", &raw[..i], &raw[i + 1..]), + None => raw.to_string(), + }) +} + +// ── Connection ─────────────────────────────────────────────────────────────── + +/// Build a SQLite URI for `path` with the given query string. +/// +/// Verified against SQLite's own `sqlite3ParseUri`: while parsing the filename +/// portion it treats exactly three bytes specially — `%` (escape), `?` (start +/// of query) and `#` (end of URI). `&` and `=` are literal there, so they need +/// no encoding. Windows separators are flipped to `/`; the parser has no +/// drive-letter special case, so `file:C:/dir/x.db` yields the filename +/// `C:/dir/x.db` unchanged, which is what the Windows VFS wants. +/// +/// The one trap is that the authority check (`zUri[5]=='/' && zUri[6]=='/'`) +/// runs on the *raw* string, before any percent-decoding. A UNC path such as +/// `\\server\share\x.db` normalises to `//server/share/x.db` and would be +/// rejected as `invalid uri authority: server`. Encoding the second slash +/// sidesteps that check and decodes back to the same path. +fn sqlite_uri(path: &Path, query: &str) -> String { + let mut encoded = String::new(); + for ch in path.to_string_lossy().chars() { + match ch { + '?' => encoded.push_str("%3f"), + '#' => encoded.push_str("%23"), + '%' => encoded.push_str("%25"), + '\\' if cfg!(windows) => encoded.push('/'), + other => encoded.push(other), + } + } + if let Some(rest) = encoded.strip_prefix("//") { + return format!("file:/%2f{rest}?{query}"); + } + format!("file:{encoded}?{query}") +} + +/// Open the summaries DB without disturbing a running `agy`. +/// +/// `mode=ro` is tried first. The DB is in WAL mode, so a read-only connection +/// still consults the `-wal` file and therefore sees commits `agy` has made +/// but not yet checkpointed. That needs the `-shm` file, which SQLite creates +/// in the same directory; when it cannot (a read-only directory, a filesystem +/// with no shared-memory support), the open fails and `immutable=1` is tried +/// instead. That second form reads the main DB file alone: never blocked, +/// never blocking, but blind to anything still sitting in the WAL — a +/// deliberate last resort rather than the default. +fn connect(db_path: &Path) -> Result { + let ro = sqlite_uri(db_path, "mode=ro"); + match SqliteConnection::establish(&ro) { + Ok(conn) => Ok(conn), + Err(first) => { + let immutable = sqlite_uri(db_path, "mode=ro&immutable=1"); + SqliteConnection::establish(&immutable).map_err(|second| { + anyhow::anyhow!( + "open {} read-only: {first}; retry with immutable=1: {second}", + db_path.display() + ) + }) + } + } +} + +// ── AntigravityReader ──────────────────────────────────────────────────────── + +pub struct AntigravityReader { + /// Path to the Antigravity CLI data directory — the folder holding + /// `conversation_summaries.db`. + pub config_path: PathBuf, +} + +impl AntigravityReader { + pub fn new(config_path: PathBuf) -> Self { + Self { config_path } + } + + fn db_path(&self) -> PathBuf { + self.config_path.join(SUMMARIES_DB) + } + + fn load_rows(&self) -> Result> { + use self::conversation_summaries::dsl::conversation_summaries as summaries; + + let mut conn = connect(&self.db_path())?; + Ok(summaries.select(SummaryRow::as_select()).load(&mut conn)?) + } +} + +impl AgentReader for AntigravityReader { + fn snapshot(&self, period: Period) -> Result { + // No DB yet means `agy` has never run here. An empty snapshot is the + // honest answer, and matches how the other readers treat a missing + // sessions directory. + if !self.db_path().is_file() { + return Ok(UsageSnapshot { + tokens_unreported: !AgentKind::Antigravity.reports_tokens(), + ..Default::default() + }); + } + + let (from, to) = match period { + Period::Last7Days => (Some(n_days_ago(6)), Some(today())), + Period::Last30Days => (Some(n_days_ago(29)), Some(today())), + Period::AllTime => (None, None), + }; + + Ok(build_activity_snapshot( + self.load_rows()?, + from.as_deref(), + to.as_deref(), + )) + } +} + +/// Roll a set of summary rows up into a snapshot, keeping only rows whose +/// activity date falls inside `[from, to]` (inclusive, "YYYY-MM-DD"). +fn build_activity_snapshot( + rows: Vec, + from: Option<&str>, + to: Option<&str>, +) -> UsageSnapshot { + let mut accum = ScanAccum::default(); + + for row in rows { + let Some(ts) = row.activity_timestamp() else { + continue; + }; + let Some(date) = date_from_timestamp(&ts) else { + continue; + }; + if from.is_some_and(|f| date.as_str() < f) || to.is_some_and(|t| date.as_str() > t) { + continue; + } + + let messages = row.step_count.max(0) as u64; + accum.total_messages += messages; + *accum.daily_message_counts.entry(date.clone()).or_insert(0) += messages; + *accum.daily_session_counts.entry(date).or_insert(0) += 1; + if let Some(hour) = hour_from_timestamp(&ts) { + *accum.hour_counts.entry(hour).or_insert(0) += 1; + } + accum.sessions.push(SessionStat { + // Summaries record when a conversation was last touched, not how + // long it ran — see `longest_session_secs` below. + duration_secs: 0, + message_count: messages, + start_timestamp: ts, + }); + } + + let mut snap = build_snapshot(accum, None); + // `build_snapshot` reads this off the max session duration, which is + // uniformly 0 here. Antigravity doesn't publish session durations, and + // "0s" reads as a measurement rather than an absence. + snap.longest_session_secs = None; + snap.tokens_unreported = !AgentKind::Antigravity.reports_tokens(); + snap +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + /// The exact `CREATE TABLE` gorm emits in `agy` 1.2.4, so the diesel + /// schema above is checked against the real column set and types. + const CREATE: &str = r#"CREATE TABLE `conversation_summaries` ( + `conversation_id` text, + `title` text NOT NULL DEFAULT "", + `preview` text NOT NULL DEFAULT "", + `step_count` integer NOT NULL DEFAULT 0, + `last_modified_time` datetime NOT NULL, + `workspace_uris` text NOT NULL, + `status` text NOT NULL DEFAULT "", + `source` text NOT NULL DEFAULT "", + `project_id` text NOT NULL DEFAULT "", + `agent_name` text NOT NULL DEFAULT "", + `parent_conversation_id` text NOT NULL DEFAULT "", + `nesting_depth` integer NOT NULL DEFAULT 0, + `battle_id` text NOT NULL DEFAULT "", + `winning_conversation_id` text NOT NULL DEFAULT "", + `not_fully_idle` numeric NOT NULL DEFAULT false, + `killed` numeric NOT NULL DEFAULT false, + `last_user_input_time` datetime NOT NULL, + `last_user_input_step_index` integer NOT NULL DEFAULT -1, + `app_data_dir` text NOT NULL DEFAULT "", + `raw_summary` blob, + `group_id` text NOT NULL DEFAULT "", + PRIMARY KEY (`conversation_id`))"#; + + /// Build a summaries DB at `dir/conversation_summaries.db`. + /// Each row is `(id, step_count, last_modified, last_user_input, app_data_dir)`. + fn seed(dir: &Path, rows: &[(&str, i64, &str, &str, &str)]) -> PathBuf { + let path = dir.join(SUMMARIES_DB); + let mut conn = SqliteConnection::establish(path.to_str().unwrap()).unwrap(); + diesel::sql_query(CREATE).execute(&mut conn).unwrap(); + for (id, steps, modified, input, app) in rows { + diesel::sql_query( + "INSERT INTO conversation_summaries \ + (conversation_id, title, preview, step_count, last_modified_time, \ + workspace_uris, last_user_input_time, app_data_dir) \ + VALUES (?, '', '', ?, ?, '', ?, ?)", + ) + .bind::(*id) + .bind::(*steps) + .bind::(*modified) + .bind::(*input) + .bind::(*app) + .execute(&mut conn) + .unwrap(); + } + path + } + + /// gorm's wire format for a UTC instant. + fn gorm(day: &str, time: &str) -> String { + format!("{day} {time}.123456789+00:00") + } + + const ZERO: &str = "0001-01-01 00:00:00+00:00"; + + #[test] + fn reads_sessions_and_messages_from_the_summaries_db() { + let dir = tempdir().unwrap(); + seed( + dir.path(), + &[ + ( + "a", + 2, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + ), + ( + "b", + 137, + &gorm("2026-03-02", "11:00:00"), + &gorm("2026-03-02", "11:00:00"), + "antigravity-cli", + ), + ], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + assert_eq!(snap.total_sessions, 2); + assert_eq!(snap.total_messages, 139); + assert_eq!(snap.active_days, 2); + assert_eq!(snap.first_session_date.as_deref(), Some("2026-03-01")); + assert_eq!(snap.last_session_date.as_deref(), Some("2026-03-02")); + assert_eq!(snap.total_days, 2); + } + + #[test] + fn token_fields_stay_empty_and_are_flagged_unreported() { + let dir = tempdir().unwrap(); + seed( + dir.path(), + &[( + "a", + 9, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + )], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + assert!( + snap.tokens_unreported, + "the UI must distinguish this from a genuine zero" + ); + assert_eq!(snap.total_tokens, 0); + assert!(snap.per_model.is_empty()); + assert!(snap.favorite_model.is_none()); + assert!(snap.daily_tokens.is_empty()); + // No session durations in the summaries — not a zero-length session. + assert!(snap.longest_session_secs.is_none()); + } + + #[test] + fn zero_user_input_time_falls_back_to_last_modified() { + let dir = tempdir().unwrap(); + // The shape every IDE-sourced row has: a real `last_modified_time` + // and Go's zero `time.Time` for `last_user_input_time`. + seed( + dir.path(), + &[( + "ide", + 137, + &gorm("2026-03-29", "01:47:15"), + ZERO, + "antigravity", + )], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + assert_eq!(snap.total_sessions, 1); + assert_eq!(snap.first_session_date.as_deref(), Some("2026-03-29")); + } + + #[test] + fn ide_rows_are_counted_alongside_cli_rows() { + let dir = tempdir().unwrap(); + seed( + dir.path(), + &[ + ( + "cli", + 2, + &gorm("2026-03-02", "10:00:00"), + &gorm("2026-03-02", "10:00:00"), + "antigravity-cli", + ), + ( + "ide", + 40, + &gorm("2026-03-01", "10:00:00"), + ZERO, + "antigravity", + ), + ], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + assert_eq!(snap.total_sessions, 2); + assert_eq!(snap.total_messages, 42); + } + + #[test] + fn period_filtering_drops_rows_outside_the_window() { + let dir = tempdir().unwrap(); + let recent = n_days_ago(1); + let stale = "2025-01-01"; + seed( + dir.path(), + &[ + ( + "recent", + 3, + &gorm(&recent, "10:00:00"), + &gorm(&recent, "10:00:00"), + "antigravity-cli", + ), + ( + "stale", + 999, + &gorm(stale, "10:00:00"), + &gorm(stale, "10:00:00"), + "antigravity-cli", + ), + ], + ); + + let reader = AntigravityReader::new(dir.path().to_path_buf()); + + let last7 = reader.snapshot(Period::Last7Days).unwrap(); + assert_eq!(last7.total_sessions, 1); + assert_eq!(last7.total_messages, 3); + + let all = reader.snapshot(Period::AllTime).unwrap(); + assert_eq!(all.total_sessions, 2); + assert_eq!(all.total_messages, 1002); + } + + #[test] + fn streaks_and_peak_hour_are_derived_from_activity() { + let dir = tempdir().unwrap(); + // Three consecutive days, two of them starting in the same hour. + let d0 = n_days_ago(2); + let d1 = n_days_ago(1); + let d2 = n_days_ago(0); + seed( + dir.path(), + &[ + ( + "a", + 1, + &gorm(&d0, "14:00:00"), + &gorm(&d0, "14:00:00"), + "antigravity-cli", + ), + ( + "b", + 1, + &gorm(&d1, "14:30:00"), + &gorm(&d1, "14:30:00"), + "antigravity-cli", + ), + ( + "c", + 1, + &gorm(&d2, "09:00:00"), + &gorm(&d2, "09:00:00"), + "antigravity-cli", + ), + ], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + assert_eq!(snap.streaks.current, 3); + assert_eq!(snap.streaks.longest, 3); + assert_eq!(snap.active_days, 3); + // Two 14:00 UTC starts vs one at 09:00 — the peak, whatever local + // hour that lands on for the machine running the test. + let expected = hour_from_timestamp(&to_rfc3339(&gorm(&d0, "14:00:00")).unwrap()); + assert_eq!(snap.peak_hour, expected); + } + + #[test] + fn daily_activity_is_sorted_and_aggregated_per_day() { + let dir = tempdir().unwrap(); + seed( + dir.path(), + &[ + ( + "b", + 5, + &gorm("2026-03-02", "09:00:00"), + &gorm("2026-03-02", "09:00:00"), + "antigravity-cli", + ), + ( + "a1", + 3, + &gorm("2026-03-01", "09:00:00"), + &gorm("2026-03-01", "09:00:00"), + "antigravity-cli", + ), + ( + "a2", + 4, + &gorm("2026-03-01", "18:00:00"), + &gorm("2026-03-01", "18:00:00"), + "antigravity-cli", + ), + ], + ); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + let days: Vec<(&str, u64, u64)> = snap + .daily_activity + .iter() + .map(|d| (d.date.as_str(), d.session_count, d.message_count)) + .collect(); + assert_eq!(days, [("2026-03-01", 2, 7), ("2026-03-02", 1, 5)]); + } + + #[test] + fn the_snapshot_flag_tracks_the_agent_kind_capability() { + // Two spellings of one fact: the tab row reads the kind (available + // before any read), the renderers read the snapshot. They must agree. + let dir = tempdir().unwrap(); + seed( + dir.path(), + &[( + "a", + 1, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + )], + ); + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + assert_eq!( + snap.tokens_unreported, + !AgentKind::Antigravity.reports_tokens() + ); + } + + #[test] + fn missing_database_is_an_empty_snapshot_not_an_error() { + let dir = tempdir().unwrap(); + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + assert_eq!(snap.total_sessions, 0); + assert_eq!(snap.total_messages, 0); + assert!(snap.tokens_unreported); + } + + #[test] + fn empty_database_reads_clean() { + let dir = tempdir().unwrap(); + seed(dir.path(), &[]); + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + assert_eq!(snap.total_sessions, 0); + assert!(snap.first_session_date.is_none()); + } + + #[test] + fn opening_read_only_does_not_create_a_database() { + let dir = tempdir().unwrap(); + let path = dir.path().join(SUMMARIES_DB); + assert!(connect(&path).is_err()); + assert!( + !path.exists(), + "mode=ro must never create the file the way a default open would" + ); + } + + #[test] + fn reads_a_database_while_another_connection_holds_it_open() { + let dir = tempdir().unwrap(); + let path = seed( + dir.path(), + &[( + "a", + 7, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + )], + ); + + // Stand in for a running `agy`: a live WAL-mode writer connection. + let mut writer = SqliteConnection::establish(path.to_str().unwrap()).unwrap(); + diesel::sql_query("PRAGMA journal_mode=WAL") + .execute(&mut writer) + .unwrap(); + diesel::sql_query( + "INSERT INTO conversation_summaries \ + (conversation_id, title, preview, step_count, last_modified_time, \ + workspace_uris, last_user_input_time, app_data_dir) \ + VALUES ('b', '', '', 11, '2026-03-02 10:00:00+00:00', '', \ + '2026-03-02 10:00:00+00:00', 'antigravity-cli')", + ) + .execute(&mut writer) + .unwrap(); + + let snap = AntigravityReader::new(dir.path().to_path_buf()) + .snapshot(Period::AllTime) + .unwrap(); + + // `mode=ro` consults the WAL, so the uncheckpointed row is visible. + assert_eq!(snap.total_sessions, 2); + assert_eq!(snap.total_messages, 18); + } + + #[test] + fn gorm_timestamps_are_normalised_to_rfc3339() { + assert_eq!( + to_rfc3339("2026-09-16 22:57:06.976586196+00:00").as_deref(), + Some("2026-09-16T22:57:06.976586196+00:00") + ); + // Already RFC 3339 — unchanged. + assert_eq!( + to_rfc3339("2026-09-16T22:57:06Z").as_deref(), + Some("2026-09-16T22:57:06Z") + ); + assert_eq!(to_rfc3339(" "), None); + } + + #[test] + fn sqlite_uri_escapes_the_characters_sqlite_reads_as_syntax() { + let uri = sqlite_uri(Path::new("/tmp/a?b#c%d/x.db"), "mode=ro"); + assert_eq!(uri, "file:/tmp/a%3fb%23c%25d/x.db?mode=ro"); + } + + #[test] + fn sqlite_uri_does_not_let_a_unc_path_parse_as_an_authority() { + // `//server/...` after separator normalisation would otherwise hit + // SQLite's authority check and fail with "invalid uri authority". + let uri = sqlite_uri(Path::new("//server/share/x.db"), "mode=ro"); + assert_eq!(uri, "file:/%2fserver/share/x.db?mode=ro"); + // Still only two leading slashes once SQLite decodes it. + assert!(!uri.starts_with("file://")); + } + + #[test] + fn sqlite_uri_leaves_ampersand_and_equals_alone() { + // Literal in the filename state of SQLite's parser — encoding them + // would corrupt the path rather than protect it. + let uri = sqlite_uri(Path::new("/tmp/a&b=c/x.db"), "mode=ro"); + assert_eq!(uri, "file:/tmp/a&b=c/x.db?mode=ro"); + } + + #[cfg(unix)] + #[test] + fn a_path_with_an_ampersand_still_opens() { + let dir = tempdir().unwrap(); + let odd = dir.path().join("a&b=c"); + std::fs::create_dir_all(&odd).unwrap(); + seed( + &odd, + &[( + "a", + 6, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + )], + ); + let snap = AntigravityReader::new(odd) + .snapshot(Period::AllTime) + .unwrap(); + assert_eq!(snap.total_messages, 6); + } + + #[test] + fn a_path_with_a_question_mark_still_opens() { + let dir = tempdir().unwrap(); + let odd = dir.path().join("we?rd#dir"); + std::fs::create_dir_all(&odd).unwrap(); + seed( + &odd, + &[( + "a", + 4, + &gorm("2026-03-01", "10:00:00"), + &gorm("2026-03-01", "10:00:00"), + "antigravity-cli", + )], + ); + + let snap = AntigravityReader::new(odd) + .snapshot(Period::AllTime) + .unwrap(); + assert_eq!(snap.total_sessions, 1); + assert_eq!(snap.total_messages, 4); + } +} diff --git a/crates/aura-core/src/reader/claude_code.rs b/crates/aura-core/src/reader/claude_code.rs index 8f1d150..bce8c3b 100644 --- a/crates/aura-core/src/reader/claude_code.rs +++ b/crates/aura-core/src/reader/claude_code.rs @@ -229,6 +229,7 @@ pub(crate) fn build_snapshot(accum: ScanAccum, cache: Option<&StatsCache>) -> Us daily_activity: daily_activity_vec, first_session_date: first_date, last_session_date: last_date, + tokens_unreported: false, } } diff --git a/crates/aura-core/src/reader/mod.rs b/crates/aura-core/src/reader/mod.rs index c53d9d1..6ee98d4 100644 --- a/crates/aura-core/src/reader/mod.rs +++ b/crates/aura-core/src/reader/mod.rs @@ -1,3 +1,4 @@ +pub mod antigravity; pub mod claude_code; pub mod codex; pub(crate) mod codex_scan; @@ -9,6 +10,7 @@ pub(crate) mod scan; mod stats_cache; mod watcher; +pub use antigravity::AntigravityReader; pub use claude_code::ClaudeCodeReader; pub use codex::CodexReader; pub use gemini::GeminiReader; @@ -24,6 +26,7 @@ pub fn make_reader(agent: &AgentConfig) -> Box { AgentKind::ClaudeCode => Box::new(ClaudeCodeReader::new(path)), AgentKind::Codex => Box::new(CodexReader::new(path)), AgentKind::Gemini => Box::new(GeminiReader::new(path)), + AgentKind::Antigravity => Box::new(AntigravityReader::new(path)), } } @@ -124,6 +127,18 @@ pub struct UsageSnapshot { pub first_session_date: Option, pub last_session_date: Option, + + // ── Capability ──────────────────────────────────────────────────────────── + /// The agent publishes no token counts at all, so every token field above + /// is absent rather than measured as zero. Readers set this when their + /// agent has nothing to report; the UI then drops the Models tab and + /// reads "not reported" rather than a `0` that would mean "you used + /// nothing". + /// + /// Mirrors [`crate::config::AgentKind::reports_tokens`], which answers the + /// same question without needing a read. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub tokens_unreported: bool, } // ── AgentReader ─────────────────────────────────────────────────────────────── diff --git a/crates/aura-core/src/theme.rs b/crates/aura-core/src/theme.rs index 3973046..77a85a8 100644 --- a/crates/aura-core/src/theme.rs +++ b/crates/aura-core/src/theme.rs @@ -242,6 +242,10 @@ pub fn agent_kind_default_color(kind: AgentKind) -> u32 { AgentKind::ClaudeCode => 0xd97757, AgentKind::Codex => 0xffffff, AgentKind::Gemini => 0x4285f4, + // The Antigravity mark renders as a monochrome arc, so there is no + // brand color to borrow. Violet keeps it distinct from the Google + // blue next to it in the profile list. + AgentKind::Antigravity => 0x7c5cff, } } @@ -610,6 +614,7 @@ accent = "#112233" name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, // config.toml override should LOSE to theme.toml override. color: Some("#222222".to_string()), tray_progress_source: None, @@ -626,6 +631,7 @@ accent = "#112233" name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: Some("#333333".to_string()), tray_progress_source: None, tray_color_source: None, @@ -637,6 +643,7 @@ accent = "#112233" name: "Claude Code".to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -653,6 +660,7 @@ accent = "#112233" name: "Codex".to_string(), kind: AgentKind::Codex, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, diff --git a/crates/aura/src/app.rs b/crates/aura/src/app.rs index e87b13d..a1a2034 100644 --- a/crates/aura/src/app.rs +++ b/crates/aura/src/app.rs @@ -5,8 +5,8 @@ use aura_core::{ lexicon::{self, Lexicon}, plugin::{PluginContent, PluginControl, PluginPanel, PluginRunner, PluginSection}, quota::{ - forecast, CodexQuota, ForecastSnapshot, ForecastStatus, ForecastWindow, GeminiQuota, - QuotaApi, QuotaSnapshot, QuotaSource, QuotaWindow, + forecast, AntigravityQuota, CodexQuota, ForecastSnapshot, ForecastStatus, ForecastWindow, + GeminiQuota, QuotaApi, QuotaSnapshot, QuotaSource, QuotaWindow, }, reader::{make_reader, Period, UsageSnapshot}, state::AppState, @@ -64,6 +64,10 @@ enum AgentSection { } impl AgentSection { + /// Every section, in tab order. What each agent actually shows is + /// [`AuraView::visible_agent_sections`]. + const ALL: [Self; 4] = [Self::Quota, Self::Forecast, Self::Summary, Self::Models]; + fn label(self, lex: &Lexicon) -> &'static str { match self { Self::Quota => lex.tab_quota, @@ -93,6 +97,31 @@ impl AgentSection { } } +/// Which sections an agent shows. +/// +/// Models is dropped for an agent that publishes no token counts: both halves +/// of that tab — the tokens-per-day chart and the per-model bars — are +/// token-derived, so it would be a permanently empty page. Driven by the +/// agent's kind rather than a loaded snapshot so the tab row is right on the +/// first frame, before any read has finished. +fn visible_sections(reports_tokens: bool) -> Vec { + AgentSection::ALL + .into_iter() + .filter(|s| reports_tokens || *s != AgentSection::Models) + .collect() +} + +/// Resolve the selection against what's actually on offer, falling back to the +/// first available section — switching from Claude to Antigravity while +/// sitting on Models, say. Derived at render time rather than clamped into +/// state, so the selection and what's drawn can never disagree. +fn effective_section(active: AgentSection, visible: &[AgentSection]) -> AgentSection { + if visible.contains(&active) { + return active; + } + visible.first().copied().unwrap_or(AgentSection::Quota) +} + pub struct AuraView { config: AppConfig, config_path: PathBuf, @@ -617,6 +646,9 @@ fn do_refresh( AgentKind::ClaudeCode => QuotaApi::new(agent_path).snapshot(), AgentKind::Codex => CodexQuota::new(agent_path).snapshot(), AgentKind::Gemini => GeminiQuota::new(agent_path).snapshot(), + AgentKind::Antigravity => { + AntigravityQuota::new(agent_path, agent.command.as_deref()).snapshot() + } }); // Forecast piggybacks on the just-loaded quota snapshot. Same refresh @@ -796,6 +828,21 @@ impl AuraView { cx.notify(); } + /// Sections the active agent actually has something to show in. + fn visible_agent_sections(&self) -> Vec { + visible_sections( + self.current_agent() + .map(|a| a.kind.reports_tokens()) + .unwrap_or(true), + ) + } + + /// The section to render, which is the selected one unless the active + /// agent doesn't have it. + fn effective_agent_section(&self) -> AgentSection { + effective_section(self.active_agent_section, &self.visible_agent_sections()) + } + fn current_agent(&self) -> Option<&AgentConfig> { self.config .agents @@ -822,7 +869,7 @@ impl AuraView { /// active period. Drives whether the period-pill row is rendered. fn current_section_uses_period(&self) -> bool { match self.mode { - Mode::Agent => self.active_agent_section.uses_period(), + Mode::Agent => self.effective_agent_section().uses_period(), Mode::Plugin => self .current_plugin_panel() .and_then(|p| { @@ -1521,14 +1568,9 @@ impl AuraView { let lex = lexicon::pick(self.config.content.goblin_mode); match self.mode { Mode::Agent => { - let sections = [ - AgentSection::Quota, - AgentSection::Forecast, - AgentSection::Summary, - AgentSection::Models, - ]; - for s in sections { - let active = self.active_agent_section == s; + let effective = self.effective_agent_section(); + for s in self.visible_agent_sections() { + let active = effective == s; row = row.child( div() .id(SharedString::from(format!("agent-section-{}", s.id()))) @@ -1604,7 +1646,7 @@ impl AuraView { } else { let accent = self.current_accent(); match self.mode { - Mode::Agent => match self.active_agent_section { + Mode::Agent => match self.effective_agent_section() { AgentSection::Quota => render_quota( &self.theme, lex, @@ -1620,7 +1662,7 @@ impl AuraView { self.spinner_frame, ), AgentSection::Summary => match self.snapshot.as_ref() { - Some(snap) => render_summary(&self.theme, snap), + Some(snap) => render_summary(&self.theme, lex, snap), None => render_loading(&self.theme, lex, self.spinner_frame), }, AgentSection::Models => match self.snapshot.as_ref() { @@ -2316,15 +2358,31 @@ fn render_forecast_window( // ── Summary (the old "Overview" — stat-card grid) ──────────────────────────── -fn render_summary(theme: &Theme, snap: &UsageSnapshot) -> AnyElement { +fn render_summary(theme: &Theme, lex: &Lexicon, snap: &UsageSnapshot) -> AnyElement { + // An agent that publishes no token counts at all reports that, rather than + // showing the `0` its empty token fields would otherwise render — the same + // distinction the quota rows already draw between "0%" and "no percentage". + let unreported = || lex.tokens_not_reported.to_string(); + let rows = [ ( "Favorite model", - snap.favorite_model - .clone() - .unwrap_or_else(|| "—".to_string()), + if snap.tokens_unreported { + unreported() + } else { + snap.favorite_model + .clone() + .unwrap_or_else(|| "—".to_string()) + }, + ), + ( + "Total tokens", + if snap.tokens_unreported { + unreported() + } else { + thousands(snap.total_tokens) + }, ), - ("Total tokens", thousands(snap.total_tokens)), ("Sessions", thousands(snap.total_sessions)), ( "Longest session", @@ -3025,6 +3083,7 @@ fn agent_icon(agent: &AgentConfig, theme: &Theme) -> impl IntoElement { AgentKind::ClaudeCode => "icons/claude.svg", AgentKind::Codex => "icons/openai.svg", AgentKind::Gemini => "icons/gemini.svg", + AgentKind::Antigravity => "icons/antigravity.svg", }; svg() .path(path) @@ -3072,6 +3131,79 @@ mod tests { } } + #[test] + fn models_tab_is_offered_to_agents_that_report_tokens() { + assert_eq!(visible_sections(true), AgentSection::ALL.to_vec()); + } + + #[test] + fn models_tab_is_dropped_for_agents_that_do_not() { + let visible = visible_sections(false); + assert!(!visible.contains(&AgentSection::Models)); + // Only Models goes; the other three are unaffected and keep their order. + assert_eq!( + visible, + [ + AgentSection::Quota, + AgentSection::Forecast, + AgentSection::Summary + ] + ); + } + + #[test] + fn every_agent_kind_agrees_with_its_section_list() { + for kind in [ + AgentKind::ClaudeCode, + AgentKind::Codex, + AgentKind::Gemini, + AgentKind::Antigravity, + ] { + let visible = visible_sections(kind.reports_tokens()); + assert_eq!( + visible.contains(&AgentSection::Models), + kind.reports_tokens(), + "{kind:?}" + ); + } + } + + #[test] + fn selection_survives_when_the_section_is_still_available() { + let visible = visible_sections(true); + assert_eq!( + effective_section(AgentSection::Models, &visible), + AgentSection::Models + ); + } + + #[test] + fn selecting_models_then_switching_to_antigravity_falls_back() { + // The reason this is derived rather than stored: the user can be + // sitting on Models when the active profile changes under them. + let visible = visible_sections(false); + assert_eq!( + effective_section(AgentSection::Models, &visible), + AgentSection::Quota + ); + // Every other selection is left alone. + for s in [ + AgentSection::Quota, + AgentSection::Forecast, + AgentSection::Summary, + ] { + assert_eq!(effective_section(s, &visible), s); + } + } + + #[test] + fn an_empty_section_list_still_resolves() { + assert_eq!( + effective_section(AgentSection::Models, &[]), + AgentSection::Quota + ); + } + #[test] fn hides_when_no_update_info() { let cfg = UpdateConfig::default(); diff --git a/crates/aura/src/assets.rs b/crates/aura/src/assets.rs index 4f5735f..c793652 100644 --- a/crates/aura/src/assets.rs +++ b/crates/aura/src/assets.rs @@ -15,6 +15,7 @@ icon_assets! { (CLAUDE, "claude.svg"), (OPENAI, "openai.svg"), (GEMINI, "gemini.svg"), + (ANTIGRAVITY, "antigravity.svg"), (DEFAULT, "default.svg"), (CLOSE, "close.svg"), (ROTATE_CW, "rotate_cw.svg"), diff --git a/crates/aura/src/cli/quota.rs b/crates/aura/src/cli/quota.rs index edc6abd..e46c48c 100644 --- a/crates/aura/src/cli/quota.rs +++ b/crates/aura/src/cli/quota.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use aura_core::{ config::{AgentKind, AppConfig}, - quota::{CodexQuota, GeminiQuota, QuotaApi, QuotaSnapshot}, + quota::{AntigravityQuota, CodexQuota, GeminiQuota, QuotaApi, QuotaSnapshot}, state::AppState, }; use clap::Args; @@ -31,6 +31,10 @@ impl QuotaCli { AgentKind::ClaudeCode => QuotaApi::new(agent.resolved_config_path()).snapshot(), AgentKind::Codex => CodexQuota::new(agent.resolved_config_path()).snapshot(), AgentKind::Gemini => GeminiQuota::new(agent.resolved_config_path()).snapshot(), + AgentKind::Antigravity => { + AntigravityQuota::new(agent.resolved_config_path(), agent.command.as_deref()) + .snapshot() + } }; match self.format { OutputFormat::Json => print_json(&snapshot), @@ -56,7 +60,10 @@ fn render_text(profile: &str, q: &QuotaSnapshot) { return; } println!(); - println!("{:<14} {:>8} {:>12} RESETS_AT", "WINDOW", "USED%", "TOKENS"); + // Wide enough for the longest label any backend produces — Antigravity's + // "Claude/GPT · week" at 17. A narrower column pushed every following + // column out of alignment on that row alone. + println!("{:<18} {:>8} {:>12} RESETS_AT", "WINDOW", "USED%", "TOKENS"); for w in &q.windows { let pct = w .used_percentage @@ -70,6 +77,6 @@ fn render_text(profile: &str, q: &QuotaSnapshot) { .resets_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "—".to_string()); - println!("{:<14} {:>8} {:>12} {}", w.label, pct, toks, reset); + println!("{:<18} {:>8} {:>12} {}", w.label, pct, toks, reset); } } diff --git a/crates/aura/src/cli/resolve.rs b/crates/aura/src/cli/resolve.rs index bf71af4..620251f 100644 --- a/crates/aura/src/cli/resolve.rs +++ b/crates/aura/src/cli/resolve.rs @@ -40,5 +40,6 @@ pub fn agent_kind_str(kind: AgentKind) -> &'static str { AgentKind::ClaudeCode => "claude-code", AgentKind::Codex => "codex", AgentKind::Gemini => "gemini", + AgentKind::Antigravity => "antigravity", } } diff --git a/crates/aura/src/cli/usage.rs b/crates/aura/src/cli/usage.rs index 17c2a8b..470009f 100644 --- a/crates/aura/src/cli/usage.rs +++ b/crates/aura/src/cli/usage.rs @@ -70,14 +70,21 @@ impl UsageCli { fn render_text(profile: &str, s: &UsageSnapshot) { println!("Profile: {profile}"); - println!( - "Tokens: {} total ({} in, {} out)", - s.total_tokens, s.total_input_tokens, s.total_output_tokens - ); - println!( - "Cache: {} read, {} write", - s.total_cache_read_tokens, s.total_cache_write_tokens - ); + if s.tokens_unreported { + // Printing "0 total" here would read as "you used nothing" rather than + // "this agent doesn't publish token counts" — see + // `UsageSnapshot::tokens_unreported`. + println!("Tokens: not reported by this agent"); + } else { + println!( + "Tokens: {} total ({} in, {} out)", + s.total_tokens, s.total_input_tokens, s.total_output_tokens + ); + println!( + "Cache: {} read, {} write", + s.total_cache_read_tokens, s.total_cache_write_tokens + ); + } println!( "Sessions: {} ({} messages)", s.total_sessions, s.total_messages diff --git a/crates/aura/src/tray_status.rs b/crates/aura/src/tray_status.rs index 805b3f1..263cc1a 100755 --- a/crates/aura/src/tray_status.rs +++ b/crates/aura/src/tray_status.rs @@ -19,7 +19,10 @@ use std::time::Duration; use aura_core::{ config::{AgentConfig, AgentKind, AppConfig, TrayConfig}, - quota::{CodexQuota, GeminiQuota, QuotaApi, QuotaSnapshot, QuotaSource, QuotaWindow}, + quota::{ + AntigravityQuota, CodexQuota, GeminiQuota, QuotaApi, QuotaSnapshot, QuotaSource, + QuotaWindow, + }, state::AppState, }; @@ -226,6 +229,9 @@ fn poll_once(config_path: &Path) -> Option { AgentKind::ClaudeCode => QuotaApi::new(agent_path).snapshot(), AgentKind::Codex => CodexQuota::new(agent_path).snapshot(), AgentKind::Gemini => GeminiQuota::new(agent_path).snapshot(), + AgentKind::Antigravity => { + AntigravityQuota::new(agent_path, agent.command.as_deref()).snapshot() + } }; Some(summarize_sticky(agent, Some("a), &config.tray)) @@ -305,6 +311,7 @@ mod tests { name: name.to_string(), kind: AgentKind::ClaudeCode, config_path: None, + command: None, color: None, tray_progress_source: None, tray_color_source: None, @@ -646,6 +653,34 @@ mod tests { assert_eq!(status.gauge_percent, Some(12)); } + #[test] + fn an_antigravity_spawn_failure_holds_the_last_good_reading() { + // Antigravity's quota comes from spawning `agy`, so it fails in ways + // the HTTP-backed agents never do — the binary missing, the user + // logged out, the process hanging. Every one of them has to reach the + // tray as a held reading rather than a blanked icon, which is what + // `AntigravityQuota` sets `api_failed` for. Driven through the real + // source (against a command that cannot exist) so the two halves stay + // wired together, not through a hand-built snapshot. + let mut last = None; + let mut agent = agent("Antigravity"); + agent.kind = AgentKind::Antigravity; + let good = snapshot(vec![ + window("Gemini · 5h", Some(64.0)), + window("Gemini · week", Some(22.0)), + ]); + let first = sticky(&agent, Some(&good), &all_on(), &mut last); + assert_eq!(first.gauge_percent, Some(64)); + + let failed = AntigravityQuota::new( + std::env::temp_dir(), + Some("aura-tray-test-no-such-agy-77c1"), + ) + .snapshot(); + assert_eq!(failed.source, QuotaSource::Unavailable); + assert_eq!(sticky(&agent, Some(&failed), &all_on(), &mut last), first); + } + #[test] fn a_degraded_poll_that_still_has_percentages_is_held_too() { // Codex's local fallback can produce percentages, but they are the diff --git a/docs/cli.md b/docs/cli.md index b2224a7..7354443 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -114,8 +114,8 @@ aura agents list # --format text|json ``` Shows each configured profile, its kind (`claude-code` / `codex` / -`gemini`), the resolved `config_path`, and whether that directory exists -on disk. +`gemini` / `antigravity`), the resolved `config_path`, and whether that +directory exists on disk. ## `aura plugin` @@ -138,8 +138,12 @@ build. aura usage [--profile ] [--period all|7d|30d] [--format text|json] ``` -Reads the active profile's local data (Claude Code JSONL, -Codex/Gemini sessions) and prints a snapshot. +Reads the active profile's local data (Claude Code JSONL, Codex/Gemini +sessions, Antigravity's `conversation_summaries.db`) and prints a snapshot. + +Antigravity publishes no token counts, so its snapshot reports +`Tokens: not reported by this agent` instead of a zero; sessions, messages, +active days, streaks and peak hour are all present. ## `aura quota` @@ -150,7 +154,13 @@ aura quota [--profile ] [--format text|json] For `claude-code` profiles this hits `/api/oauth/usage` using the credentials in `~/.claude/.credentials.json` (or Keychain / Credential Manager). For `codex` / `gemini` it computes windows locally from session -data. The `source` field in the output (`"api"`, `"fallback"`, or +data. For `antigravity` it runs `agy -p "/usage" --output-format json`, +which returns the same backend numbers the Antigravity IDE shows — four +windows (`Gemini · 5h`, `Gemini · week`, `Claude/GPT · 5h`, +`Claude/GPT · week`) with percentages but no token counts, because +Antigravity meters cost-weighted fractions rather than tokens. + +The `source` field in the output (`"api"`, `"fallback"`, or `"unavailable"`) tells you which path produced the numbers. ## `aura doctor` diff --git a/docs/configuration.md b/docs/configuration.md index 3abc86d..4c5aa84 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -240,8 +240,9 @@ Controls the "Update available" header button. | Field | Type | Allowed | Summary | |---|---|---|---| | `name` | string | — | Display name for this agent profile. | -| `kind` | string | `claude-code` \| `codex` \| `gemini` | Which agent this profile reads. | -| `config_path` | string? | — | Agent config dir; defaults to `~/.claude`, `~/.codex`, `~/.gemini` per kind. | +| `kind` | string | `claude-code` \| `codex` \| `gemini` \| `antigravity` | Which agent this profile reads. | +| `config_path` | string? | — | Agent config dir; defaults to `~/.claude`, `~/.codex`, `~/.gemini`, `~/.gemini/antigravity-cli` per kind. | +| `command` | string? | — | Executable for agents Aura reads by running them (`antigravity`). Unset = the agent's usual binary name on `$PATH`. | | `color` | string? | — | Accent color override, hex like `#rrggbb` or `#rgb`. | | `tray_progress_source` | u32? | — | Quota window that fills the tray ring, by position. Unset = `0`, the session. | | `tray_color_source` | u32? | — | Quota window that drives the tray color ramp, by position. Unset = `1`, the week. | @@ -536,9 +537,67 @@ indicator or a plain button that opens the modal. | `claude-code` | Claude Code CLI agent | `~/.claude` (dir containing `stats-cache.json` / `projects/`) | | `codex` | OpenAI Codex CLI | `~/.codex` (dir containing `sessions/`) | | `gemini` | Gemini CLI | `~/.gemini` | +| `antigravity` | Google Antigravity CLI (`agy`) | `~/.gemini/antigravity-cli` (dir containing `conversation_summaries.db`) | A leading `~` in `config_path` is expanded to the user's home directory. +`antigravity` shares `~/.gemini` with the Gemini CLI but reads a disjoint +subtree, so the two profiles can both be configured without colliding. + +### The `command` key + +Most agents are read purely off disk. Antigravity is not: `agy` keeps its +OAuth credentials in the OS keyring and its quota RPC is gated on the CLI's +own client identity, so Aura gets quota by running +`agy -p "/usage" --output-format json` — a documented public flag. The call +is free (it starts no LLM turn and consumes no quota) and its result is +cached for 20 s so a tray tick and a modal open share one reading. + +Aura resolves `agy` to an absolute path before spawning it, searching ahead +of the inherited `$PATH`: + +| Platform | Searched | +|---|---| +| Linux / macOS | `~/.local/bin`, `~/.cargo/bin`, `~/.bun/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin` | +| Windows | `%LOCALAPPDATA%\agy\bin`, plus the same per-user directories where they exist | + +That is deliberate. Aura runs from a GUI launcher, a systemd user unit or a +launchd agent, and none of those source your shell rc files: + +- **Linux** — the systemd user manager's `PATH` typically omits + `~/.local/bin` entirely, which is exactly where `agy`'s installer puts the + binary. +- **macOS** — launchd hands a bundled app only + `/usr/bin:/bin:/usr/sbin:/sbin`, so neither `~/.local/bin` (where `agy` + lands) nor Homebrew is visible. The `agy` installer adds its directory by + appending to your *shell profile*, which a launchd agent never reads. +- **Windows** — `agy` installs to `%LOCALAPPDATA%\agy\bin` and registers it + in the user `PATH`, but a process started before the install, or a user who + ran the installer with `--skip-path`, won't see it. + +In every case the binary resolves fine from a terminal and is invisible to +the tray process, which makes this a confusing failure to hit. + +`command` points that at an install outside all of those: + +```toml +[[agents]] +name = "Antigravity" +kind = "antigravity" +command = "/opt/antigravity/bin/agy" +``` + +When `agy` is missing or you are not logged in, the Quota tab shows an +`unavailable` note explaining which — the tray keeps its last good reading +rather than degrading. + +Antigravity reports no token counts at all (its trajectories are +schema-less protobuf). The modal drops the **Models** tab entirely for it — +both the tokens-per-day chart and the per-model bars are token-derived, so +the page would have nothing on it — and the token stat cards on Summary read +"not reported" rather than `0`. Sessions, messages, active days, streaks and +peak hour all work normally. + ## State file Aura writes the active profile selection to diff --git a/docs/plans/antigravity-agent.md b/docs/plans/antigravity-agent.md new file mode 100644 index 0000000..65cfbf7 --- /dev/null +++ b/docs/plans/antigravity-agent.md @@ -0,0 +1,435 @@ +--- +title: Antigravity agent +status: implemented +version: 1.0.0 +last_updated: 2026-09-16 +last_verified: 2026-09-16 +source_refs: + - crates/aura-core/src/quota/antigravity.rs + - crates/aura-core/src/reader/antigravity.rs + - crates/aura-core/src/config.rs + - crates/aura-core/src/config_schema.rs + - crates/aura-core/src/quota/mod.rs + - crates/aura-core/src/reader/mod.rs + - crates/aura-core/src/theme.rs + - crates/aura/src/app.rs + - crates/aura/src/tray_status.rs + - crates/aura/src/cli/quota.rs +owner: "@rfluid" +tags: [agents, quota, antigravity, design] +--- + +# Antigravity agent + +## Problem + +Aura monitors Claude Code, Codex, and Gemini. Google's Antigravity CLI +(`agy`) is a fourth agent in daily use on the same machine, with its own +rate-limit windows that no other tool surfaces. A user running `agy` +alongside `claude` has no way to see how much of the Antigravity weekly +or 5-hour limit is left without dropping into the CLI and typing +`/usage`. + +Adding Antigravity as a first-class `AgentKind` puts those windows in the +tray ring, the Quota tab, the Forecast tab, and `aura quota`. + +## What Antigravity exposes + +Investigated against `agy` 1.2.4 on Linux, 2026-09-16. + +### Data directory + +`~/.gemini/antigravity-cli/`, created on first `agy` run. Distinct from +the Gemini CLI subtree Aura already reads (`~/.gemini/tmp//chats/`), +so the two agents never collide despite sharing a parent. + +Relevant contents: + +| Path | Contents | +| --- | --- | +| `conversation_summaries.db` | SQLite. One row per conversation: id, title, preview, `step_count`, `last_modified_time`, `last_user_input_time`, `workspace_uris`, `app_data_dir`, `source`, `agent_name`. Covers both CLI and IDE conversations. | +| `conversations/.db` | SQLite. `steps`, `gen_metadata`, `executor_metadata` — all **schema-less protobuf blobs**. | +| `history.jsonl` | Prompt history: `display`, `timestamp`, `workspace`, `type`. No usage data. | +| `settings.json`, `cache/`, `log/` | Config, caches, verbose gRPC logs. No usage data. | + +The IDE (`~/.gemini/antigravity/conversations/.pb`) uses an older +flat-protobuf format for the same trajectories. + +### Quota — available, exact, free + +```bash +agy -p "/usage" --output-format json +``` + +Returns: + +```json +{ + "conversation_id": "", + "status": "SUCCESS", + "response": "Gemini Models\tWeekly Limit Remaining\t99%\t2026-09-23T22:57:05Z\n…", + "num_turns": 0, + "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }, + "command": { + "name": "usage", + "data": { + "description": "Within each group, models share a weekly limit and a 5-hour limit…", + "groups": [ + { + "name": "Gemini Models", + "description": "Models within this group: Gemini Flash, Gemini Pro", + "buckets": [ + { "id": "gemini-weekly", "name": "Weekly Limit Remaining", "window": "weekly", + "remaining_fraction": 0.9999147057533264, "reset_time": "2026-09-23T22:57:05Z" }, + { "id": "gemini-5h", "name": "Five Hour Limit Remaining", "window": "5h", + "remaining_fraction": 0.9994884729385376, "reset_time": "2026-09-17T03:57:05Z" } + ] + }, + { "name": "Claude and GPT models", "buckets": [ { "id": "3p-weekly", … }, { "id": "3p-5h", … } ] } + ] + } + } +} +``` + +Measured properties: + +- **Free.** `num_turns: 0`, `usage.total_tokens: 0`. The slash command is + handled locally against a cached backend reading; it does not start an + LLM turn. +- **Non-draining.** Eight back-to-back invocations moved + `remaining_fraction` only between `0.9999` and `1.0` — rounding noise on + an idle account, not per-call consumption. +- **Live, not local.** These are backend numbers, the same ones the IDE + shows. Treated as `QuotaSource::Api`. +- **~3 s wall time.** The binary is a 207 MB Go executable; startup + dominates. +- **Failure mode.** Not logged in surfaces as + `error getting token source: You are not logged into Antigravity.` + in the CLI logs and a non-`SUCCESS` status. + +### Why not a direct API call, like Claude Code and Codex + +Claude Code and Codex both follow the same pattern: read an OAuth token +from a file the agent already wrote (`~/.claude/.credentials.json`, +`~/.codex/auth.json`), refresh it, call a stable HTTPS endpoint. That +pattern was investigated for Antigravity and rejected. Findings, all +verified on 2026-09-16: + +**The endpoint exists.** `agy`'s verbose log names it: + +``` +quota_manager.go:45] doRefreshQuota: starting reload (force=true) +cache.go:135] Cache(retrieveUserQuotaSummary): Singleflight refresh failed: + Post "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary" +``` + +Both `cloudcode-pa.googleapis.com` and the `daily-` staging host serve +`POST /v1internal:retrieveUserQuotaSummary`. The wire schema is +`google/internal/cloud/code/v1internal/quota_summary.proto` — +`QuotaSummaryBucket` with a `fixed32 remaining_fraction`, a +`QuotaResetTime`, and `quota_bucket_key` / `quota_bucket_name` / +`quota_bucket_team`. Internal, unversioned, no public descriptor. + +**The credentials are not in a file.** `agy` stores its OAuth token in +the OS keyring via `zalando/go-keyring` +(`ChainedAuth: authenticated via keyring (effective: keyring)`). On Linux +that is a Secret Service item with attributes +`{service: "gemini", username: "antigravity"}`, holding +`{auth_method, id_token, token: {access_token, refresh_token, token_type, expiry}}`. +The binary has a file-storage fallback, but only for hosts where the +keyring times out or no D-Bus session exists — not the normal path. Aura +would need the `keyring` crate and three platform backends, and would be +handling live Google OAuth credentials, which is a security surface the +current file-reading agents do not have. + +**The call is license-gated and the gate was not reproducible.** Direct +`POST` with `{}` returns: + +``` +403 You do not have a valid license of this product. +``` + +That is with `agy`'s own keyring access token — so a valid bearer token +is not sufficient. `x-goog-user-project`, `client-metadata` (carrying the +`antigravity.env_prod.tier_paid` blob the binary embeds), and +`x-goog-api-client` were each tried and each still 403. A separate token +on the same machine (`~/.gemini/antigravity-acp/acp_token.json`, which +*is* a plain file with a refresh token) authenticates fine against +`v1internal:loadCodeAssist` but comes back +`UNSUPPORTED_CLIENT … please migrate to the Antigravity suite`, i.e. the +license is bound to the CLI's own OAuth client identity plus a handshake +Aura has not reconstructed. + +Cost of going direct: reverse-engineered internal proto + a +license/identity handshake + cross-platform keyring credential handling, +all undocumented and free to change in any `agy` release. Cost of the +subprocess: 3 s and a process spawn against `--output-format json`, a +documented public flag. The subprocess wins until Antigravity either +writes quota to disk or publishes the API. + +### Token history — not available + +`conversations/.db` blobs and the IDE `.pb` files contain no +plaintext model names and no field names. Token counts do exist in the +wire data (`agy` embeds +`Interaction_Usage_ModelInvocationTokenCounts`), but recovering them +means guessing undocumented protobuf field numbers that Google can +renumber in any release. Out of scope; revisit only if Antigravity ships +a documented export. + +## Scope + +**In:** + +- `AgentKind::Antigravity`, kebab slug `antigravity`. +- Quota + Forecast parity with the other agents, sourced from `agy`. +- An activity-only reader: sessions, messages, active days, streaks, + peak hour, first/last dates. Tokens and per-model breakdown stay empty. +- Icon, accent color, detection in `aura setup-config` and the installers, + docs. + +**Out:** + +- Per-model token attribution and cost estimation (see above). +- Reading the Antigravity IDE's trajectories. + +## Design + +### 1. Quota source — `crates/aura-core/src/quota/antigravity.rs` + +New `AntigravityQuota { command: PathBuf, data_dir: PathBuf }`. + +`snapshot()`: + +1. Resolve the binary: the agent's configured `command`, else `agy` from + `PATH`. Not found → `QuotaSnapshot::unavailable("agy not found on PATH")`. +2. Spawn `agy -p "/usage" --output-format json` with a hard timeout + (10 s) and `cwd` set to the data dir's parent so no workspace trust + prompt is triggered. +3. Parse `command.data.groups[].buckets[]` with `serde`. +4. Map each bucket to a `QuotaWindow`: + - `label` — `" "`, e.g. `"Gemini · 5h"`, + `"Gemini · week"`, `"Claude/GPT · 5h"`, `"Claude/GPT · week"`. + - `used_percentage` — `(1.0 - remaining_fraction) * 100.0`. + - `used_tokens` — `None`; Antigravity reports cost-weighted fractions, + not tokens. + - `resets_at` — `reset_time`. + - `length_minutes` — `300` for `"5h"`, `10080` for `"weekly"`. This is + what lets the Forecast tab project the window. +5. `subscription_type` — `None` (not exposed by `/usage`). +6. Any spawn, timeout, non-`SUCCESS` status, or parse failure → + `api_failed: true` plus a `note`, so `tray_status::summarize_sticky` + holds the last good reading instead of degrading the icon. + +**Window order** is `[Gemini · 5h, Gemini · week, Claude/GPT · 5h, Claude/GPT · week]`, +not the order `/usage` prints them. This preserves the repo-wide +convention that position 0 is the session window and position 1 is the +week, so the `tray_progress_source` / `tray_color_source` defaults +(`0` and `1`) keep meaning the same thing on this agent as on every other. + +**Caching.** The tray poll floor is 30 s (`TrayConfig::refresh_interval`) +and the modal refreshes on open. A 3 s subprocess on each is tolerable +because quota fetches already run on `background_executor`, but the +snapshot is cached with a short TTL (~20 s) so a modal open right after +a tray tick reuses the reading rather than re-spawning a 207 MB binary. + +### 2. Reader — `crates/aura-core/src/reader/antigravity.rs` + +`AntigravityReader { config_path }` over `conversation_summaries.db`. + +- Opens the DB **read-only** (`file:…?mode=ro`) so a running `agy` is never + disturbed, and copies nothing. Not `immutable=1` by default, contrary to + the original sketch: the DB is in WAL mode, and `immutable=1` reads the + main file alone, so it would silently miss every commit `agy` had not yet + checkpointed. `immutable=1` is the fallback for when the `-shm` file + cannot be created. +- One row → one session. `step_count` → messages. `last_user_input_time` + (falling back to `last_modified_time` when it is the zero timestamp — + older rows have `0001-01-01`) → the session's date and hour. +- Period filtering by that date; `AllTime` reads every row. +- Produces `UsageSnapshot` with `total_sessions`, `total_messages`, + `active_days`, `total_days`, `streaks`, `peak_hour`, `daily_activity`, + `first_session_date`, `last_session_date`. Token fields and `per_model` + stay at their defaults, and `favorite_model` is `None`. +- Optionally filters on `app_data_dir` so a profile can scope to CLI-only + conversations; default is every row. + +**Decided:** `diesel` (sqlite backend, no default features) plus +`libsqlite3-sys/bundled`. `history.jsonl` was rejected on measurement — on +the reference machine it held 4 prompt lines against the DB's 13 +conversations, carries no `step_count`, and has a `conversationId` on only +some entries. The "new C dependency" objection turned out to be weaker than +the plan assumed: `cc` is already in `Cargo.lock` via seven deps in the gpui +tree, and all six release targets build on native runners, so the bundled +amalgamation needs no cross-compiler. + +`agy` is Go/gorm, so both timestamp columns are declared `Text` and +normalised by hand — diesel's chrono deserializer expects +`%Y-%m-%d %H:%M:%S%.f` and these carry a `+00:00` offset. + +The UI consequence: the Models tab renders empty and the Overview's token +counters read zero for this agent. Both need an explicit "not reported by +this agent" state rather than a bare `0`, mirroring how Gemini's +percentage-less quota windows already suppress their progress bar. + +### 3. Config + +- `AgentKind::Antigravity` in `config.rs`; `resolved_config_path()` + default `~/.gemini/antigravity-cli`. +- New optional `AgentConfig::command: Option` — the agent's + executable, for installs outside `PATH`. Serialized only when set; + documented in `config_schema.rs` and `docs/configuration.md`. Used by + the Antigravity quota source today, available to any future + CLI-backed agent. +- Detection list in `setup-config` gains + `("Antigravity", AgentKind::Antigravity, ~/.gemini/antigravity-cli)`. +- `config_schema.rs`: `allowed: &["claude-code", "codex", "gemini", "antigravity"]`, + path summary updated. + +### 4. Presentation + +- `assets/icons/antigravity.svg` — already on disk, currently untracked; + `git add` it. +- `assets.rs` icon registration + `app.rs` kind → icon path arm. +- `theme.rs::agent_kind_default_color` — Antigravity brand tint. The + mark is a monochrome arc; pick a violet (`0x7c5cff`) so it reads + distinctly against Gemini's `0x4285f4`. + +### 5. Match arms to extend + +`make_reader` (`reader/mod.rs`), and the three quota dispatch sites: +`app.rs:619`, `tray_status.rs:228`, `cli/quota.rs:33`. All are total +matches, so the compiler enumerates them. + +### 6. Docs + +`README.md`, `docs/configuration.md` (the new `command` key), +`docs/cli.md`, `.design/agents.md`, `.agent/context/glossary.md` +(the Agent entry lists supported agents), `install.sh`, +`scripts/install.ps1`. + +## Testing + +- `quota/antigravity.rs`: parse fixtures for the healthy response, a + not-logged-in response, and malformed JSON. Window ordering and the + `remaining_fraction` → `used_percentage` inversion get their own + assertions. Binary spawning is behind a trait or a command path so the + tests never invoke `agy`. +- `reader/antigravity.rs`: build a temp SQLite DB with known rows; assert + session/message counts, streaks, peak hour, period filtering, and the + zero-timestamp fallback. +- `tray_status`: an Antigravity snapshot with `api_failed: true` holds + the previous reading. +- Manual: `aura quota --format json` against the live CLI. + +## Risks + +| Risk | Mitigation | +| --- | --- | +| `/usage` JSON shape changes across `agy` releases | Parse defensively — unknown fields ignored, missing `command.data` → `unavailable` with a note naming the version | +| 3 s subprocess on every poll | Background executor + TTL cache + hard timeout | +| `agy` not installed or not logged in | `unavailable` with an actionable note; no panic, no tray degradation | +| New `rusqlite` dependency | Decide reader backing (SQLite vs `history.jsonl`) before implementing | +| Empty Models tab reads as a bug | Tab hidden entirely for agents that report no tokens | + +## Resolved questions + +1. **SQLite dependency** — `diesel` + `libsqlite3-sys/bundled`, over + `rusqlite` and over the `history.jsonl` fallback. See §2. +2. **CLI-only or both** — both. No `app_data_dir` filter. On the reference + machine the split is 1 CLI row against 12 IDE rows going back to + 2025-12; filtering would reduce a user's whole Antigravity history to + whatever they had typed into `agy` since installing it. The DB is the + account's, and `agy` itself writes and reads every row in it. The IDE's + *trajectories* (`~/.gemini/antigravity/conversations/.pb`) stay out + of scope, as planned — those are a different file format entirely. + +## Delivered beyond the plan + +- **`crates/aura-core/src/bin_path.rs`.** The first build resolved `agy` with + a bare `Command::new("agy")`, which works from a terminal and fails in the + tray: the systemd user manager's `PATH` is + `~/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:…` + with no `~/.local/bin`, where `agy` installs itself. The plugin runner had + already solved the same problem with an `augmented_path()` helper; that + logic is now a shared module, and the quota source resolves the binary to + an absolute path before spawning. Resolving up front rather than only + widening the child's `PATH` matters for portability — Unix resolves a bare + program name against the `PATH` handed to the child, but Windows resolves + it against the parent's. The child still gets the widened `PATH` so `agy` + can find its own helpers, and a genuine miss now names where Aura looked + and what to set. `%LOCALAPPDATA%\agy\bin` is searched on Windows, which is + where Antigravity's own installer puts the binary and which no generic + bin-directory list would guess. +- **`CREATE_NO_WINDOW` on the `agy` spawn.** `aura` is built with + `windows_subsystem = "windows"`, so Windows opens a console for every + console-subsystem child — `agy` is one, and the tray polls every 30 s. + Without the flag the user gets a CMD window flashing at them twice a + minute, forever. The plugin runner already had this; the quota source did + not. + +- **A capability split for "this agent has no tokens".** + `AgentKind::reports_tokens()` answers it synchronously, before any read, + and drives the tab row; `UsageSnapshot::tokens_unreported` carries the same + fact on a loaded snapshot and drives the renderers. A test pins the two + together so they can't drift. The plan asked for an explicit + "not reported by this agent" state; what shipped is stronger — the **Models + tab is not offered at all** for such an agent, since both the + tokens-per-day chart and the per-model bars are token-derived and the page + would be blank. The selected section is resolved against the visible list + at render time rather than clamped into state, so switching profiles while + sitting on Models falls back to Quota instead of desyncing. The Summary + stat cards and `aura usage --format text` still say "not reported" via a + `tokens_not_reported` `Lexicon` entry. +- **`aura quota`'s `WINDOW` column widened 14 → 18.** Antigravity's + `Claude/GPT · week` is the first 17-character label any backend has + produced, and it pushed every following column out of alignment. +- **Deterministic `peak_hour`** (`reader/claude_code.rs`). `hour_counts` is + a `HashMap` and `max_by_key` keeps the *last* maximum it sees, so ties + resolved by hash order and the stat changed between runs on unchanged + data. Pre-existing and agent-agnostic, but Antigravity's sparse histories + hit it immediately — the live DB has a four-way tie at three + conversations each. Ties now resolve to the earliest hour. + +## Cross-platform notes + +Verified without access to those machines, by reading the sources that decide +the behaviour: + +- **SQLite URI form.** Checked against `sqlite3ParseUri` in the bundled + amalgamation. While parsing the filename it treats exactly three bytes + specially — `%`, `?`, `#` — so `&` and `=` are literal and must *not* be + encoded. There is no drive-letter special case, so `file:C:/dir/x.db` + yields `C:/dir/x.db` unchanged. The one trap is that the authority check + (`zUri[5]=='/' && zUri[6]=='/'`) runs on the raw string *before* + percent-decoding, so a UNC path would be rejected as + `invalid uri authority: server`; the second slash is encoded to sidestep it. +- **Data directory.** `~/.gemini/antigravity-cli` on all three platforms — + the `agy` binary hardcodes that relative path, with no `AppData` variant. +- **Bundled SQLite on `aarch64-pc-windows-msvc`.** This is the one release + target that is *not* native: it cross-compiles on an x86_64 + `windows-latest` runner. (An earlier note in this doc claimed all six were + native; that was wrong.) The runner image does carry + `Microsoft.VisualStudio.Component.VC.Tools.ARM64`, so `cc` can build the + amalgamation, and that target is already `experimental: true` / + `continue-on-error`. +- **macOS sandbox.** `build-macos-app.sh` signs with `--options runtime` and + no entitlements file, so the bundle uses the hardened runtime but is not + sandboxed — spawning `agy` is unrestricted. +- **Compilation.** The `#[cfg]`-gated code (`CREATE_NO_WINDOW`, the Windows + `PATHEXT` candidates, the non-Unix `is_executable`, `agy_install_dirs`) + type-checks on all six release targets. + +## Verified against the live install + +`agy` 1.2.4, `~/.gemini/antigravity-cli`, 2026-09-16: + +- `aura setup-config` detects Antigravity and writes the profile. +- `aura quota --profile Antigravity` returns all four windows, + `source: api`, correct reset times, `length_minutes` set. +- `aura usage --profile Antigravity` reports 13 sessions / 1673 messages + over a 278-day span, and 1 session / 2 messages for `--period 7d`. +- `assets/icons/antigravity.svg` renders to a clean arc silhouette through + `usvg`/`resvg` 0.45 — the same pipeline `gpui::SvgRenderer` uses, masks + and blur filters included. From 49ca246cd5d4ec92ab465d433a62c27eacd7db24 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Thu, 17 Sep 2026 00:18:53 -0300 Subject: [PATCH 4/5] fix(test): make PATH and filename tests platform-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI caught two Unix-isms in the tests added by the previous two commits. Both were test bugs; neither indicated a product problem. `bin_path` tests built PATH strings by hand with `:`. On Windows the separator is `;`, so the whole string parsed as one entry, which then failed the `is_dir` filter and vanished — taking the assertion with it. They also hardcoded `/usr/bin` as an inherited entry, which does not exist there. Both now go through `std::env::join_paths` against real temporary directories, so they assert the same thing on every host instead of quietly degenerating. The reader test built a directory named `we?rd#dir` to prove that URI syntax in a path is escaped. `?` is not a legal filename character on Windows, so the setup failed before reaching the code under test. The name is now narrowed per platform to the characters that can actually occur there, which keeps the assertion meaningful on Windows rather than skipping it. --- crates/aura-core/src/bin_path.rs | 40 ++++++++++++++++++---- crates/aura-core/src/reader/antigravity.rs | 16 +++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/aura-core/src/bin_path.rs b/crates/aura-core/src/bin_path.rs index ffa8d21..1377088 100644 --- a/crates/aura-core/src/bin_path.rs +++ b/crates/aura-core/src/bin_path.rs @@ -187,6 +187,23 @@ mod tests { use std::fs; use tempfile::tempdir; + /// A `PATH` value spelled the way the host does it — `:` on Unix, `;` on + /// Windows. Building one by hand with `:` made these tests silently + /// degenerate on Windows: the whole string parsed as a single entry, which + /// then failed the `is_dir` filter and vanished. + fn path_env(dirs: &[&Path]) -> OsString { + std::env::join_paths(dirs).unwrap() + } + + /// A real directory to stand in for an inherited `PATH` entry. It has to + /// exist, because `search_dirs_from` drops entries that don't — so + /// hardcoding `/usr/bin` tested nothing on Windows. + fn existing_dir(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + fs::create_dir_all(&path).unwrap(); + path + } + #[cfg(unix)] fn write_exe(dir: &Path, name: &str) -> PathBuf { use std::os::unix::fs::PermissionsExt; @@ -203,13 +220,15 @@ mod tests { // what the user installed there. let home = tempdir().unwrap(); fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + let inherited = existing_dir(home.path(), "inherited-bin"); let dirs = search_dirs_from( &[], Some(home.path().to_path_buf()), - Some(OsString::from("/usr/bin:/bin")), + Some(path_env(&[&inherited])), ); assert_eq!(dirs.first(), Some(&home.path().join(".local/bin"))); + assert!(dirs.contains(&inherited)); } #[test] @@ -234,7 +253,7 @@ mod tests { let dirs = search_dirs_from( &[], Some(home.path().to_path_buf()), - Some(OsString::from(local.to_str().unwrap())), + Some(path_env(&[&local])), ); assert_eq!(dirs.iter().filter(|d| **d == local).count(), 1); } @@ -244,13 +263,18 @@ mod tests { let home = tempdir().unwrap(); fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + let inherited = existing_dir(home.path(), "inherited-bin"); + let merged = augmented_path_from( Some(home.path().to_path_buf()), - Some(OsString::from("/usr/bin:/bin")), + Some(path_env(&[&inherited])), ); let entries: Vec = std::env::split_paths(&merged).collect(); - assert!(entries.contains(&PathBuf::from("/usr/bin"))); - assert!(entries.contains(&home.path().join(".local/bin"))); + assert!(entries.contains(&inherited), "{entries:?}"); + assert!( + entries.contains(&home.path().join(".local/bin")), + "{entries:?}" + ); } #[cfg(unix)] @@ -309,10 +333,11 @@ mod tests { fs::create_dir_all(&extra).unwrap(); fs::create_dir_all(home.path().join(".local/bin")).unwrap(); + let inherited = existing_dir(home.path(), "inherited-bin"); let dirs = search_dirs_from( std::slice::from_ref(&extra), Some(home.path().to_path_buf()), - Some(OsString::from("/usr/bin")), + Some(path_env(&[&inherited])), ); assert_eq!(dirs.first(), Some(&extra)); } @@ -323,10 +348,11 @@ mod tests { // must not end up naming a directory that cannot exist there. let home = tempdir().unwrap(); let missing = home.path().join("not-installed"); + let inherited = existing_dir(home.path(), "inherited-bin"); let dirs = search_dirs_from( std::slice::from_ref(&missing), Some(home.path().to_path_buf()), - Some(OsString::from("/usr/bin")), + Some(path_env(&[&inherited])), ); assert!(!dirs.contains(&missing)); } diff --git a/crates/aura-core/src/reader/antigravity.rs b/crates/aura-core/src/reader/antigravity.rs index 44006bd..0c80506 100644 --- a/crates/aura-core/src/reader/antigravity.rs +++ b/crates/aura-core/src/reader/antigravity.rs @@ -723,10 +723,22 @@ mod tests { assert_eq!(snap.total_messages, 6); } + /// A directory name carrying the characters SQLite's URI parser treats as + /// syntax, narrowed to what the host allows in a filename. Windows rejects + /// `?` outright (`< > : " / \\ | ? *`), so it gets `#` and `%` — which is + /// the set that can actually reach the parser there anyway. + fn tricky_dir_name() -> &'static str { + if cfg!(windows) { + "we#rd%dir" + } else { + "we?rd#dir%x" + } + } + #[test] - fn a_path_with_a_question_mark_still_opens() { + fn a_path_full_of_uri_syntax_still_opens() { let dir = tempdir().unwrap(); - let odd = dir.path().join("we?rd#dir"); + let odd = dir.path().join(tricky_dir_name()); std::fs::create_dir_all(&odd).unwrap(); seed( &odd, From 98780d2f7318db540fe84ba68029c5686a3e1cb0 Mon Sep 17 00:00:00 2001 From: Rfluid Date: Thu, 17 Sep 2026 00:48:34 -0300 Subject: [PATCH 5/5] ci(codeql): add a config file for path and rule exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two suppressions that previously existed only as clicks in the Security tab now live in the repo, where they are reviewable and travel with the branch. `paths-ignore: vendor` stops scanning `vendor/gpui`, a patched copy of Zed's gpui carried for the macOS 26 NSApplication fix. We don't author it and can't act on findings in it, so its alerts — currently a standing `rust/access-invalid-pointer` — are pure noise. This takes effect because CodeQL analyses Rust with build mode `none`; `paths-ignore` would be ignored if it were tracing a real `cargo build`. `query-filters` excludes `rust/cleartext-logging`, which fires on `aura quota`'s output. `ClaudeOauth` holds `access_token` and `refresh_token` in the same struct as `subscription_type`, and CodeQL's taint tracking is field-insensitive, so any field of it reaching a print is flagged. Only the plan tier reaches stdout; the tokens are used solely for the authorization header. That second exclusion is repo-wide — CodeQL has no path-scoped rule filter and Rust has no inline suppression (github/codeql#21637) — so it also silences a genuine future leak. The file says so, and the per-alert dismissals remain as the narrower control. --- .github/codeql/codeql-config.yml | 46 +++++++++++++++++++++++++++ .github/workflows/codeql-analysis.yml | 1 + 2 files changed, 47 insertions(+) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..1e27139 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,46 @@ +# CodeQL configuration for Aura. +# +# Read by `github/codeql-action/init` via `config-file:` in +# .github/workflows/codeql-analysis.yml. +# +# Why `paths-ignore` works here: CodeQL analyses Rust with build mode `none`, +# building its database without compiling the crate. `paths` / `paths-ignore` +# apply to an interpreted language, or to a compiled language analysed without +# a build — which is this case. They would be silently ignored if CodeQL were +# tracing a real `cargo build`. + +name: Aura CodeQL config + +paths-ignore: + # Vendored third-party source. `vendor/gpui` is a patched copy of Zed's + # gpui, carried so we can keep the macOS 26 (Tahoe) NSApplication fix — + # see the `[patch.crates-io]` note in Cargo.toml. We do not author it and + # cannot act on findings inside it, so scanning it only produces alerts + # nobody can close: today that is a standing `rust/access-invalid-pointer` + # in `platform/windows/platform.rs`. + - vendor + +query-filters: + # `rust/cleartext-logging` fires on `aura quota`'s output. `ClaudeOauth` + # (deserialised from ~/.claude/.credentials.json, quota/oauth.rs) holds + # `access_token` and `refresh_token` in the same struct as + # `subscription_type`, and CodeQL's taint tracking is field-insensitive — + # so any field of that struct reaching a print is flagged. The only value + # that actually reaches stdout is the plan tier ("pro" / "max"), via + # `QuotaSnapshot::subscription_type` (quota/api.rs:221). The tokens are + # used solely to build the authorization header (quota/api.rs:144) and + # never enter `QuotaSnapshot`. + # + # CAUTION: this exclusion is repo-wide. CodeQL has no path-scoped rule + # filter, and Rust has no inline `// codeql[...]` suppression + # (github/codeql#21637), so there is no way to scope it to the three + # `println!`s that prompted it. It therefore also silences a *genuine* + # future leak — which matters in a process that holds live OAuth + # credentials for four agents. + # + # The narrower control is per-alert dismissal in the Security tab, which + # is already in place for alerts 5, 6 and 7 and persists on its own. If + # you would rather keep the rule armed, delete this `query-filters` block; + # nothing else depends on it. + - exclude: + id: rust/cleartext-logging diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae68511..2169610 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,6 +34,7 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml # GPUI + tray-icon (gtk) link GTK / xkbcommon / xcb / fontconfig # via pkg-config; the build script panics without dev headers.