diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 96fa06179c..2967a25434 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -437,6 +437,7 @@ pub struct SettingsSnapshot { codex_custom_sessions_dirs: Vec, agent_sessions_enabled: bool, agent_session_ssh_hosts: Vec, + hooks_enabled: bool, ui_language: &'static str, theme: &'static str, window_scale_percent: u16, @@ -531,6 +532,7 @@ impl From for SettingsSnapshot { codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, agent_sessions_enabled: settings.agent_sessions_enabled, agent_session_ssh_hosts: settings.agent_session_ssh_hosts, + hooks_enabled: settings.hooks_enabled, ui_language: language_label(settings.ui_language), theme: theme_label(settings.theme), window_scale_percent: settings.window_scale_percent, diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index ce9136fb83..8218395c91 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -27,6 +27,7 @@ mod chart; mod diagnostics; mod tokens; mod updater; +mod usage_spend; mod agent_sessions; mod bridge; @@ -63,6 +64,7 @@ pub use chart::*; pub use diagnostics::*; pub use tokens::*; pub use updater::*; +pub use usage_spend::*; const PROVIDER_CACHE_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30); const MAX_API_KEY_LEN: usize = 16 * 1024; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 8846ec7a20..1d12ac816e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -617,6 +617,13 @@ fn notify_usage_thresholds( snapshot.primary.used_percent, settings, ); + dispatch_quota_hooks( + settings, + provider, + &account, + "session", + snapshot.primary.used_percent, + ); } if let Some(weekly) = &snapshot.secondary && !weekly.is_informational @@ -628,6 +635,13 @@ fn notify_usage_thresholds( weekly.used_percent, settings, ); + dispatch_quota_hooks( + settings, + provider, + &account, + "weekly", + weekly.used_percent, + ); } notify_predictive_pace( &mut guard.notification_manager, @@ -641,6 +655,35 @@ fn notify_usage_thresholds( } } +fn dispatch_quota_hooks( + settings: &Settings, + provider: ProviderId, + account: &str, + window: &str, + used_percent: f64, +) { + if !settings.hooks_enabled { + return; + } + let thresholds = settings.usage_thresholds(provider, window); + let account = if settings.hide_personal_info { + None + } else if account.is_empty() { + None + } else { + Some(account) + }; + codexbar::core::emit_quota_threshold_hooks( + true, + provider.cli_name(), + window, + used_percent, + thresholds.high, + thresholds.critical, + account, + ); +} + /// Stable account discriminator for threshold/session toast dedupe. /// Prefer token-account id, then email, org, plan; empty for single-account lanes. fn quota_notification_account_identity( diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 921fb26480..ccd8185532 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -38,6 +38,7 @@ pub struct SettingsUpdate { pub codex_custom_sessions_dirs: Option>, pub agent_sessions_enabled: Option, pub agent_session_ssh_hosts: Option>, + pub hooks_enabled: Option, pub ui_language: Option, pub theme: Option, pub window_scale_percent: Option, @@ -250,6 +251,9 @@ impl SettingsUpdate { settings.agent_session_ssh_hosts = codexbar::agent_sessions::RemoteSessionFetcher::sanitized_hosts(&v); } + if let Some(v) = self.hooks_enabled { + settings.hooks_enabled = v; + } if let Some(v) = self.install_updates_on_quit { settings.install_updates_on_quit = v; } diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs new file mode 100644 index 0000000000..8632c073e1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -0,0 +1,92 @@ +//! Usage & Spend settings tab: 7-day / 30-day local cost aggregates. + +use codexbar::cost_scanner::CostScanner; +use serde::Serialize; +use tauri::State; + +use super::ProviderUsageSnapshot; +use crate::state::AppState; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSpendRow { + pub provider_id: String, + pub display_name: String, + pub seven_day: Option, + pub thirty_day: Option, + pub currency: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSpendSummary { + pub rows: Vec, +} + +#[tauri::command] +pub async fn get_usage_spend_summary( + state: State<'_, Mutex>, +) -> Result { + let cached = { + let guard = state.lock().map_err(|e| e.to_string())?; + guard.provider_cache.clone() + }; + + tauri::async_runtime::spawn_blocking(move || build_usage_spend_summary(&cached)) + .await + .map_err(|e| format!("usage spend worker failed: {e}")) +} + +fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot]) -> UsageSpendSummary { + let mut rows = Vec::new(); + + // Local JSONL scanners for Codex / Claude (primary spend sources). + let codex_7 = CostScanner::new(7).scan_codex().total_cost_usd; + let codex_30 = CostScanner::new(30).scan_codex().total_cost_usd; + rows.push(UsageSpendRow { + provider_id: "codex".into(), + display_name: "Codex".into(), + seven_day: Some(codex_7), + thirty_day: Some(codex_30), + currency: "USD".into(), + source: "local logs".into(), + }); + + let claude_7 = CostScanner::new(7).scan_claude().total_cost_usd; + let claude_30 = CostScanner::new(30).scan_claude().total_cost_usd; + rows.push(UsageSpendRow { + provider_id: "claude".into(), + display_name: "Claude".into(), + seven_day: Some(claude_7), + thirty_day: Some(claude_30), + currency: "USD".into(), + source: "local logs".into(), + }); + + // Surface any other provider cost snapshots from the last refresh (period + // costs, not calendar 7d/30d — shown under thirty_day only). + for snapshot in cached { + if snapshot.provider_id == "codex" || snapshot.provider_id == "claude" { + continue; + } + let Some(cost) = &snapshot.cost else { + continue; + }; + rows.push(UsageSpendRow { + provider_id: snapshot.provider_id.clone(), + display_name: if snapshot.display_name.is_empty() { + snapshot.provider_id.clone() + } else { + snapshot.display_name.clone() + }, + seven_day: None, + thirty_day: Some(cost.used), + currency: cost.currency_code.clone(), + source: format!("period ({})", cost.period), + }); + } + + UsageSpendSummary { rows } +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 3c6a92b7f8..eee3102e3e 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -182,6 +182,7 @@ fn main() { commands::get_app_info, commands::get_provider_chart_data, commands::get_provider_local_usage_summary, + commands::get_usage_spend_summary, commands::reorder_providers, commands::set_provider_cookie_source, commands::get_provider_cookie_source, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index ef95b03f8b..5ffc32c235 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -12,6 +12,7 @@ export const ALL_LOCALE_KEYS = [ "TabMenu", "TabApiKeys", "TabCookies", + "TabUsageSpend", "TabAdvanced", "TabAbout", "TabShortcuts", @@ -281,6 +282,21 @@ export const ALL_LOCALE_KEYS = [ "SectionLocalIntegrations", "PowerToysPipeLabel", "PowerToysPipeHelper", + "HooksTitle", + "HooksCaption", + "HooksEnableLabel", + "HooksEnableHelper", + "HooksConfigPathHint", + "UsageSpendTitle", + "UsageSpendCaption", + "UsageSpendRefresh", + "UsageSpendLoading", + "UsageSpendEmpty", + "UsageSpendColProvider", + "UsageSpendCol7d", + "UsageSpendCol30d", + "UsageSpendColCurrency", + "UsageSpendColSource", "AgentSessionsTitle", "AgentSessionsEnableLabel", "AgentSessionsEnableHelper", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 300d5c67c8..97f8d63fa7 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -34,6 +34,7 @@ import type { AgentSessionDiscoveryResult, SessionFocusResult, TrayVisibilityStatusDto, + UsageSpendSummary, } from "../types/bridge"; export function getBootstrapState(): Promise { @@ -249,6 +250,10 @@ export function getProviderLocalUsageSummary( return invoke("get_provider_local_usage_summary", { providerId }); } +export function getUsageSpendSummary(): Promise { + return invoke("get_usage_spend_summary"); +} + // ── Token account bridge ───────────────────────────────────────────── export function getTokenAccountProviders(): Promise { diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 2ca8c8bced..be235a3638 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -5236,6 +5236,28 @@ html:has(.menu-surface--tray) { line-height: 1.4; } +/* Usage & Spend table */ +.usage-spend-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-body); +} +.usage-spend-table th, +.usage-spend-table td { + text-align: left; + padding: 6px 8px; + border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); +} +.usage-spend-table th { + font-weight: 600; + color: var(--text-secondary, #a1a1a6); + font-size: var(--font-caption); +} +.usage-spend-table__source { + color: var(--text-muted); + font-size: var(--font-caption); +} + /* About update controls block */ .about-update-controls { display: flex; diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 691adbe219..50ff6f3814 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -16,6 +16,7 @@ import DisplayTab from "./settings/tabs/DisplayTab"; import AdvancedTab from "./settings/tabs/AdvancedTab"; import AboutTab from "./settings/tabs/AboutTab"; import ProvidersTab from "./settings/tabs/ProvidersTab"; +import UsageSpendTab from "./settings/tabs/UsageSpendTab"; // ── tab types ──────────────────────────────────────────────────────── @@ -80,6 +81,12 @@ const TabIcons: Record = { ), + usageSpend: ( + + + + + ), advanced: ( @@ -103,6 +110,7 @@ export const TAB_META: { id: SettingsTab; labelKey: LocaleKey }[] = [ { id: "notifications", labelKey: "TabNotifications" }, { id: "menuBar", labelKey: "TabMenuBar" }, { id: "menu", labelKey: "TabMenu" }, + { id: "usageSpend", labelKey: "TabUsageSpend" }, { id: "advanced", labelKey: "TabAdvanced" }, { id: "about", labelKey: "TabAbout" }, ]; @@ -267,6 +275,9 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst {activeTab === "menu" && ( )} + {activeTab === "usageSpend" && ( + + )} {activeTab === "advanced" && ( )} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx index 4a4a38398f..0ed4d5c801 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx @@ -197,6 +197,26 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { + {/* -- External hooks --------------------------------------- */} +
+

{t("HooksTitle")}

+

{t("HooksCaption")}

+
+ + set({ hooksEnabled: v })} + /> + +
+

{t("HooksConfigPathHint")}

+ + {/* ── Keychain access ──────────────────────────────────────── */}

diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx new file mode 100644 index 0000000000..0e0710fd66 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLocale } from "../../../hooks/useLocale"; +import { getUsageSpendSummary } from "../../../lib/tauri"; +import type { UsageSpendSummary } from "../../../types/bridge"; +import type { TabProps } from "../../Settings"; + +function formatUsd(value: number | null | undefined, currency: string): string { + if (value == null || !Number.isFinite(value)) return "—"; + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + maximumFractionDigits: 2, + }).format(value); + } catch { + return `$${value.toFixed(2)}`; + } +} + +export default function UsageSpendTab(_props: TabProps) { + const { t } = useLocale(); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(() => { + setLoading(true); + setError(null); + void getUsageSpendSummary() + .then((data) => { + setSummary(data); + setLoading(false); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : String(err)); + setLoading(false); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+

+ {t("UsageSpendTitle")} +

+

{t("UsageSpendCaption")}

+ +
+ +
+ + {error &&

{error}

} + + {!error && ( + + + + + + + + + + + + {(summary?.rows ?? []).map((row) => ( + + + + + + + + ))} + {!loading && (summary?.rows?.length ?? 0) === 0 && ( + + + + )} + +
{t("UsageSpendColProvider")}{t("UsageSpendCol7d")}{t("UsageSpendCol30d")}{t("UsageSpendColCurrency")}{t("UsageSpendColSource")}
{row.displayName}{formatUsd(row.sevenDay, row.currency)}{formatUsd(row.thirtyDay, row.currency)}{row.currency || "USD"}{row.source}
{t("UsageSpendEmpty")}
+ )} +
+ ); +} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 4454995fd0..aa766fbfdc 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -6,6 +6,7 @@ export type SettingsTabId = | "notifications" | "menuBar" | "menu" + | "usageSpend" | "advanced" | "about"; @@ -251,6 +252,8 @@ export interface SettingsSnapshot { codexCustomSessionsDirs: string[]; agentSessionsEnabled?: boolean; agentSessionSshHosts?: string[]; + /** Master switch for external hooks (hooks.json next to settings). */ + hooksEnabled?: boolean; uiLanguage: Language; theme: ThemePreference; /** 100..=250 — clamped server-side. */ @@ -315,6 +318,7 @@ export interface SettingsUpdate { codexCustomSessionsDirs?: string[]; agentSessionsEnabled?: boolean; agentSessionSshHosts?: string[]; + hooksEnabled?: boolean; uiLanguage?: Language; theme?: ThemePreference; windowScalePercent?: number; @@ -343,6 +347,20 @@ export interface UsageThresholdOverride { critical?: number; } +/** One provider row for Settings → Usage & Spend. */ +export interface UsageSpendRow { + providerId: string; + displayName: string; + sevenDay: number | null; + thirtyDay: number | null; + currency: string; + source: string; +} + +export interface UsageSpendSummary { + rows: UsageSpendRow[]; +} + export interface BootstrapState { contractVersion: string; providers: ProviderCatalogEntry[]; diff --git a/rust/src/core/hooks.rs b/rust/src/core/hooks.rs new file mode 100644 index 0000000000..b660ef24a6 --- /dev/null +++ b/rust/src/core/hooks.rs @@ -0,0 +1,697 @@ +//! External hook runner for quota / provider events (upstream #2001). +//! +//! Loads rules from `hooks.json` next to `settings.json` and runs matching +//! binaries with a narrow env whitelist + JSON stdin payload. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Stable event names used in config, env vars, and the JSON payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEventType { + QuotaLow, + QuotaReached, + QuotaReset, + ProviderUnavailable, + ProviderRecovered, + RefreshFailed, +} + +impl HookEventType { + pub fn as_str(self) -> &'static str { + match self { + Self::QuotaLow => "quota_low", + Self::QuotaReached => "quota_reached", + Self::QuotaReset => "quota_reset", + Self::ProviderUnavailable => "provider_unavailable", + Self::ProviderRecovered => "provider_recovered", + Self::RefreshFailed => "refresh_failed", + } + } + + /// Events that can repeat every refresh while a condition persists. + pub fn is_rate_limited(self) -> bool { + matches!(self, Self::ProviderUnavailable | Self::RefreshFailed) + } +} + +/// Single quota/provider event handed to external hooks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HookEvent { + pub event: HookEventType, + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, + /// Remaining capacity 0..=100 when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub remaining_percent: Option, + /// Used fraction 0..=1 (upstream-compatible env/payload). + #[serde(skip_serializing_if = "Option::is_none")] + pub usage_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + pub timestamp: String, +} + +impl HookEvent { + pub fn new(event: HookEventType, provider: impl Into) -> Self { + Self { + event, + provider: provider.into(), + account: None, + window: None, + remaining_percent: None, + usage_percent: None, + status: None, + timestamp: utc_now_iso(), + } + } + + pub fn with_remaining_percent(mut self, remaining: f64) -> Self { + let remaining = remaining.clamp(0.0, 100.0); + self.remaining_percent = Some(remaining); + self.usage_percent = Some(((100.0 - remaining) / 100.0).clamp(0.0, 1.0)); + self + } + + pub fn with_used_percent(mut self, used: f64) -> Self { + let used = used.clamp(0.0, 100.0); + self.usage_percent = Some(used / 100.0); + self.remaining_percent = Some(100.0 - used); + self + } + + pub fn with_window(mut self, window: impl Into) -> Self { + self.window = Some(window.into()); + self + } + + pub fn with_account(mut self, account: impl Into) -> Self { + self.account = Some(account.into()); + self + } + + pub fn with_status(mut self, status: impl Into) -> Self { + self.status = Some(status.into()); + self + } + + /// `CODEXBAR_*` environment variables for the hook process. + pub fn environment_variables(&self) -> HashMap { + let mut env = HashMap::new(); + env.insert("CODEXBAR_EVENT".into(), self.event.as_str().into()); + env.insert("CODEXBAR_PROVIDER".into(), self.provider.clone()); + env.insert("CODEXBAR_TIMESTAMP".into(), self.timestamp.clone()); + if let Some(account) = &self.account { + env.insert("CODEXBAR_ACCOUNT".into(), account.clone()); + } + if let Some(window) = &self.window { + env.insert("CODEXBAR_WINDOW".into(), window.clone()); + } + if let Some(usage) = self.usage_percent { + env.insert("CODEXBAR_USAGE_PERCENT".into(), format_number(usage)); + } + if let Some(remaining) = self.remaining_percent { + env.insert( + "CODEXBAR_REMAINING_PERCENT".into(), + format_number(remaining), + ); + } + if let Some(status) = &self.status { + env.insert("CODEXBAR_STATUS".into(), status.clone()); + } + env + } + + pub fn json_payload(&self) -> Result, String> { + serde_json::to_vec(self).map_err(|e| e.to_string()) + } +} + +/// One configured hook rule. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HookRule { + #[serde(default = "default_true")] + pub enabled: bool, + /// Single event (upstream shape). Prefer this for new configs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, + /// Optional multi-event list (task shape). Either `event` or `events` must match. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// For quota_low: fire when usage fraction ≥ threshold (0..=1). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, + pub executable: PathBuf, + #[serde(default)] + pub arguments: Vec, + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, +} + +fn default_true() -> bool { + true +} + +fn default_timeout_secs() -> u64 { + 10 +} + +impl HookRule { + fn matched_events(&self) -> Vec { + let mut out = self.events.clone(); + if let Some(event) = self.event { + if !out.contains(&event) { + out.push(event); + } + } + out + } + + pub fn matches(&self, event: &HookEvent) -> bool { + if !self.enabled { + return false; + } + if !self.matched_events().contains(&event.event) { + return false; + } + if !self.executable.is_absolute() || self.executable.as_os_str().is_empty() { + return false; + } + if self.timeout_secs == 0 || self.timeout_secs > 300 { + return false; + } + if let Some(provider) = &self.provider + && provider != &event.provider + { + return false; + } + if event.event == HookEventType::QuotaLow + && let Some(threshold) = self.threshold + { + let usage = event.usage_percent.unwrap_or(0.0); + if !(threshold.is_finite() && threshold > 0.0 && threshold <= 1.0) || usage < threshold + { + return false; + } + } + true + } +} + +/// Top-level hooks config file contents. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct HooksConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub events: Vec, +} + +impl HooksConfig { + pub const MAX_RULES: usize = 32; + pub const MAX_PAYLOAD_BYTES: usize = 4096; + + pub fn path() -> Option { + dirs::config_dir().map(|p| p.join("CodexBar").join("hooks.json")) + } + + pub fn load() -> Self { + let Some(path) = Self::path() else { + return Self::default(); + }; + Self::load_from(&path) + } + + pub fn load_from(path: &Path) -> Self { + let Ok(content) = std::fs::read_to_string(path) else { + return Self::default(); + }; + serde_json::from_str(content.trim_start_matches('\u{feff}')).unwrap_or_default() + } + + pub fn matching_rules(&self, event: &HookEvent) -> Vec<&HookRule> { + if !self.enabled || self.events.len() > Self::MAX_RULES { + return Vec::new(); + } + self.events.iter().filter(|rule| rule.matches(event)).collect() + } +} + +/// Env keys forwarded from the parent process (never secrets). +const FORWARDED_ENV_KEYS: &[&str] = &["PATH", "HOME", "USER", "TEMP", "TMP", "USERPROFILE"]; + +/// Builds the process environment for a hook. +pub fn build_hook_environment( + base: &HashMap, + event: &HookEvent, +) -> HashMap { + let mut env = HashMap::new(); + for key in FORWARDED_ENV_KEYS { + if let Some(value) = base.get(*key) { + env.insert((*key).to_string(), value.clone()); + } + } + for (key, value) in event.environment_variables() { + env.insert(key, value); + } + env +} + +/// Fire-and-forget hook runner. +pub struct HookRunner; + +impl HookRunner { + /// Dispatch matching rules for `event`. Failures are logged, never returned. + pub fn dispatch(event: &HookEvent, config: &HooksConfig, rate_limiter: &HookRateLimiter) { + let rules = config.matching_rules(event); + if rules.is_empty() { + return; + } + if event.event.is_rate_limited() && !rate_limiter.allow(event) { + tracing::debug!( + event = event.event.as_str(), + provider = %event.provider, + "hook suppressed by rate limiter" + ); + return; + } + let base_env: HashMap = std::env::vars().collect(); + for rule in rules { + if let Err(err) = Self::run(rule, event, &base_env) { + tracing::warn!( + event = event.event.as_str(), + provider = %event.provider, + reason = %err, + "hook failed" + ); + } else { + tracing::info!( + event = event.event.as_str(), + provider = %event.provider, + "ran hook" + ); + } + } + } + + /// Best-effort load + dispatch when settings allow hooks. + pub fn dispatch_if_enabled(event: HookEvent, hooks_enabled: bool) { + if !hooks_enabled { + return; + } + let config = HooksConfig::load(); + if !config.enabled { + return; + } + HOOK_RATE_LIMITER.with(|limiter| { + Self::dispatch(&event, &config, &limiter); + }); + } + + pub fn run( + rule: &HookRule, + event: &HookEvent, + base_env: &HashMap, + ) -> Result<(), String> { + let payload = event.json_payload()?; + if payload.len() > HooksConfig::MAX_PAYLOAD_BYTES { + return Err("payload too large".into()); + } + if !rule.executable.is_absolute() { + return Err("executable must be absolute".into()); + } + if !rule.executable.exists() { + return Err("executable not found".into()); + } + + let env = build_hook_environment(base_env, event); + let mut child = Command::new(&rule.executable) + .args(&rule.arguments) + .env_clear() + .envs(env) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("launch failed: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(&payload); + } + + let timeout = Duration::from_secs(rule.timeout_secs.max(1)); + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + return Ok(()); + } + return Err(format!( + "exit {}", + status.code().unwrap_or(-1) + )); + } + Ok(None) => { + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err("timed out".into()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(format!("wait failed: {e}")), + } + } + } +} + +/// In-process storm suppression for spammy events. +#[derive(Debug)] +pub struct HookRateLimiter { + last_fired: Mutex>, + window: Duration, +} + +impl HookRateLimiter { + pub const DEFAULT_WINDOW_SECS: u64 = 600; + + pub fn new(window: Duration) -> Self { + Self { + last_fired: Mutex::new(HashMap::new()), + window, + } + } + + pub fn allow(&self, event: &HookEvent) -> bool { + let key = rate_limit_key(event); + let Ok(mut map) = self.last_fired.lock() else { + return true; + }; + let now = Instant::now(); + if let Some(previous) = map.get(&key) + && now.duration_since(*previous) < self.window + { + return false; + } + map.insert(key, now); + true + } +} + +impl Default for HookRateLimiter { + fn default() -> Self { + Self::new(Duration::from_secs(Self::DEFAULT_WINDOW_SECS)) + } +} + +fn rate_limit_key(event: &HookEvent) -> String { + format!( + "{}\u{1f}{}\u{1f}{}\u{1f}{}", + event.event.as_str(), + event.provider, + event.account.as_deref().unwrap_or(""), + event.window.as_deref().unwrap_or("") + ) +} + +thread_local! { + static HOOK_RATE_LIMITER: HookRateLimiter = HookRateLimiter::default(); +} + +fn utc_now_iso() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // RFC3339-ish UTC without chrono dependency in this module path. + // Good enough for hooks; refresh path also has chrono elsewhere. + format_unix_utc(secs) +} + +fn format_unix_utc(secs: u64) -> String { + // Manual UTC formatting: YYYY-MM-DDTHH:MM:SSZ + const SECS_PER_DAY: u64 = 86_400; + let days = secs / SECS_PER_DAY; + let tod = secs % SECS_PER_DAY; + let hour = tod / 3600; + let min = (tod % 3600) / 60; + let sec = tod % 60; + let (year, month, day) = civil_from_days(days as i64); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") +} + +/// Howard Hinnant civil_from_days (proleptic Gregorian). +fn civil_from_days(z: i64) -> (i32, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as i32, m as u32, d as u32) +} + +fn format_number(value: f64) -> String { + if value.is_finite() && (value - value.round()).abs() < f64::EPSILON && value.abs() < 1e15 { + format!("{}", value as i64) + } else { + format!("{value}") + } +} + +/// Tracks last used% per provider/account/window so hooks fire on crossings only. +static QUOTA_HOOK_BASELINES: Mutex>> = Mutex::new(None); + +fn baseline_key(provider: &str, account: &str, window: &str) -> String { + format!("{provider}\u{1f}{account}\u{1f}{window}") +} + +/// Emit quota hooks when used% crosses high/critical/exhausted (best-effort). +/// +/// First sample only establishes a baseline — no fire on launch while already high. +pub fn emit_quota_threshold_hooks( + hooks_enabled: bool, + provider_cli: &str, + window: &str, + used_percent: f64, + high_threshold: f64, + critical_threshold: f64, + account: Option<&str>, +) { + if !hooks_enabled { + return; + } + let account = account.unwrap_or("").to_string(); + let key = baseline_key(provider_cli, &account, window); + let used = used_percent.clamp(0.0, 100.0); + + let previous = { + let Ok(mut guard) = QUOTA_HOOK_BASELINES.lock() else { + return; + }; + let map = guard.get_or_insert_with(HashMap::new); + let prev = map.get(&key).copied(); + map.insert(key, used); + prev + }; + let Some(previous) = previous else { + return; + }; + + let mut events: Vec = Vec::new(); + // Exhausted first so a jump 60→100 fires reached (and low is still useful). + if previous < 100.0 && used >= 100.0 { + events.push(HookEventType::QuotaReached); + } + if previous < critical_threshold && used >= critical_threshold && used < 100.0 { + events.push(HookEventType::QuotaLow); + } else if previous < high_threshold && used >= high_threshold && used < 100.0 { + events.push(HookEventType::QuotaLow); + } + // Reset edge: was exhausted, now recovered. + if previous >= 99.99 && used < 99.99 { + events.push(HookEventType::QuotaReset); + } + if events.is_empty() { + return; + } + + let provider = provider_cli.to_string(); + let window = window.to_string(); + std::thread::Builder::new() + .name("codexbar-hook".into()) + .spawn(move || { + for event_type in events { + let mut event = HookEvent::new(event_type, provider.clone()).with_used_percent(used); + if !window.is_empty() { + event = event.with_window(window.clone()); + } + if !account.is_empty() { + event = event.with_account(account.clone()); + } + HookRunner::dispatch_if_enabled(event, true); + } + }) + .ok(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn payload_env_includes_event_and_remaining() { + let event = HookEvent::new(HookEventType::QuotaLow, "claude") + .with_used_percent(80.0) + .with_window("session") + .with_account("user@example.com"); + let env = event.environment_variables(); + assert_eq!(env.get("CODEXBAR_EVENT").map(String::as_str), Some("quota_low")); + assert_eq!(env.get("CODEXBAR_PROVIDER").map(String::as_str), Some("claude")); + assert_eq!(env.get("CODEXBAR_WINDOW").map(String::as_str), Some("session")); + assert_eq!( + env.get("CODEXBAR_ACCOUNT").map(String::as_str), + Some("user@example.com") + ); + assert_eq!(env.get("CODEXBAR_USAGE_PERCENT").map(String::as_str), Some("0.8")); + assert_eq!( + env.get("CODEXBAR_REMAINING_PERCENT").map(String::as_str), + Some("20") + ); + let payload = event.json_payload().unwrap(); + assert!(payload.len() < HooksConfig::MAX_PAYLOAD_BYTES); + let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(parsed["event"], "quota_low"); + assert_eq!(parsed["provider"], "claude"); + assert!((parsed["remaining_percent"].as_f64().unwrap() - 20.0).abs() < 0.01); + } + + #[test] + fn build_hook_environment_whitelists_only_safe_keys() { + let mut base = HashMap::new(); + base.insert("PATH".into(), "/usr/bin".into()); + base.insert("HOME".into(), "/home/u".into()); + base.insert("USER".into(), "u".into()); + base.insert("TEMP".into(), "C:\\Temp".into()); + base.insert("SECRET_API_KEY".into(), "should-not-pass".into()); + base.insert("OPENAI_API_KEY".into(), "nope".into()); + let event = HookEvent::new(HookEventType::QuotaReached, "codex").with_used_percent(100.0); + let env = build_hook_environment(&base, &event); + assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); + assert_eq!(env.get("HOME").map(String::as_str), Some("/home/u")); + assert_eq!(env.get("USER").map(String::as_str), Some("u")); + assert_eq!(env.get("TEMP").map(String::as_str), Some("C:\\Temp")); + assert!(!env.contains_key("SECRET_API_KEY")); + assert!(!env.contains_key("OPENAI_API_KEY")); + assert_eq!( + env.get("CODEXBAR_EVENT").map(String::as_str), + Some("quota_reached") + ); + } + + #[test] + fn rate_limiter_suppresses_repeat_within_window() { + let limiter = HookRateLimiter::new(Duration::from_secs(600)); + let event = HookEvent::new(HookEventType::RefreshFailed, "cursor"); + assert!(limiter.allow(&event)); + assert!(!limiter.allow(&event)); + let other = HookEvent::new(HookEventType::RefreshFailed, "claude"); + assert!(limiter.allow(&other)); + } + + #[test] + fn rule_matches_event_list_and_provider() { + #[cfg(windows)] + let absolute = PathBuf::from(r"C:\Windows\System32\cmd.exe"); + #[cfg(not(windows))] + let absolute = PathBuf::from("/usr/bin/true"); + let rule = HookRule { + enabled: true, + event: None, + events: vec![HookEventType::QuotaLow, HookEventType::QuotaReached], + provider: Some("codex".into()), + threshold: Some(0.7), + executable: absolute, + arguments: vec![], + timeout_secs: 10, + }; + let match_event = HookEvent::new(HookEventType::QuotaLow, "codex").with_used_percent(80.0); + assert!(rule.matches(&match_event)); + let low = HookEvent::new(HookEventType::QuotaLow, "codex").with_used_percent(50.0); + assert!(!rule.matches(&low)); + let other = HookEvent::new(HookEventType::QuotaLow, "claude").with_used_percent(80.0); + assert!(!rule.matches(&other)); + } + + #[test] + fn config_matching_respects_enabled_flag() { + let rule = HookRule { + enabled: true, + event: Some(HookEventType::QuotaReset), + events: vec![], + provider: None, + threshold: None, + executable: PathBuf::from("C:\\Windows\\System32\\cmd.exe"), + arguments: vec![], + timeout_secs: 5, + }; + let event = HookEvent::new(HookEventType::QuotaReset, "claude"); + let disabled = HooksConfig { + enabled: false, + events: vec![rule.clone()], + }; + assert!(disabled.matching_rules(&event).is_empty()); + let enabled = HooksConfig { + enabled: true, + events: vec![rule], + }; + assert_eq!(enabled.matching_rules(&event).len(), 1); + } + + #[test] + fn relative_executable_never_matches() { + let rule = HookRule { + enabled: true, + event: Some(HookEventType::QuotaLow), + events: vec![], + provider: None, + threshold: None, + executable: PathBuf::from("notify.sh"), + arguments: vec![], + timeout_secs: 10, + }; + let event = HookEvent::new(HookEventType::QuotaLow, "codex").with_used_percent(90.0); + assert!(!rule.matches(&event)); + } + + #[test] + fn rate_limiter_key_is_stable() { + let a = HookEvent::new(HookEventType::ProviderUnavailable, "warp") + .with_window("session") + .with_account("a"); + let b = HookEvent::new(HookEventType::ProviderUnavailable, "warp") + .with_window("session") + .with_account("a"); + assert_eq!(rate_limit_key(&a), rate_limit_key(&b)); + let _ = Arc::new(a); + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index b3b3b9b178..eacd95d1db 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -2,6 +2,7 @@ mod cost_pricing; mod credential_migration; +mod hooks; mod http; mod jsonl_scanner; mod models_dev_pricing; @@ -18,6 +19,7 @@ mod widget_snapshot; pub use cost_pricing::*; pub use credential_migration::*; +pub use hooks::*; pub use http::*; pub use jsonl_scanner::*; pub use models_dev_pricing::*; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e39f14c48d..14b6f7fa60 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -164,6 +164,7 @@ locale_keys! { TabMenu, TabApiKeys, TabCookies, + TabUsageSpend, TabAdvanced, TabAbout, TabShortcuts, @@ -497,6 +498,21 @@ locale_keys! { SectionLocalIntegrations, PowerToysPipeLabel, PowerToysPipeHelper, + HooksTitle, + HooksCaption, + HooksEnableLabel, + HooksEnableHelper, + HooksConfigPathHint, + UsageSpendTitle, + UsageSpendCaption, + UsageSpendRefresh, + UsageSpendLoading, + UsageSpendEmpty, + UsageSpendColProvider, + UsageSpendCol7d, + UsageSpendCol30d, + UsageSpendColCurrency, + UsageSpendColSource, AgentSessionsTitle, AgentSessionsEnableLabel, AgentSessionsEnableHelper, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index f50226b2c5..5decb53f8f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -5,6 +5,7 @@ TabMenuBar = Menu Bar TabMenu = Menu TabApiKeys = API Keys TabCookies = Cookies +TabUsageSpend = Usage & Spend TabAdvanced = Advanced TabAbout = About TabShortcuts = Shortcuts @@ -276,6 +277,21 @@ HidePersonalInfoHelper = Mask emails and account names (good for streaming) SectionLocalIntegrations = Local integrations PowerToysPipeLabel = PowerToys status pipe PowerToysPipeHelper = Expose cached provider status and local usage totals to PowerToys Command Palette through a named pipe. No emails or plans are included. Restart required. +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). Events: quota_low, quota_reached, quota_reset. No shell; env is limited to PATH/HOME/USER/TEMP plus CODEXBAR_*. +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude (JSONL logs), plus period cost snapshots from other providers when available. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning… +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = Agent Sessions AgentSessionsEnableLabel = Show active Codex and Claude sessions AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 4d213ab40e..8f5cbbae2f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -5,6 +5,7 @@ TabMenuBar = Menu Bar TabMenu = Menu TabApiKeys = Claves API TabCookies = Cookies +TabUsageSpend = Usage & Spend TabAdvanced = Avanzado TabAbout = Acerca de TabShortcuts = Atajos @@ -270,6 +271,21 @@ HidePersonalInfoHelper = Ocultar correos y nombres de cuenta (útil para transmi SectionLocalIntegrations = Integraciones locales PowerToysPipeLabel = Canal de estado de PowerToys PowerToysPipeHelper = Exponer el estado de proveedores en cache y totales de uso local a PowerToys Command Palette mediante un canal con nombre. No incluye correos ni planes. Requiere reiniciar. +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude, plus period cost snapshots from other providers. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning... +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = Agent Sessions AgentSessionsEnableLabel = Show active Codex and Claude sessions AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 9e11a942d4..8bb1efc103 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -5,6 +5,7 @@ TabMenuBar = メニューバー TabMenu = メニュー TabApiKeys = API キー TabCookies = Cookie +TabUsageSpend = Usage & Spend TabAdvanced = 詳細 TabAbout = 情報 TabShortcuts = ショートカット @@ -270,6 +271,21 @@ HidePersonalInfoHelper = メールとアカウント名をマスク(配信時に SectionLocalIntegrations = ローカル連携 PowerToysPipeLabel = PowerToys ステータスパイプ PowerToysPipeHelper = 名前付きパイプ経由で、キャッシュ済みのプロバイダーステータスとローカル使用量の合計を PowerToys Command Palette に公開します。メールやプランは含まれません。再起動が必要です。 +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude, plus period cost snapshots from other providers. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning... +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = エージェントセッション AgentSessionsEnableLabel = アクティブな Codex / Claude セッションを表示 AgentSessionsEnableHelper = ローカルセッションとオプションの SSH ホストを検出。デフォルトはオフ。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index aa162c313e..b00a29efc9 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -5,6 +5,7 @@ TabMenuBar = Menu Bar TabMenu = Menu TabApiKeys = API 키 TabCookies = 쿠키 +TabUsageSpend = Usage & Spend TabAdvanced = 고급 TabAbout = 정보 TabShortcuts = 단축키 @@ -270,6 +271,21 @@ HidePersonalInfoHelper = 이메일 및 계정 이름 마스킹 (스트리밍 시 SectionLocalIntegrations = 로컬 통합 PowerToysPipeLabel = PowerToys 상태 파이프 PowerToysPipeHelper = 명명된 파이프를 통해 캐시된 공급자 상태와 로컬 사용량 합계를 PowerToys Command Palette에 노출합니다. 이메일이나 플랜은 포함되지 않습니다. 재시작이 필요합니다. +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude, plus period cost snapshots from other providers. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning... +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = Agent Sessions AgentSessionsEnableLabel = Show active Codex and Claude sessions AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index ece77c1edf..3ed674d2e5 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -5,6 +5,7 @@ TabMenuBar = 菜单栏 TabMenu = 菜单 TabApiKeys = API 密钥 TabCookies = Cookie +TabUsageSpend = Usage & Spend TabAdvanced = 高级 TabAbout = 关于 TabShortcuts = 快捷键 @@ -270,6 +271,21 @@ HidePersonalInfoHelper = 遮蔽邮箱和账号名称(适合直播时使用) SectionLocalIntegrations = 本机集成 PowerToysPipeLabel = PowerToys 状态管道 PowerToysPipeHelper = 通过命名管道向 PowerToys Command Palette 暴露已缓存的提供商状态和本地用量汇总。不包含邮箱或套餐。需要重启后生效。 +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude, plus period cost snapshots from other providers. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning... +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = Agent 会话 AgentSessionsEnableLabel = 显示活跃的 Codex 和 Claude 会话 AgentSessionsEnableHelper = 发现本地会话及可选的 SSH 主机。默认关闭。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index cdbbbd359b..c914265151 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -5,6 +5,7 @@ TabMenuBar = 選單欄 TabMenu = 選單 TabApiKeys = API 金鑰 TabCookies = Cookie +TabUsageSpend = Usage & Spend TabAdvanced = 高階 TabAbout = 關於 TabShortcuts = 快捷鍵 @@ -270,6 +271,21 @@ HidePersonalInfoHelper = 遮蔽郵箱和賬號名稱(適合直播時使用) SectionLocalIntegrations = 本機整合 PowerToysPipeLabel = PowerToys 狀態管道 PowerToysPipeHelper = 透過具名管道向 PowerToys Command Palette 公開已快取的供應商狀態與本機使用量總計。不包含電子郵件或方案資訊。需要重新啟動。 +HooksTitle = External hooks +HooksCaption = Run a local program when quota thresholds are crossed. Rules live in hooks.json next to settings. +HooksEnableLabel = Enable hooks +HooksEnableHelper = Master switch. hooks.json must also set enabled=true and list absolute executable paths. +HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). +UsageSpendTitle = Usage & Spend +UsageSpendCaption = Local estimated cost history for Codex and Claude, plus period cost snapshots from other providers. +UsageSpendRefresh = Refresh +UsageSpendLoading = Scanning... +UsageSpendEmpty = No spend data yet. +UsageSpendColProvider = Provider +UsageSpendCol7d = 7 days +UsageSpendCol30d = 30 days +UsageSpendColCurrency = Currency +UsageSpendColSource = Source AgentSessionsTitle = Agent 工作階段 AgentSessionsEnableLabel = 顯示活躍的 Codex 和 Claude 工作階段 AgentSessionsEnableHelper = 發現本地工作階段及可選的 SSH 主機。預設關閉。 diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 7f431a61e1..562c6af881 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -168,7 +168,12 @@ impl CursorApi { }) }) .unwrap_or_else(|| { - let mut cost = CostSnapshot::new(used_cents / 100.0, "USD", "Monthly"); + // Plan-included spend (cents → USD) when on-demand is off. + let mut cost = CostSnapshot::new( + used_cents / 100.0, + "USD", + plan_period_label(summary.billing_cycle_start.as_deref()), + ); if limit_cents > 0.0 { cost = cost.with_limit(limit_cents / 100.0); } @@ -246,7 +251,9 @@ impl CursorApi { return None; } - let mut cost = CostSnapshot::new(used_cents / 100.0, "USD", "Monthly"); + // usage-summary exposes on-demand spend in cents for the billing cycle. + // Label it explicitly so the tray/detail cost line is not a vague "Monthly". + let mut cost = CostSnapshot::new(used_cents / 100.0, "USD", "On-demand (billing cycle)"); if limit_cents > 0.0 { cost = cost.with_limit(limit_cents / 100.0); } @@ -277,6 +284,14 @@ fn clamp_percent(value: f64) -> f64 { value.clamp(0.0, 100.0) } +/// Period label for plan-included spend from usage-summary (no new network calls). +fn plan_period_label(billing_cycle_start: Option<&str>) -> String { + match billing_cycle_start { + Some(start) if !start.is_empty() => format!("Plan (since {start})"), + _ => "Plan (billing cycle)".to_string(), + } +} + impl Default for CursorApi { fn default() -> Self { Self::new() @@ -549,7 +564,28 @@ mod tests { let cost = cost.expect("cost should exist from on-demand usage"); assert!((cost.used - 3.5).abs() < 0.01); assert_eq!(cost.limit, Some(10.0)); - assert_eq!(cost.period, "Monthly"); + assert_eq!(cost.period, "On-demand (billing cycle)"); + } + + #[test] + fn plan_cost_period_uses_billing_cycle_start() { + let json = r#"{ + "billingCycleStart": "2026-03-01T00:00:00Z", + "billingCycleEnd": "2026-04-01T00:00:00Z", + "membershipType": "pro", + "individualUsage": { + "plan": { + "used": 2500, + "limit": 5000 + } + } + }"#; + let summary = parse_summary(json); + let (_, _, _, cost, _, _) = api().build_result(summary, None).unwrap(); + let cost = cost.expect("plan cost"); + assert!((cost.used - 25.0).abs() < 0.01); + assert_eq!(cost.limit, Some(50.0)); + assert_eq!(cost.period, "Plan (since 2026-03-01T00:00:00Z)"); } #[test] diff --git a/rust/src/settings.rs b/rust/src/settings.rs index f3a4e131d7..5a33396291 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -155,6 +155,10 @@ pub struct Settings { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agent_session_ssh_hosts: Vec, + /// Master switch for external hooks (`hooks.json` next to settings). + #[serde(default)] + pub hooks_enabled: bool, + /// Automatically download updates in the background #[serde(default)] pub auto_download_updates: bool, @@ -399,6 +403,7 @@ impl Default for Settings { codex_custom_sessions_dirs: Vec::new(), agent_sessions_enabled: false, agent_session_ssh_hosts: Vec::new(), + hooks_enabled: false, auto_download_updates: false, // Require explicit opt-in for background downloads install_updates_on_quit: false, // Don't auto-install on quit by default ui_language: Language::default(), // English by default diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 218b2cab90..3e2e7fa59e 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -110,6 +110,8 @@ pub(super) struct RawSettings { codex_custom_sessions_dirs: Vec, agent_sessions_enabled: bool, agent_session_ssh_hosts: Vec, + #[serde(default)] + hooks_enabled: bool, auto_download_updates: bool, install_updates_on_quit: bool, ui_language: Language, @@ -210,6 +212,7 @@ impl Default for RawSettings { codex_custom_sessions_dirs: s.codex_custom_sessions_dirs, agent_sessions_enabled: s.agent_sessions_enabled, agent_session_ssh_hosts: s.agent_session_ssh_hosts, + hooks_enabled: s.hooks_enabled, auto_download_updates: s.auto_download_updates, install_updates_on_quit: s.install_updates_on_quit, ui_language: s.ui_language, @@ -478,6 +481,7 @@ impl From for Settings { codex_custom_sessions_dirs: raw.codex_custom_sessions_dirs, agent_sessions_enabled: raw.agent_sessions_enabled, agent_session_ssh_hosts: raw.agent_session_ssh_hosts, + hooks_enabled: raw.hooks_enabled, auto_download_updates: raw.auto_download_updates, install_updates_on_quit: raw.install_updates_on_quit, ui_language: raw.ui_language,