Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,17 @@ The Settings pane id is `usageSpend`. The pane and the Overview read one shared
- A missing spend source stays unknown. It never shows as `$0`.
- Totals are never converted across currencies.

### Claude local cost history

Claude local cost reads Claude transcript `.jsonl` files and prices assistant responses.

- The default history, with `CLAUDE_CONFIG_DIR` unset or blank and no mapped token account, also reads Claude Desktop on Windows: `%APPDATA%\Claude` and the Microsoft Store package stores. It takes the project stores and Cowork (local agent mode) `audit.jsonl` files.
- Cowork rows use the CLI transcript parser. A response present in both is counted once. `result` rows, which hold session totals, are never counted.
- A mapped token account or a set `CLAUDE_CONFIG_DIR` leaves Claude Desktop out. Only the default history includes it.
- Desktop discovery stops at 16,384 directory entries or 5 seconds. If it stops early, the Claude scan counts an unreadable root, so cost stays unavailable rather than showing a partial total.
- Symbolic links are not followed during Desktop discovery.
- Windows keeps no on-disk Claude cost cache. Each scan rereads the transcripts in the window.

### AWS Bedrock monitoring

AWS Bedrock uses signed Cost Explorer requests and, optionally, CloudWatch activity. It is disabled by default.
Expand Down
2 changes: 1 addition & 1 deletion rust/src/agent_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ pub struct AgentSessionDiscovery {
remote: RemoteSessionFetcher,
}

mod claude_desktop;
pub(crate) mod claude_desktop;
mod focus;
mod native_processes;
mod parsers;
Expand Down
113 changes: 110 additions & 3 deletions rust/src/agent_sessions/claude_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@
)]

use super::pi_family::DirectoryScanBudget;
use crate::browser::claude_desktop::msix_store_dirs;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

const SESSION_DIRECTORY_NAMES: [&str; 2] = ["local-agent-mode-sessions", "claude-code-sessions"];
const MAX_DEPTH: usize = 4;
/// Cowork (local agent mode) transcript file written beside a session's work.
const AUDIT_FILE_NAME: &str = "audit.jsonl";
/// Entry and wall-clock caps for one cost-history discovery pass.
const COST_SCAN_MAX_ENTRIES: usize = 16_384;
const COST_SCAN_TIME_BUDGET: Duration = Duration::from_secs(5);

const SKIPPED_DIRECTORY_NAMES: [&str; 7] = [
".build",
Expand All @@ -32,6 +40,29 @@ const SKIPPED_DIRECTORY_NAMES: [&str; 7] = [

pub(crate) struct ClaudeDesktopProjectsLocator;

/// Claude Desktop roots for cost history: project stores and Cowork audit
/// transcripts, in one sorted, de-duplicated list.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ClaudeDesktopCostRoots {
/// Canonical `.claude/projects` directories and `audit.jsonl` files.
pub(crate) paths: Vec<PathBuf>,
/// False when the walk ran out of its entry or time budget, so `paths`
/// may be missing roots that exist.
pub(crate) complete: bool,
}

/// Result of one walk below one application-data root.
struct Discovery {
roots: Vec<PathBuf>,
complete: bool,
}

/// Budget for one cost-history discovery pass. Discovery never shares the
/// live-session budget, so a slow Desktop tree cannot starve session listing.
pub(crate) fn cost_scan_budget() -> DirectoryScanBudget {
DirectoryScanBudget::new(COST_SCAN_MAX_ENTRIES, MAX_DEPTH, COST_SCAN_TIME_BUDGET)
}

impl ClaudeDesktopProjectsLocator {
/// Locate Claude Desktop roots in the current Windows user's AppData.
///
Expand All @@ -50,7 +81,51 @@ impl ClaudeDesktopProjectsLocator {
Vec::new()
}

/// Discover roots below an injected application-data directory.
/// Cost-history roots from the Windows Claude Desktop folders: the classic
/// `%APPDATA%\Claude` and each Microsoft Store package store, because MSIX
/// installs redirect Roaming AppData into the package.
#[cfg(windows)]
pub(crate) fn cost_roots(budget: &mut DirectoryScanBudget) -> ClaudeDesktopCostRoots {
let mut application_data = Vec::new();
if let Some(data_directory) = dirs::data_dir() {
application_data.push(data_directory.join("Claude"));
}
if let Some(local_directory) = dirs::data_local_dir() {
application_data.extend(msix_store_dirs(&local_directory));
}
Self::cost_roots_under(&application_data, budget)
}

#[cfg(not(windows))]
pub(crate) fn cost_roots(_budget: &mut DirectoryScanBudget) -> ClaudeDesktopCostRoots {
ClaudeDesktopCostRoots {
paths: Vec::new(),
complete: true,
}
}

/// Cost-history discovery below injected application-data directories.
/// Cowork `audit.jsonl` files are admitted beside the project stores. The
/// budget is shared across the roots, so a later root starts with whatever
/// remains and reports incomplete once it is spent.
pub(crate) fn cost_roots_under(
application_data_roots: &[PathBuf],
budget: &mut DirectoryScanBudget,
) -> ClaudeDesktopCostRoots {
let mut paths = Vec::new();
let mut complete = true;
for application_data_root in application_data_roots {
let found = Self::discover(application_data_root, budget, true);
paths.extend(found.roots);
complete &= found.complete;
}
paths.sort();
paths.dedup();
ClaudeDesktopCostRoots { paths, complete }
}

/// Discover live-session project roots below an injected application-data
/// directory.
///
/// This platform-neutral helper keeps the traversal deterministic and
/// makes the Windows deadline contract testable without touching a real
Expand All @@ -59,8 +134,19 @@ impl ClaudeDesktopProjectsLocator {
application_data_root: &Path,
budget: &mut DirectoryScanBudget,
) -> Vec<PathBuf> {
Self::discover(application_data_root, budget, false).roots
}

fn discover(
application_data_root: &Path,
budget: &mut DirectoryScanBudget,
include_audit_files: bool,
) -> Discovery {
if !budget.has_time_remaining() {
return Vec::new();
return Discovery {
roots: Vec::new(),
complete: false,
};
}

let session_roots = SESSION_DIRECTORY_NAMES
Expand Down Expand Up @@ -94,6 +180,17 @@ impl ClaudeDesktopProjectsLocator {
roots.push(canonical);
}

// Audit transcripts are cost inputs, not project roots. A symbolic
// link is never followed, matching the project-store rule.
let audit = current.join(AUDIT_FILE_NAME);
if include_audit_files
&& budget.has_time_remaining()
&& is_regular_file(&audit)
&& let Some(canonical) = budget.canonicalize_if_time_remaining(&audit)
{
roots.push(canonical);
}

if depth >= MAX_DEPTH || depth >= budget.max_depth() || !budget.has_time_remaining() {
continue;
}
Expand All @@ -113,10 +210,20 @@ impl ClaudeDesktopProjectsLocator {

roots.sort();
roots.dedup();
roots
// Every queued directory was visited, and neither the entry cap nor
// the clock stopped a listing or a check along the way.
let complete = next_index >= queue.len()
&& !budget.entry_limit_reached()
&& budget.has_time_remaining();
Discovery { roots, complete }
}
}

/// A regular file that is not a symbolic link.
fn is_regular_file(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_file())
}

fn is_skipped_directory_name(name: &str) -> bool {
SKIPPED_DIRECTORY_NAMES
.iter()
Expand Down
10 changes: 10 additions & 0 deletions rust/src/agent_sessions/pi_family/roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ impl DirectoryScanBudget {
self.max_depth
}

/// True once the entry cap is spent, so a walk may have skipped entries it
/// was asked for and its result is not the complete set.
#[cfg_attr(
not(windows),
allow(dead_code, reason = "only Windows Claude Desktop discovery reads it")
)]
pub(crate) fn entry_limit_reached(&self) -> bool {
self.entries_seen >= self.max_entry_count
}

fn allowed_depth(&self, depth: usize) -> bool {
depth <= self.max_depth
}
Expand Down
68 changes: 68 additions & 0 deletions rust/src/agent_sessions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,74 @@ bad line
assert!(expired.is_empty());
}

#[test]
fn claude_desktop_cost_discovery_admits_cowork_audit_files_only_for_cost() {
let app_data = tempfile::tempdir().expect("tempdir");
let session = app_data
.path()
.join("local-agent-mode-sessions")
.join("account")
.join("workspace")
.join("session");
fs::create_dir_all(&session).expect("session dir");
let audit = session.join("audit.jsonl");
fs::write(&audit, "{}\n").expect("audit file");
let projects = app_data
.path()
.join("claude-code-sessions")
.join("account")
.join(".claude")
.join("projects");
fs::create_dir_all(&projects).expect("desktop projects");
let roots_input = [app_data.path().to_path_buf()];

let mut budget = pi_family::DirectoryScanBudget::new(512, 4, Duration::from_secs(5));
let cost = claude_desktop::ClaudeDesktopProjectsLocator::cost_roots_under(
&roots_input,
&mut budget,
);
let mut expected = vec![
pi_family::canonicalize_for_scan(&audit),
pi_family::canonicalize_for_scan(&projects),
];
expected.sort();
assert_eq!(
cost,
claude_desktop::ClaudeDesktopCostRoots {
paths: expected,
complete: true,
}
);

// Live-session discovery keeps its project-only output.
let mut live_budget = pi_family::DirectoryScanBudget::new(512, 4, Duration::from_secs(5));
let live = claude_desktop::ClaudeDesktopProjectsLocator::roots_under(
app_data.path(),
&mut live_budget,
);
assert_eq!(live, vec![pi_family::canonicalize_for_scan(&projects)]);

// An expired clock and an exhausted entry cap both report incomplete.
let mut expired_budget = pi_family::DirectoryScanBudget::new_with_deadline_for_test(
512,
4,
Instant::now(),
);
let expired = claude_desktop::ClaudeDesktopProjectsLocator::cost_roots_under(
&roots_input,
&mut expired_budget,
);
assert!(expired.paths.is_empty());
assert!(!expired.complete);

let mut capped_budget = pi_family::DirectoryScanBudget::new(1, 4, Duration::from_secs(5));
let capped = claude_desktop::ClaudeDesktopProjectsLocator::cost_roots_under(
&roots_input,
&mut capped_budget,
);
assert!(!capped.complete);
}

#[test]
fn focus_rejects_remote_and_file_only_targets_explicitly() {
let remote = AgentSession {
Expand Down
4 changes: 3 additions & 1 deletion rust/src/browser/claude_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ pub(super) fn profiles(user_data_dir: &Path) -> Vec<BrowserProfile> {
}]
}

fn msix_store_dirs(local: &Path) -> Vec<PathBuf> {
/// Microsoft Store (MSIX) Claude package stores under `local`, sorted. The
/// directories are not checked for existence.
pub(crate) fn msix_store_dirs(local: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(local.join("Packages")) else {
return Vec::new();
};
Expand Down
2 changes: 1 addition & 1 deletion rust/src/browser/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Browser detection and cookie extraction for Windows and WSL

mod claude_desktop;
pub(crate) mod claude_desktop;
pub mod cookie_cache;
pub mod cookies;
pub mod detection;
Expand Down
Loading