From 0044316d0803197bbeed1fc1e0dfe642fd9f0f9d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:29:11 +0700 Subject: [PATCH 1/4] Scan Claude Desktop Cowork audit files for cost history Cost discovery admits Cowork audit.jsonl files and the Desktop project stores under %APPDATA%\Claude and the MSIX package stores, for the default Claude history only. A mapped token account or a set CLAUDE_CONFIG_DIR leaves them out. An incomplete discovery walk counts as an unreadable root. --- rust/src/agent_sessions.rs | 2 +- rust/src/agent_sessions/claude_desktop.rs | 113 ++++++++++++++++++- rust/src/agent_sessions/pi_family/roots.rs | 10 ++ rust/src/browser/claude_desktop.rs | 4 +- rust/src/browser/mod.rs | 2 +- rust/src/cost_scanner.rs | 124 ++++++++++++++++++--- rust/src/cost_scanner/claude_roots.rs | 52 +++++++++ 7 files changed, 288 insertions(+), 19 deletions(-) diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index ebd940056ed..bc7fb7eef8d 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -338,7 +338,7 @@ pub struct AgentSessionDiscovery { remote: RemoteSessionFetcher, } -mod claude_desktop; +pub(crate) mod claude_desktop; mod focus; mod native_processes; mod parsers; diff --git a/rust/src/agent_sessions/claude_desktop.rs b/rust/src/agent_sessions/claude_desktop.rs index c80eb6ac68c..d5ca50205da 100644 --- a/rust/src/agent_sessions/claude_desktop.rs +++ b/rust/src/agent_sessions/claude_desktop.rs @@ -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", @@ -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, + /// 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, + 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. /// @@ -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 @@ -59,8 +134,19 @@ impl ClaudeDesktopProjectsLocator { application_data_root: &Path, budget: &mut DirectoryScanBudget, ) -> Vec { + 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 @@ -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; } @@ -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() diff --git a/rust/src/agent_sessions/pi_family/roots.rs b/rust/src/agent_sessions/pi_family/roots.rs index 6248fe41d71..3699bf80b58 100644 --- a/rust/src/agent_sessions/pi_family/roots.rs +++ b/rust/src/agent_sessions/pi_family/roots.rs @@ -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 } diff --git a/rust/src/browser/claude_desktop.rs b/rust/src/browser/claude_desktop.rs index e37f09d903f..5ea9ff5eeb0 100644 --- a/rust/src/browser/claude_desktop.rs +++ b/rust/src/browser/claude_desktop.rs @@ -47,7 +47,9 @@ pub(super) fn profiles(user_data_dir: &Path) -> Vec { }] } -fn msix_store_dirs(local: &Path) -> Vec { +/// 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 { let Ok(entries) = std::fs::read_dir(local.join("Packages")) else { return Vec::new(); }; diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index 07afccf772c..28b3a90fcda 100755 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -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; diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs index 2784d3cacef..65a9bc2158b 100755 --- a/rust/src/cost_scanner.rs +++ b/rust/src/cost_scanner.rs @@ -15,6 +15,9 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::agent_sessions::claude_desktop::{ + ClaudeDesktopCostRoots, ClaudeDesktopProjectsLocator, cost_scan_budget, +}; #[cfg(test)] use crate::codex_costs::tests::scan_codex_file_cost; use crate::codex_costs::{ @@ -335,6 +338,7 @@ pub struct CostScanner { /// ambient `CODEX_HOME` database is used, unless sessions dirs are injected. codex_trace_database_override: Option, claude_account_directory: ClaudeAccountDirectory, + claude_desktop_app_data: ClaudeDesktopAppData, } /// Where a Claude scan learns the config directory mapped to the active token @@ -358,6 +362,29 @@ impl Default for ClaudeAccountDirectory { } } +/// Where a Claude scan finds Claude Desktop application data, which holds +/// Cowork audit transcripts and Desktop project stores (upstream 0.77.1 #3209). +#[derive(Debug, Clone)] +enum ClaudeDesktopAppData { + /// The per-user Claude Desktop folders: classic `%APPDATA%\Claude` and the + /// Microsoft Store package stores. + Discover, + /// Explicit application-data roots. Unit tests use this so the developer's + /// real Desktop folders never reach a fixture. + Fixed(Vec), +} + +impl Default for ClaudeDesktopAppData { + fn default() -> Self { + // Unit tests never read the developer's real Claude Desktop folders. + if cfg!(test) { + Self::Fixed(Vec::new()) + } else { + Self::Discover + } + } +} + impl CostScanner { /// Create a new scanner for the last N days (default 60s cache debounce). pub fn new(days: u32) -> Self { @@ -374,6 +401,7 @@ impl CostScanner { sessions_dirs_override: None, codex_trace_database_override: None, claude_account_directory: ClaudeAccountDirectory::default(), + claude_desktop_app_data: ClaudeDesktopAppData::default(), } } @@ -413,6 +441,14 @@ impl CostScanner { self } + /// Discover Claude Desktop cost roots below these application-data + /// directories instead of the per-user Desktop folders. + #[cfg(test)] + pub(crate) fn with_claude_desktop_app_data(mut self, roots: Vec) -> Self { + self.claude_desktop_app_data = ClaudeDesktopAppData::Fixed(roots); + self + } + /// Scan standalone Pi and OMP local history. /// /// Pi session rows can represent either Codex or Claude models. They are @@ -648,6 +684,10 @@ impl CostScanner { /// Existing Claude transcript roots: the profile root plus claude-swap /// session homes, de-duplicated by resolved path, or only the projects /// directory of the config directory mapped to the active token account. + /// + /// Claude Desktop roots (Cowork audit files and Desktop project stores) are + /// part of the default history only. A mapped token account and a set + /// `CLAUDE_CONFIG_DIR` both leave them out, as upstream's `costProjectsRoots`. fn claude_projects_roots(&self) -> claude_roots::ClaudeProjectsRoots { let account_dir = match &self.claude_account_directory { ClaudeAccountDirectory::ActiveTokenAccount => { @@ -658,10 +698,27 @@ impl CostScanner { if let Some(dir) = account_dir { return claude_roots::claude_account_projects_roots(&dir); } - claude_roots::claude_projects_roots( - std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref(), + let config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok(); + let mut roots = claude_roots::claude_projects_roots( + config_dir.as_deref(), crate::pi_session_cost::scan_home().as_deref(), - ) + ); + if claude_roots::config_dir_is_unset(config_dir.as_deref()) { + let desktop = self.claude_desktop_cost_roots(); + roots.add_desktop_roots(desktop.paths, desktop.complete); + } + roots + } + + /// Claude Desktop cost roots from the configured application-data source. + fn claude_desktop_cost_roots(&self) -> ClaudeDesktopCostRoots { + let mut budget = cost_scan_budget(); + match &self.claude_desktop_app_data { + ClaudeDesktopAppData::Discover => ClaudeDesktopProjectsLocator::cost_roots(&mut budget), + ClaudeDesktopAppData::Fixed(roots) => { + ClaudeDesktopProjectsLocator::cost_roots_under(roots, &mut budget) + } + } } /// Stream every in-window Claude usage record, de-duplicated across all @@ -729,10 +786,35 @@ impl CostScanner { F: FnMut(&Path), { roots.iter().fold(0u32, |failures, root| { - failures.saturating_add(self.walk_claude_files(root, cutoff, cancel, on_file)) + failures.saturating_add(self.walk_claude_root(root, cutoff, cancel, on_file)) }) } + /// Walk one root. A directory is walked for transcripts. A file root is + /// read directly when it is a `.jsonl` transcript, as Cowork audit files + /// are. Any other file cannot be a transcript root and counts as unreadable. + fn walk_claude_root( + &self, + root: &Path, + cutoff: &DateTime, + cancel: Option<&AtomicBool>, + on_file: &mut F, + ) -> u32 + where + F: FnMut(&Path), + { + match fs::metadata(root) { + Ok(metadata) if metadata.is_file() => { + if root.extension().is_some_and(|e| e == "jsonl") { + visit_transcript(root, &metadata, cutoff, on_file) + } else { + 1 + } + } + _ => self.walk_claude_files(root, cutoff, cancel, on_file), + } + } + fn walk_claude_files( &self, dir: &Path, @@ -770,15 +852,8 @@ impl CostScanner { .saturating_add(self.walk_claude_files(&path, cutoff, cancel, on_file)); } Ok(metadata) if path.extension().is_some_and(|e| e == "jsonl") => { - match metadata.modified() { - Ok(modified) => { - let modified_dt: DateTime = modified.into(); - if modified_dt >= *cutoff { - on_file(&path); - } - } - Err(_) => read_failures = read_failures.saturating_add(1), - } + read_failures = read_failures + .saturating_add(visit_transcript(&path, &metadata, cutoff, on_file)); } Ok(_) => {} Err(_) => read_failures = read_failures.saturating_add(1), @@ -787,3 +862,26 @@ impl CostScanner { read_failures } } + +/// Hands a transcript to `on_file` when it was modified inside the window. +/// Returns 1 when its modification time cannot be read. +fn visit_transcript( + path: &Path, + metadata: &fs::Metadata, + cutoff: &DateTime, + on_file: &mut F, +) -> u32 +where + F: FnMut(&Path), +{ + match metadata.modified() { + Ok(modified) => { + let modified_dt: DateTime = modified.into(); + if modified_dt >= *cutoff { + on_file(path); + } + 0 + } + Err(_) => 1, + } +} diff --git a/rust/src/cost_scanner/claude_roots.rs b/rust/src/cost_scanner/claude_roots.rs index 20a66200646..a492e7bc599 100644 --- a/rust/src/cost_scanner/claude_roots.rs +++ b/rust/src/cost_scanner/claude_roots.rs @@ -28,6 +28,32 @@ impl ClaudeProjectsRoots { pub(super) fn has_possible_roots(&self) -> bool { !self.paths.is_empty() || self.read_failures > 0 } + + /// Adds Claude Desktop cost roots (project stores and Cowork audit files), + /// de-duplicated by resolved path against the roots already present. An + /// incomplete discovery walk counts as a read failure, so the estimate + /// stays unavailable rather than silently missing Desktop history. + pub(super) fn add_desktop_roots(&mut self, desktop: Vec, complete: bool) { + if !complete { + self.read_failures = self.read_failures.saturating_add(1); + } + let mut seen: HashSet = self.paths.iter().map(|root| resolved(root)).collect(); + for root in desktop { + if seen.insert(resolved(&root)) { + self.paths.push(root); + } + } + } +} + +/// True when `CLAUDE_CONFIG_DIR` is unset or blank. Only the default history +/// takes Claude Desktop roots; a configured directory keeps them out. +pub(super) fn config_dir_is_unset(config_dir: Option<&str>) -> bool { + config_dir.map(str::trim).is_none_or(str::is_empty) +} + +fn resolved(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } /// Existing Claude transcript roots, de-duplicated by resolved path. @@ -397,4 +423,30 @@ mod tests { assert_eq!(roots.read_failures, 1); assert!(roots.has_possible_roots()); } + + #[test] + fn desktop_roots_join_once_and_an_incomplete_walk_is_a_read_failure() { + let home = tempfile::tempdir().unwrap(); + let projects = make_projects(&home.path().join(".claude")); + let audit = home.path().join("audit.jsonl"); + fs::write(&audit, b"{}\n").unwrap(); + + let mut roots = claude_projects_roots(None, Some(home.path())); + // The projects directory found again by Desktop discovery is not added twice. + roots.add_desktop_roots(vec![projects.clone(), audit.clone()], true); + assert_eq!(roots.paths, vec![projects.clone(), audit.clone()]); + assert_eq!(roots.read_failures, 0); + + roots.add_desktop_roots(Vec::new(), false); + assert_eq!(roots.read_failures, 1); + assert!(roots.has_possible_roots()); + } + + #[test] + fn only_an_unset_or_blank_config_dir_takes_desktop_roots() { + assert!(config_dir_is_unset(None)); + assert!(config_dir_is_unset(Some(""))); + assert!(config_dir_is_unset(Some(" "))); + assert!(!config_dir_is_unset(Some("D:\\claude-profile"))); + } } From a154e3df96e150201f2729a428b579a18fd677bc Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:29:18 +0700 Subject: [PATCH 2/4] Add Cowork cost history tests Cover audit file dedupe, ignored result rows, chart and daily consistency, the mapped-account and CLAUDE_CONFIG_DIR exclusions, live discovery staying project-only, and incomplete discovery when the clock or the entry cap runs out. --- rust/src/agent_sessions/tests.rs | 68 ++++++++ rust/src/cost_scanner/tests.rs | 3 + rust/src/cost_scanner/tests/claude_cowork.rs | 170 +++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 rust/src/cost_scanner/tests/claude_cowork.rs diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index c9342d4db93..deaf475de97 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -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 { diff --git a/rust/src/cost_scanner/tests.rs b/rust/src/cost_scanner/tests.rs index 88a291074c8..eab803c9f8b 100644 --- a/rust/src/cost_scanner/tests.rs +++ b/rust/src/cost_scanner/tests.rs @@ -26,6 +26,9 @@ mod claude_account; #[path = "tests/claude_today.rs"] mod claude_today; +#[cfg(test)] +#[path = "tests/claude_cowork.rs"] +mod claude_cowork; #[path = "tests/claude_hourly.rs"] mod claude_hourly; #[cfg(test)] diff --git a/rust/src/cost_scanner/tests/claude_cowork.rs b/rust/src/cost_scanner/tests/claude_cowork.rs new file mode 100644 index 00000000000..2da9ab5a6ec --- /dev/null +++ b/rust/src/cost_scanner/tests/claude_cowork.rs @@ -0,0 +1,170 @@ +//! Claude Desktop Cowork audit transcripts in Claude cost history (upstream +//! 0.77.1 #3209). Cowork rows use the same parser as CLI transcripts, so a +//! response present in both is counted once, and `result` rows never count. +//! Cowork history belongs to the default history only: a mapped token account +//! or a set `CLAUDE_CONFIG_DIR` leaves it out. +//! +//! Each fixture runs in a child process so `CLAUDE_CONFIG_DIR` and the home +//! directory are exactly the temp dirs this test sets. + +use super::*; + +const CHILD_MARKER: &str = "CODEXBAR_CLAUDE_COWORK_TEST_CHILD"; +const CHILD_DONE: &str = "claude cowork fixtures verified"; + +/// One assistant response. `session_key` is `sessionId` for CLI transcripts +/// and `session_id` for Cowork audit files. +fn response( + timestamp: &str, + session_key: &str, + session_id: &str, + message_id: &str, + input: u64, + output: u64, +) -> String { + format!( + r#"{{"type":"assistant","timestamp":"{timestamp}","{session_key}":"{session_id}","message":{{"id":"{message_id}","model":"claude-sonnet-4-6","usage":{{"input_tokens":{input},"output_tokens":{output}}}}}}}"# + ) +} + +/// A Cowork `result` row carries session totals. It must never be counted. +fn result_totals(timestamp: &str) -> String { + format!( + r#"{{"type":"result","timestamp":"{timestamp}","session_id":"s3","usage":{{"input_tokens":99999,"output_tokens":99999}}}}"# + ) +} + +fn write_lines(path: &Path, lines: &[String]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("{}\n", lines.join("\n"))).unwrap(); +} + +/// (input, output, sessions) of a summary, the three values this fixture pins. +fn totals(summary: &CostSummary) -> (u64, u64, u32) { + ( + summary.input_tokens, + summary.output_tokens, + summary.sessions_count, + ) +} + +fn child_body(config_dir_is_set: bool) { + let home = PathBuf::from(std::env::var_os("CODEXBAR_TEST_HOME").unwrap()); + let now = Utc::now().to_rfc3339(); + let app_data = home.join("appdata"); + let audit = app_data + .join("local-agent-mode-sessions") + .join("acct") + .join("ws") + .join("sess") + .join("audit.jsonl"); + + if config_dir_is_set { + // CLAUDE_CONFIG_DIR is set: only that folder is scanned, so the Cowork + // audit row (m3) must stay out and the folder's own row (m2) counts. + let config = PathBuf::from(std::env::var_os("CLAUDE_CONFIG_DIR").unwrap()); + write_lines( + &config.join("projects").join("p").join("a.jsonl"), + &[response(&now, "sessionId", "s2", "m2", 2000, 0)], + ); + write_lines( + &audit, + &[response(&now, "session_id", "s3", "m3", 300, 100)], + ); + let summary = CostScanner::new(1) + .with_claude_desktop_app_data(vec![app_data]) + .scan_claude(); + assert_eq!(totals(&summary), (2000, 0, 1), "configured dir only"); + } else { + // CLI history: m1 (s1) and m2 (s2) under the default projects folder. + write_lines( + &home + .join(".claude") + .join("projects") + .join("cli") + .join("cli.jsonl"), + &[ + response(&now, "sessionId", "s1", "m1", 1000, 500), + response(&now, "sessionId", "s2", "m2", 2000, 0), + ], + ); + // Cowork audit: m1 again (same session and message, so it dedupes), + // a new m3, and a result row whose totals must be ignored. + write_lines( + &audit, + &[ + response(&now, "session_id", "s1", "m1", 1000, 500), + response(&now, "session_id", "s3", "m3", 300, 100), + result_totals(&now), + ], + ); + + let summary = CostScanner::new(1) + .with_claude_desktop_app_data(vec![app_data.clone()]) + .scan_claude(); + // m1 once + m2 + m3 = 3300 input, 600 output, from two files. + assert_eq!(totals(&summary), (3300, 600, 2), "cowork deduped"); + assert!(summary.history_coverage_established); + + let chart = CostScanner::new(1) + .with_claude_desktop_app_data(vec![app_data]) + .scan_claude_chart_snapshot_with_cancel(None); + assert_eq!(totals(&chart.summary), (3300, 600, 2), "chart matches"); + let daily_tokens: u64 = chart.daily_tokens.iter().map(|(_, tokens)| tokens).sum(); + assert_eq!(daily_tokens, 3900, "daily tokens match the summary"); + + // A mapped token account scans only its own folder and never Cowork. + let account = home.join("account"); + write_lines( + &account.join("projects").join("p").join("a.jsonl"), + &[response(&now, "sessionId", "s2", "m2", 2000, 0)], + ); + let mapped = CostScanner::new(1) + .with_claude_desktop_app_data(vec![home.join("appdata")]) + .with_claude_account_config_dir(Some(account)) + .scan_claude(); + assert_eq!( + totals(&mapped), + (2000, 0, 1), + "mapped account excludes cowork" + ); + } + println!("{CHILD_DONE}"); +} + +fn run_child(test_name: &str, home: &Path, config_dir: Option<&Path>) { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .env(CHILD_MARKER, "1") + // Every home-relative root (claude-swap, Pi, OMP) resolves here. + .env("CODEXBAR_TEST_HOME", home) + .env_remove("CLAUDE_CONFIG_DIR"); + if let Some(config_dir) = config_dir { + command.env("CLAUDE_CONFIG_DIR", config_dir); + } + let output = command.output().expect("spawn isolated exact-test child"); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains(CHILD_DONE), + "fixture child failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn claude_cowork_history_counts_once_and_stays_in_default_history() { + if std::env::var_os(CHILD_MARKER).is_some() { + child_body(std::env::var_os("CLAUDE_CONFIG_DIR").is_some()); + return; + } + let test_thread = std::thread::current(); + let test_name = test_thread.name().expect("test harness names this thread"); + + let default_home = tempfile::tempdir().unwrap(); + run_child(test_name, default_home.path(), None); + + let configured_home = tempfile::tempdir().unwrap(); + let config = configured_home.path().join("config"); + run_child(test_name, configured_home.path(), Some(&config)); +} From 7863820ec464be74899a6e9403d9dee1551bd111 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:29:24 +0700 Subject: [PATCH 3/4] Document Claude local cost history Describe Cowork inclusion, the default-history-only rule, the Desktop discovery budget and the unavailable state after an incomplete walk. --- docs/PROVIDERS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 829d1424e39..c9d84c9b6bd 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -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. From e21a0db640e49f0eaa207c921ea2467587a14e93 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:50:22 +0700 Subject: [PATCH 4/4] Retrigger CI