diff --git a/rust/src/cli/tty_runner.rs b/rust/src/cli/tty_runner.rs index 789bfca131..02886da4b1 100755 --- a/rust/src/cli/tty_runner.rs +++ b/rust/src/cli/tty_runner.rs @@ -69,6 +69,20 @@ pub struct TtyCommandOptions { pub stop_on_substrings: Vec, /// Settle time after stopping (default: 0.25s) pub settle_after_stop_secs: f64, + /// Re-send the script at these offsets (seconds since launch) while none + /// of `script_done_substrings` has appeared in the output yet. Interactive + /// CLIs such as Claude Code drop keystrokes that arrive before their input + /// widget is mounted, and that readiness delay varies between machines. + pub script_retry_delays_secs: Vec, + /// Case-insensitive markers that show the script was accepted (stops retries). + pub script_done_substrings: Vec, + /// Case-insensitive markers that show the script text was at least echoed + /// into the input widget; a retry is skipped while one is visible so the + /// same text is not typed twice into a half-processed line. + pub script_echo_substrings: Vec, + /// Shorter idle timeout used once a done marker is visible: the answer + /// is on screen, so only trailing output is awaited (optional). + pub idle_timeout_after_done_secs: Option, /// Environment variables to set pub env: HashMap, } @@ -90,6 +104,10 @@ impl Default for TtyCommandOptions { stop_on_url: false, stop_on_substrings: Vec::new(), settle_after_stop_secs: 0.25, + script_retry_delays_secs: Vec::new(), + script_done_substrings: Vec::new(), + script_echo_substrings: Vec::new(), + idle_timeout_after_done_secs: None, env: HashMap::new(), } } @@ -145,6 +163,29 @@ impl TtyCommandOptions { self } + pub fn with_script_retries( + mut self, + delays_secs: Vec, + done_substrings: Vec, + echo_substrings: Vec, + ) -> Self { + self.script_retry_delays_secs = delays_secs; + self.script_done_substrings = done_substrings + .into_iter() + .map(|marker| marker.to_lowercase()) + .collect(); + self.script_echo_substrings = echo_substrings + .into_iter() + .map(|marker| marker.to_lowercase()) + .collect(); + self + } + + pub fn with_idle_timeout_after_done(mut self, secs: f64) -> Self { + self.idle_timeout_after_done_secs = Some(secs); + self + } + pub fn with_send_on_substring( mut self, trigger: impl Into, @@ -318,7 +359,7 @@ impl TtyCommandRunner { let mut buffer = String::new(); let mut stopped_early = false; let mut detected_urls = Vec::new(); - let mut last_output_time = Instant::now(); + let mut last_output_time; let mut triggered_sends = std::collections::HashSet::new(); // URL detection regex @@ -349,8 +390,38 @@ impl TtyCommandRunner { } }); - // Initial delay - std::thread::sleep(Duration::from_secs_f64(options.initial_delay_secs)); + // Initial delay. Keep draining output while waiting so a terminal + // capability query (ConPTY Device Status Report) sent during startup + // is answered instead of buffered silently; some TUIs block their + // input widget until the cursor position reply arrives. + let initial_deadline = start + Duration::from_secs_f64(options.initial_delay_secs); + loop { + let remaining = initial_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining.min(Duration::from_millis(50))) { + Ok(chunk) => { + tracing::trace!( + elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + bytes = chunk.len(), + "tty session: startup chunk" + ); + buffer.push_str(&chunk); + if chunk.contains("\x1b[6n") { + let _cursor_reply = write!(writer, "\x1b[1;1R"); + let _cursor_flushed = writer.flush(); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + tracing::trace!( + elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + buffered = buffer.len(), + "tty session: sending script" + ); // Send the script if provided. PTYs expect carriage-return line endings // for interactive programs to treat writes like pressing Enter. @@ -359,27 +430,17 @@ impl TtyCommandRunner { .map(str::trim_end) .filter(|line| !line.trim().is_empty()) .collect(); - for (idx, line) in script_lines.iter().enumerate() { - if options.script_char_delay_secs > 0.0 { - for ch in line.chars() { - // Best-effort scripted input; a closed PTY just drops the write. - let _typed = write!(writer, "{}", ch); - // Best-effort flush; failures surface only as missing script output. - let _flushed = writer.flush(); - std::thread::sleep(Duration::from_secs_f64(options.script_char_delay_secs)); - } - // Best-effort line terminator; interactive programs expect CRLF. - let _newline_written = write!(writer, "\r\n"); - } else { - // Best-effort whole-line write for the no-delay path. - let _line_written = write!(writer, "{}\r\n", line); - } - // Best-effort flush per script line. - let _line_flushed = writer.flush(); - if idx + 1 < script_lines.len() && options.script_line_delay_secs > 0.0 { - std::thread::sleep(Duration::from_secs_f64(options.script_line_delay_secs)); - } - } + write_script_lines(&mut writer, &script_lines, options); + // The idle timer measures silence after our input, not startup time. + last_output_time = Instant::now(); + let mut pending_script_retries: Vec = options + .script_retry_delays_secs + .iter() + .map(|secs| Duration::from_secs_f64(*secs)) + .collect(); + pending_script_retries.sort(); + let mut next_script_retry = pending_script_retries.into_iter(); + let mut upcoming_script_retry = next_script_retry.next(); let mut last_enter = Instant::now(); @@ -390,13 +451,22 @@ impl TtyCommandRunner { break; } - // Check idle timeout - if let Some(idle) = idle_timeout - && !buffer.is_empty() - && last_output_time.elapsed() > idle - { - stopped_early = true; - break; + // Check idle timeout (shorter once the done marker is on screen) + if !buffer.is_empty() { + let done_idle = options + .idle_timeout_after_done_secs + .filter(|_| script_accepted(&buffer, options)) + .map(Duration::from_secs_f64); + let effective_idle = match (idle_timeout, done_idle) { + (Some(idle), Some(done)) => Some(idle.min(done)), + (idle, done) => idle.or(done), + }; + if let Some(idle) = effective_idle + && last_output_time.elapsed() > idle + { + stopped_early = true; + break; + } } // Check if process has exited @@ -426,6 +496,11 @@ impl TtyCommandRunner { // Read available output while let Ok(chunk) = rx.try_recv() { + tracing::trace!( + elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + bytes = chunk.len(), + "tty session: output chunk" + ); buffer.push_str(&chunk); last_output_time = Instant::now(); @@ -488,6 +563,23 @@ impl TtyCommandRunner { break; } + // Re-send the script while the target program has not shown that it + // accepted the first attempt (input widget mounted late). + if let Some(retry_at) = upcoming_script_retry + && start.elapsed() >= retry_at + { + upcoming_script_retry = next_script_retry.next(); + if !script_lines.is_empty() && !script_accepted(&buffer, options) { + if script_echoed(&buffer, options) { + // Text arrived but Enter was swallowed: only confirm. + let _enter_written = write!(writer, "\r\n"); + let _enter_flushed = writer.flush(); + } else { + write_script_lines(&mut writer, &script_lines, options); + } + } + } + // Send periodic enters if configured if let Some(interval) = options.send_enter_every_secs && last_enter.elapsed() >= Duration::from_secs_f64(interval) @@ -515,10 +607,27 @@ impl TtyCommandRunner { } if child.try_wait().ok().flatten().is_none() { + tracing::trace!( + elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + "tty session: killing child tree" + ); + // On Windows several CLIs (claude.exe, npm shims) are launchers + // whose real process is a child; killing only the launcher leaves + // that child alive, holding the PTY session and, for Claude, the + // fixed --session-id ("already in use" on the next probe). Tear + // down the whole tree first. + #[cfg(windows)] + if let Some(pid) = child.process_id() { + kill_process_tree(pid); + } // Best-effort kill; the process may have exited on its own. let _killed = child.kill(); // Best-effort reap; the exit status is intentionally discarded. let _reaped = child.wait(); + tracing::trace!( + elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + "tty session: child reaped" + ); } if buffer.is_empty() && !stopped_early { @@ -630,10 +739,103 @@ impl RollingBuffer { } } +/// Type the script lines into the PTY, honouring the configured delays. +fn write_script_lines( + writer: &mut Box, + script_lines: &[&str], + options: &TtyCommandOptions, +) { + write_script_lines_impl(writer, script_lines, options) +} + +/// Kill a process and all of its descendants (best effort, Windows only). +#[cfg(windows)] +fn kill_process_tree(pid: u32) { + let mut cmd = Command::new("taskkill.exe"); + cmd.args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(0x0800_0000); // CREATE_NO_WINDOW + // Best-effort; a process that already exited is fine. + let _status = cmd.status(); +} + +fn write_script_lines_impl( + writer: &mut Box, + script_lines: &[&str], + options: &TtyCommandOptions, +) { + for (idx, line) in script_lines.iter().enumerate() { + if options.script_char_delay_secs > 0.0 { + for ch in line.chars() { + // Best-effort scripted input; a closed PTY just drops the write. + let _typed = write!(writer, "{}", ch); + // Best-effort flush; failures surface only as missing script output. + let _flushed = writer.flush(); + std::thread::sleep(Duration::from_secs_f64(options.script_char_delay_secs)); + } + // Best-effort line terminator; interactive programs expect CRLF. + let _newline_written = write!(writer, "\r\n"); + } else { + // Best-effort whole-line write for the no-delay path. + let _line_written = write!(writer, "{}\r\n", line); + } + // Best-effort flush per script line. + let _line_flushed = writer.flush(); + if idx + 1 < script_lines.len() && options.script_line_delay_secs > 0.0 { + std::thread::sleep(Duration::from_secs_f64(options.script_line_delay_secs)); + } + } +} + +/// True once any configured done marker is visible in the (ANSI-stripped) output. +fn script_accepted(buffer: &str, options: &TtyCommandOptions) -> bool { + contains_any_marker(buffer, &options.script_done_substrings) +} + +/// True while the typed script text is visible in the output (echoed input). +fn script_echoed(buffer: &str, options: &TtyCommandOptions) -> bool { + contains_any_marker(buffer, &options.script_echo_substrings) +} + +fn contains_any_marker(buffer: &str, markers: &[String]) -> bool { + if markers.is_empty() { + return false; + } + let ansi = Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]").ok(); + let clean = ansi + .map(|re| re.replace_all(buffer, "").to_string()) + .unwrap_or_else(|| buffer.to_string()) + .to_lowercase(); + markers.iter().any(|marker| clean.contains(marker)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn script_accepted_matches_markers_case_insensitively_through_ansi() { + let opts = TtyCommandOptions::new().with_script_retries( + vec![1.0], + vec!["Current session".to_string()], + vec!["/usage".to_string()], + ); + assert!(script_accepted( + "\x1b[1mCURRENT\x1b[0m session 12% used", + &opts + )); + assert!(!script_accepted("Try \"fix lint errors\"", &opts)); + assert!(!script_accepted( + "Current session", + &TtyCommandOptions::new() + )); + assert!(script_echoed("❯ /usa\x1b[0mge", &opts)); + assert!(!script_echoed("❯ Try \"fix lint errors\"", &opts)); + assert!(script_echoed("❯ /usage", &opts)); + } + #[test] fn test_tty_options_builder() { let opts = TtyCommandOptions::new() diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 22da9fe951..756d337d63 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -174,6 +174,93 @@ fn claude_plan_label(tier: &str) -> String { } const CLAUDE_PROBE_SESSION_ID_FILE: &str = ".codexbar-session-id"; +const CLAUDE_PROBE_LOCK_FILE: &str = ".codexbar-probe.lock"; +const CLAUDE_PROBE_CACHE_FILE: &str = ".codexbar-usage-cache.json"; +/// How long a second codexbar process waits for a running probe to finish. +const CLAUDE_PROBE_LOCK_WAIT: Duration = Duration::from_secs(30); +/// Every codexbar process (the `serve` daemon, one-off `usage` calls from the +/// companion) launches its own Claude CLI for a probe. The interactive +/// `/usage` screen costs 6-10 s of CPU each time, so a recent successful +/// probe output is shared across processes for this long. +const CLAUDE_PROBE_CACHE_TTL: Duration = Duration::from_secs(45); + +#[derive(serde::Serialize, serde::Deserialize)] +struct ClaudeProbeCache { + captured_at_unix: u64, + output: String, +} + +fn unix_now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn load_cached_probe_output(probe_dir: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(probe_dir.join(CLAUDE_PROBE_CACHE_FILE)).ok()?; + let cache: ClaudeProbeCache = serde_json::from_str(&raw).ok()?; + let age = unix_now_secs().saturating_sub(cache.captured_at_unix); + if age > CLAUDE_PROBE_CACHE_TTL.as_secs() || cache.output.trim().is_empty() { + return None; + } + tracing::debug!(age_secs = age, "Reusing recent Claude CLI probe output"); + Some(cache.output) +} + +fn store_cached_probe_output(probe_dir: &std::path::Path, output: &str) { + let cache = ClaudeProbeCache { + captured_at_unix: unix_now_secs(), + output: output.to_string(), + }; + if let Ok(json) = serde_json::to_string(&cache) + && let Err(err) = std::fs::write(probe_dir.join(CLAUDE_PROBE_CACHE_FILE), json) + { + tracing::debug!(error = %err, "failed to persist Claude probe cache"); + } +} + +/// Cross-process guard around the Claude PTY probe. Claude Code refuses to +/// start a session whose `--session-id` is already running ("Session ID … +/// is already in use"), so two codexbar processes (for example the +/// `serve` daemon and a one-off `usage` call) must not probe concurrently. +struct ClaudeProbeLock(std::fs::File); + +impl ClaudeProbeLock { + fn acquire(probe_dir: &std::path::Path) -> Option { + let path = probe_dir.join(CLAUDE_PROBE_LOCK_FILE); + let file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .ok()?; + let deadline = Instant::now() + CLAUDE_PROBE_LOCK_WAIT; + loop { + match file.try_lock() { + Ok(()) => return Some(Self(file)), + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(err)) => { + tracing::debug!(error = %err, "Claude probe lock unavailable; continuing unlocked"); + return None; + } + } + if Instant::now() >= deadline { + tracing::debug!("Claude probe lock wait expired; continuing unlocked"); + return None; + } + std::thread::sleep(Duration::from_millis(200)); + } + } +} + +impl Drop for ClaudeProbeLock { + fn drop(&mut self) { + // Best-effort unlock; closing the handle releases it anyway. + let _unlocked = self.0.unlock(); + } +} fn claude_usage_probe_dir() -> Result { let base = dirs::data_local_dir() @@ -221,6 +308,39 @@ fn cleanup_probe_session_jsonl(probe_dir: &std::path::Path) { let _removed = std::fs::remove_file(&path); } } + cleanup_probe_transcript(probe_dir); +} + +/// Claude stores the transcript for a working directory under +/// `~/.claude/projects//.jsonl`, where the cwd is +/// sanitized by replacing every non-alphanumeric character with `-` +/// (`C:\\Users\\x` -> `C--Users-x`). Remove the probe session transcript there, +/// otherwise the fixed `--session-id` fails with "already in use" on the next run. +fn cleanup_probe_transcript(probe_dir: &std::path::Path) { + let Some(home) = dirs::home_dir() else { + return; + }; + let project_dir = home + .join(".claude") + .join("projects") + .join(claude_project_dir_name(probe_dir)); + let Ok(entries) = std::fs::read_dir(&project_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + // Best-effort cleanup: a locked transcript just stays. + let _removed = std::fs::remove_file(&path); + } + } +} + +fn claude_project_dir_name(dir: &std::path::Path) -> String { + dir.to_string_lossy() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() } /// Arguments shared by every Claude CLI `/usage` probe. @@ -255,8 +375,30 @@ struct ClaudePtyProbeOptions { script_char_delay_secs: f64, script_line_delay_secs: f64, send_on_substring: Option<(&'static str, &'static str)>, + /// Re-type the script at these offsets while no done marker is visible. + script_retry_delays_secs: &'static [f64], + script_done_substrings: &'static [&'static str], + script_echo_substrings: &'static [&'static str], + /// Idle window after the done marker appeared (trailing output only). + idle_timeout_after_done_secs: Option, } +/// Offsets (seconds after launch) at which `/usage` is re-sent when Claude's +/// input widget was not ready for the first attempt. Claude Code needs roughly +/// 1-5 s to mount its prompt on Windows, and keystrokes before that are lost. +const CLAUDE_USAGE_RETRY_DELAYS_SECS: &[f64] = &[6.0, 9.5, 14.0]; +/// Output markers that prove `/usage` opened (limits view or activity stats). +const CLAUDE_USAGE_DONE_MARKERS: &[&str] = &[ + "current session", + "current week", + "total duration", + "favorite model:", + "total tokens:", +]; +/// The typed command as Claude echoes it into its prompt line. While this is +/// visible the first attempt is still being processed, so do not type again. +const CLAUDE_USAGE_ECHO_MARKERS: &[&str] = &["❯ /usage", "> /usage", "/usage show session cost"]; + async fn run_claude_usage_pty_probe( claude_path: std::path::PathBuf, working_directory: std::path::PathBuf, @@ -266,12 +408,16 @@ async fn run_claude_usage_pty_probe( working_directory, ClaudePtyProbeOptions { script: "/usage", - timeout_secs: 20.0, + timeout_secs: 24.0, idle_timeout_secs: Some(6.0), initial_delay_secs: 3.0, script_char_delay_secs: 0.04, script_line_delay_secs: 0.0, send_on_substring: None, + script_retry_delays_secs: CLAUDE_USAGE_RETRY_DELAYS_SECS, + script_done_substrings: CLAUDE_USAGE_DONE_MARKERS, + script_echo_substrings: CLAUDE_USAGE_ECHO_MARKERS, + idle_timeout_after_done_secs: Some(1.5), }, ) .await @@ -292,6 +438,10 @@ async fn run_claude_trust_preflight( script_char_delay_secs: 0.0, script_line_delay_secs: 0.0, send_on_substring: Some(("Enter", "\n/exit\n")), + script_retry_delays_secs: &[], + script_done_substrings: &[], + script_echo_substrings: &[], + idle_timeout_after_done_secs: None, }, ) .await @@ -309,9 +459,20 @@ async fn fetch_claude_cli_usage_text( claude_path: std::path::PathBuf, ) -> Result { let probe_dir = claude_usage_probe_dir()?; + if let Some(cached) = load_cached_probe_output(&probe_dir) { + return Ok(cached); + } let combined = run_claude_usage_pty_probe(claude_path.clone(), probe_dir.clone()).await?; - rerun_claude_usage_after_trust_prompt(claude_path, probe_dir, combined).await + let combined = + rerun_claude_usage_after_trust_prompt(claude_path, probe_dir.clone(), combined).await?; + // Only a parseable limits screen is worth sharing; errors are retried live. + if claude_cli_error_from_output(&combined).is_none() + && ClaudeProvider::new().parse_cli_output(&combined).is_ok() + { + store_cached_probe_output(&probe_dir, &combined); + } + Ok(combined) } async fn rerun_claude_usage_after_trust_prompt( @@ -399,6 +560,7 @@ async fn run_claude_pty_probe( // Keep ownership in the worker: cancelling the async refresh does not // stop spawn_blocking or its CLI process from rotating credentials. let _account_operation = accounts::CREDENTIAL_OPERATION.blocking_lock(); + let _probe_lock = ClaudeProbeLock::acquire(&working_directory); cleanup_probe_session_jsonl(&working_directory); let session_id = load_or_create_probe_session_id(&working_directory); let env = claude_passive_probe_env(TtyCommandRunner::enriched_environment()); @@ -413,9 +575,27 @@ async fn run_claude_pty_probe( if let Some(idle) = probe.idle_timeout_secs { options = options.with_idle_timeout(idle); } + if let Some(idle) = probe.idle_timeout_after_done_secs { + options = options.with_idle_timeout_after_done(idle); + } if let Some((trigger, keys)) = probe.send_on_substring { options = options.with_send_on_substring(trigger, keys); } + if !probe.script_retry_delays_secs.is_empty() { + options = options.with_script_retries( + probe.script_retry_delays_secs.to_vec(), + probe + .script_done_substrings + .iter() + .map(|marker| (*marker).to_string()) + .collect(), + probe + .script_echo_substrings + .iter() + .map(|marker| (*marker).to_string()) + .collect(), + ); + } options.env = env; TtyCommandRunner::new() @@ -672,6 +852,7 @@ impl ClaudeProvider { let claude_path = resolve_claude_cli_path()?; let combined = fetch_claude_cli_usage_text(claude_path).await?; + tracing::trace!(output = %strip_ansi(&combined), "Claude CLI probe output"); if let Some(error) = claude_cli_error_from_output(&combined) { return Err(error); @@ -701,12 +882,6 @@ impl ClaudeProvider { )); } - if is_cli_activity_stats_response(&clean_lower) { - return Err(ProviderError::Other( - "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.".to_string(), - )); - } - // Parse session percent: "X% used" or "X% left" let mut session_percent: Option = None; let mut weekly_percent: Option = None; @@ -723,8 +898,19 @@ impl ClaudeProvider { weekly_percent = Some(weekly_pct); } + // Newer Claude versions print local activity stats (cost, duration, + // cache tokens) below the plan limits. Those sections carry their own + // percentages, so only reject the output when no labelled limit + // percentage was found; otherwise the stats are just extra noise. + let activity_stats = is_cli_activity_stats_response(&clean_lower); + if activity_stats && session_percent.is_none() && weekly_percent.is_none() { + return Err(ProviderError::Other( + "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.".to_string(), + )); + } + // Fallback: collect all percentages in order - if session_percent.is_none() { + if session_percent.is_none() && !activity_stats { let all_percents = extract_all_percents(&clean); if !all_percents.is_empty() { session_percent = Some(all_percents[0]); @@ -1194,6 +1380,27 @@ mod tests { use super::*; + #[test] + fn probe_cache_roundtrip_and_expiry() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_cached_probe_output(dir.path()).is_none()); + store_cached_probe_output(dir.path(), "Current session 12% used"); + assert_eq!( + load_cached_probe_output(dir.path()).as_deref(), + Some("Current session 12% used") + ); + let stale = ClaudeProbeCache { + captured_at_unix: unix_now_secs() - CLAUDE_PROBE_CACHE_TTL.as_secs() - 5, + output: "Current session 12% used".to_string(), + }; + std::fs::write( + dir.path().join(CLAUDE_PROBE_CACHE_FILE), + serde_json::to_string(&stale).unwrap(), + ) + .unwrap(); + assert!(load_cached_probe_output(dir.path()).is_none()); + } + #[test] fn passive_probe_env_disables_autoupdater_and_color() { let env = claude_passive_probe_env(HashMap::new()); @@ -1237,6 +1444,18 @@ mod tests { #[test] fn probe_session_jsonl_cleanup_removes_transcript_files() { + assert_eq!( + claude_project_dir_name(std::path::Path::new( + r"C:\Users\erwin\AppData\Local\CodexBar\claude-usage-probe" + )), + "C--Users-erwin-AppData-Local-CodexBar-claude-usage-probe" + ); + assert_eq!( + claude_project_dir_name(std::path::Path::new( + "/Users/me/Library/Application Support/x" + )), + "-Users-me-Library-Application-Support-x" + ); let dir = tempfile::tempdir().unwrap(); let jsonl = dir.path().join("session.jsonl"); std::fs::write(&jsonl, "{}").unwrap(); @@ -1669,6 +1888,44 @@ Active days: 2/10 Longest streak: 1 day assert!(matches!(err, ProviderError::Other(_))); } + #[test] + fn accepts_plan_limits_followed_by_activity_stats() { + // Claude Code 2.1.27x on Windows prints the exit summary (cost, + // duration, cache tokens) after the /usage view when the probe ends. + let provider = ClaudeProvider::new(); + let output = r#" +❯ /usage + +Status Config Usage Stats + +Current session +███████░░░░░░░░░░░░░░░░░░░░░░ 19% used +Resets 3pm (Europe/Berlin) + +Current week (all models) +█████████░░░░░░░░░░░░░░░░░░░░ 31% used +Resets Sep 19, 4pm (Europe/Berlin) + +Total cost: $0.0000 +Total duration (API): 0s +Usage: 0 input, 0 output, 0 cache read +"#; + + let result = provider + .parse_cli_output(output) + .expect("plan limits should win over trailing activity stats"); + + assert_eq!(result.usage.primary.used_percent, 19.0); + assert_eq!( + result + .usage + .secondary + .as_ref() + .map(|window| window.used_percent), + Some(31.0) + ); + } + // ── Upstream 0.50.1 #2516: revoked vs missing OAuth ──────────────────────── #[test]