Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dcda254
feat(usage): add Claude/Codex usage monitoring via ccusage sidecar
Fldicoahkiin May 31, 2026
ee4aa22
fix(ci): fetch ccusage sidecar before compiling the desktop crate
Fldicoahkiin May 31, 2026
3a5c7cd
fix(ci): define the _fetch-ccusage recipe the lint/test tasks depend on
Fldicoahkiin May 31, 2026
f525955
build(desktop): fetch ccusage sidecar in build.rs
Fldicoahkiin Jun 2, 2026
eb081ec
feat(usage): add Claude/Codex remaining-quota limits endpoint
Fldicoahkiin Jun 2, 2026
faf2245
refactor(usage): extract reporting logic into aghub-usage crate
Fldicoahkiin Jun 3, 2026
dc4b454
feat(cli): add usage command for token and rate-limit reporting
Fldicoahkiin Jun 3, 2026
3951b7b
fix(usage): tolerate null cost from ccusage output
Fldicoahkiin Jun 4, 2026
0573903
fix(usage): enable tokio "macros" feature for tokio::join!
Fldicoahkiin Jun 4, 2026
13934f1
fix(usage): redact filesystem paths from errors and reap timed-out cc…
Fldicoahkiin Jun 4, 2026
f0fed1b
fix(desktop): keep platform exe suffix when resolving ccusage sidecar
Fldicoahkiin Jun 4, 2026
ec389f5
fix(desktop): re-fetch ccusage sidecar when CCUSAGE_VERSION changes
Fldicoahkiin Jun 4, 2026
374f446
build(desktop): verify ccusage tarball integrity against npm registry
Fldicoahkiin Jun 4, 2026
576473a
Merge remote-tracking branch 'origin/main' into feat/usage-monitoring
Fldicoahkiin Jun 5, 2026
26025bf
Merge remote-tracking branch 'origin/main' into feat/usage-monitoring
Fldicoahkiin Jun 8, 2026
1e3bcdc
Merge remote-tracking branch 'origin/main' into feat/usage-monitoring
Fldicoahkiin Jun 14, 2026
4fd3745
Merge remote-tracking branch 'origin/main' into feat/usage-monitoring
Fldicoahkiin Jun 14, 2026
07739db
Merge remote-tracking branch 'origin/main' into feat/usage-monitoring
Fldicoahkiin Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ build/

# ts-rs generated TypeScript bindings (build artifact)
crates/api/bindings/
crates/usage/bindings/

# ccusage sidecar binaries — fetched at build time by src-tauri/build.rs
crates/desktop/src-tauri/binaries/
57 changes: 57 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/markdown",
"crates/inference",
"crates/cc-plugins",
"crates/usage",
"crates/desktop/src-tauri",
]
resolver = "2"
Expand Down
4 changes: 3 additions & 1 deletion crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ aghub-core = { path = "../core" }
aghub-git = { path = "../git" }
aghub-inference = { path = "../inference" }
aghub-cc-plugins = { path = "../cc-plugins" }
aghub-usage = { path = "../usage" }
skill = { path = "../skill" }
skills-sh = { path = "../skills-sh" }
rocket = { version = "0.5", features = [ "json" ] }
rocket_cors = "0.6.0"
tokio = { workspace = true, features = [ "rt-multi-thread", "macros", "process" ] }
tokio = { workspace = true, features = [ "rt-multi-thread", "macros", "process", "time" ] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
Expand All @@ -37,4 +38,5 @@ keyring = { workspace = true }
uuid = { workspace = true }
log = { workspace = true }
reqwest = { workspace = true }
chrono = { workspace = true }
url = { workspace = true }
14 changes: 14 additions & 0 deletions crates/api/src/bin/export-dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ use aghub_api::dto::{
TransferRequest,
},
};
use aghub_usage::{
AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto,
UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto,
};
use ts_rs::{Config, TS};

fn workspace_root() -> PathBuf {
Expand Down Expand Up @@ -247,6 +251,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export_type::<CCPluginValidateRequest>(&cfg)?;
export_type::<CCPluginValidateResponse>(&cfg)?;

export_type::<UsageModelDto>(&cfg)?;
export_type::<UsageDayDto>(&cfg)?;
export_type::<UsageTotalsDto>(&cfg)?;
export_type::<AgentUsageDto>(&cfg)?;
export_type::<UsageReportDto>(&cfg)?;

export_type::<LimitWindowDto>(&cfg)?;
export_type::<AgentLimitsDto>(&cfg)?;
export_type::<UsageLimitsReportDto>(&cfg)?;

write_index_file(&out_dir)?;

if disallowed_dir.exists() {
Expand Down
10 changes: 10 additions & 0 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub struct ApiOptions {
pub port: u16,
pub app_data_dir: Option<PathBuf>,
/// Path to the bundled `ccusage` sidecar; `None` falls back to env/PATH.
pub ccusage_bin: Option<PathBuf>,
pub auth_token: Option<String>,
pub allowed_origins: Vec<String>,
pub allowed_origin_regexes: Vec<String>,
Expand All @@ -33,6 +35,7 @@ impl ApiOptions {
Self {
port,
app_data_dir: None,
ccusage_bin: None,
auth_token: None,
allowed_origins: default_allowed_origins(),
allowed_origin_regexes: default_allowed_origin_regexes(),
Expand All @@ -58,6 +61,7 @@ impl ApiOptions {
app_data_dir: self
.app_data_dir
.unwrap_or_else(default_app_data_dir),
ccusage_bin: self.ccusage_bin,
auth_token,
token_was_generated,
allowed_origins: self.allowed_origins,
Expand Down Expand Up @@ -87,6 +91,7 @@ fn default_allowed_origin_regexes() -> Vec<String> {
struct ResolvedApiOptions {
port: u16,
app_data_dir: PathBuf,
ccusage_bin: Option<PathBuf>,
auth_token: String,
token_was_generated: bool,
allowed_origins: Vec<String>,
Expand Down Expand Up @@ -183,6 +188,9 @@ fn build_rocket(
.manage(crate::state::InferenceProviderState {
app_data_dir: options.app_data_dir,
})
.manage(crate::state::UsageState {
ccusage_bin: options.ccusage_bin,
})
.manage(crate::auth::ApiAuthState {
token: options.auth_token,
})
Expand Down Expand Up @@ -286,6 +294,8 @@ fn build_rocket(
routes::plugins::cli_status,
routes::plugins::prune_plugins,
routes::plugins::validate_plugin,
routes::usage::usage_summary,
routes::usage::usage_limits,
],
)
.register(
Expand Down
1 change: 1 addition & 0 deletions crates/api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod mcps;
pub mod plugins;
pub mod skills;
pub mod sub_agents;
pub mod usage;

use aghub_core::{
create_adapter, manager::ConfigManager, models::ResourceScope,
Expand Down
27 changes: 27 additions & 0 deletions crates/api/src/routes/usage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Usage routes: thin handlers over the `aghub-usage` crate, which owns the
//! ccusage shell-out, normalization, and vendor limit-endpoint logic.

use rocket::serde::json::Json;
use rocket::State;

use aghub_usage::{UsageLimitsReportDto, UsageReportDto};

use crate::state::UsageState;

/// `GET /api/v1/usage/summary` — daily token/cost usage for Claude and Codex.
#[get("/usage/summary?<since>&<until>&<timezone>")]
pub async fn usage_summary(
usage: &State<UsageState>,
since: Option<String>,
until: Option<String>,
timezone: Option<String>,
) -> Json<UsageReportDto> {
let bin = aghub_usage::resolve_ccusage_bin(usage.ccusage_bin.clone());
Json(aghub_usage::summary(&bin, since, until, timezone).await)
}

/// `GET /api/v1/usage/limits` — remaining rate-limit quota for Claude and Codex.
#[get("/usage/limits")]
pub async fn usage_limits() -> Json<UsageLimitsReportDto> {
Json(aghub_usage::limits().await)
}
7 changes: 7 additions & 0 deletions crates/api/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ pub struct GitCloneSessions {
pub struct InferenceProviderState {
pub app_data_dir: PathBuf,
}

/// Path to the bundled `ccusage` sidecar binary, injected by the desktop shell.
/// `None` in dev / standalone server: usage routes fall back to the
/// `AGHUB_CCUSAGE_BIN` env var, then to `ccusage` on `PATH`.
pub struct UsageState {
pub ccusage_bin: Option<PathBuf>,
}
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
aghub-core = { path = "../core" }
aghub-agents = { path = "../agents" }
aghub-cc-plugins = { path = "../cc-plugins" }
aghub-usage = { path = "../usage" }
skill = { path = "../skill" }

clap = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod enable;
pub mod get;
pub mod plugin;
pub mod update;
pub mod usage;

use aghub_core::models::McpTransport;
use anyhow::{bail, Result};
Expand Down
54 changes: 54 additions & 0 deletions crates/cli/src/commands/usage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! `aghub-cli usage ...` subcommands.
//!
//! Thin CLI surface over the `aghub_usage` crate. Both subcommands print the
//! same JSON the desktop app consumes; the CLI does not bundle the ccusage
//! sidecar, so `summary` resolves it from `AGHUB_CCUSAGE_BIN` or `PATH`.

use anyhow::{Context, Result};
use clap::Subcommand;

#[derive(Subcommand)]
pub enum UsageAction {
/// Daily token and cost usage for Claude and Codex from local ccusage data
Summary {
/// Start date, YYYY-MM-DD (passed through to ccusage)
#[arg(long)]
since: Option<String>,
/// End date, YYYY-MM-DD (passed through to ccusage)
#[arg(long)]
until: Option<String>,
/// IANA timezone for day bucketing (passed through to ccusage)
#[arg(long)]
timezone: Option<String>,
},
/// Remaining rate-limit quota for Claude and Codex from each vendor endpoint
Limits,
}

pub fn execute(action: UsageAction) -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to build tokio runtime")?;
runtime.block_on(dispatch(action))
}

async fn dispatch(action: UsageAction) -> Result<()> {
match action {
UsageAction::Summary {
since,
until,
timezone,
} => {
let bin = aghub_usage::resolve_ccusage_bin(None);
let report =
aghub_usage::summary(&bin, since, until, timezone).await;
println!("{}", serde_json::to_string_pretty(&report)?);
}
UsageAction::Limits => {
let report = aghub_usage::limits().await;
println!("{}", serde_json::to_string_pretty(&report)?);
}
}
Ok(())
}
16 changes: 15 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use aghub_core::{

mod commands;

use commands::{add, delete, disable, enable, get, plugin, update};
use commands::{add, delete, disable, enable, get, plugin, update, usage};

/// Global verbose flag used by the eprintln_verbose macro
static VERBOSE: AtomicBool = AtomicBool::new(false);
Expand Down Expand Up @@ -205,6 +205,11 @@ enum Commands {
#[command(subcommand)]
action: plugin::PluginAction,
},
/// Report token usage and rate-limit quota for Claude and Codex
Usage {
#[command(subcommand)]
action: usage::UsageAction,
},
}

#[derive(ValueEnum, Clone, Copy, Debug)]
Expand All @@ -221,6 +226,12 @@ fn main() -> Result<()> {
// Set global verbose flag
set_verbose(cli.verbose);

// Usage reporting is agent-agnostic: it reads each vendor's own data, so it
// runs before agent/scope resolution and config loading.
if let Commands::Usage { action } = cli.command {
return usage::execute(action);
}

// Handle --agent all: iterate all registered agents
if cli.agent == "all" {
return handle_all_agents(&cli);
Expand Down Expand Up @@ -380,6 +391,9 @@ fn main() -> Result<()> {
}
plugin::execute(action)
}
Commands::Usage { .. } => {
unreachable!("usage is dispatched before agent resolution")
}
}
}

Expand Down
Loading
Loading