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/.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. 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/bin_path.rs b/crates/aura-core/src/bin_path.rs new file mode 100644 index 0000000..1377088 --- /dev/null +++ b/crates/aura-core/src/bin_path.rs @@ -0,0 +1,378 @@ +//! 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; + + /// 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; + 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 inherited = existing_dir(home.path(), "inherited-bin"); + + let dirs = search_dirs_from( + &[], + Some(home.path().to_path_buf()), + Some(path_env(&[&inherited])), + ); + assert_eq!(dirs.first(), Some(&home.path().join(".local/bin"))); + assert!(dirs.contains(&inherited)); + } + + #[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(path_env(&[&local])), + ); + 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 inherited = existing_dir(home.path(), "inherited-bin"); + + let merged = augmented_path_from( + Some(home.path().to_path_buf()), + Some(path_env(&[&inherited])), + ); + let entries: Vec = std::env::split_paths(&merged).collect(); + assert!(entries.contains(&inherited), "{entries:?}"); + assert!( + entries.contains(&home.path().join(".local/bin")), + "{entries:?}" + ); + } + + #[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 inherited = existing_dir(home.path(), "inherited-bin"); + let dirs = search_dirs_from( + std::slice::from_ref(&extra), + Some(home.path().to_path_buf()), + Some(path_env(&[&inherited])), + ); + 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 inherited = existing_dir(home.path(), "inherited-bin"); + let dirs = search_dirs_from( + std::slice::from_ref(&missing), + Some(home.path().to_path_buf()), + Some(path_env(&[&inherited])), + ); + 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/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/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() { 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..0c80506 --- /dev/null +++ b/crates/aura-core/src/reader/antigravity.rs @@ -0,0 +1,760 @@ +//! 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); + } + + /// 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_full_of_uri_syntax_still_opens() { + let dir = tempdir().unwrap(); + let odd = dir.path().join(tricky_dir_name()); + 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 6f407cc..bce8c3b 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 ─────────────────────────────────────────────── @@ -226,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, } } @@ -237,6 +241,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(); 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.