diff --git a/Cargo.lock b/Cargo.lock index 3b0c2da8a..c57aa8058 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,7 +3363,6 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width 0.2.0", - "uuid", ] [[package]] @@ -3619,17 +3618,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "v_frame" version = "0.3.9" diff --git a/README.md b/README.md index 8b7585e6d..819932729 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ Some catalog entries have explicit boundaries: Tokscale applies the fixed total-only bucket allocation from ADR 0017. - `commandcode` is transcript-estimated usage, not authoritative vendor token accounting. -- `antigravity` uses a local cache refreshed by an explicit sync command. +- `antigravity` reads current AGY CLI SQLite/WAL data directly; the retired + IDE/2.0 private-RPC bridge is intentionally unsupported (ADR 0025). ## Data and pricing semantics diff --git a/README.zh-cn.md b/README.zh-cn.md index 45a47580d..a9fff2271 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -105,7 +105,8 @@ OpenCode、Claude Code、Codex CLI、Gemini CLI、Amp、Droid、OpenClaw、Pi、 - `grok` 和本地 `warp.sqlite` 只提供没有 bucket 拆分的 token 总数,因此 Tokscale 使用 ADR 0017 定义的固定 bucket 分配。 - `commandcode` 是基于 transcript 的估算用量,不是供应商权威 token 记账。 -- `antigravity` 使用显式 sync 命令刷新的本地缓存。 +- `antigravity` 直接读取当前 AGY CLI 的 SQLite/WAL 数据;已退役的 IDE/2.0 + 私有 RPC bridge 不受支持(ADR 0025)。 ## 数据和定价语义 diff --git a/crates/tokscale-cli/Cargo.toml b/crates/tokscale-cli/Cargo.toml index 601669b11..54fd67918 100644 --- a/crates/tokscale-cli/Cargo.toml +++ b/crates/tokscale-cli/Cargo.toml @@ -38,7 +38,6 @@ image = "0.25" imageproc = "0.25" ab_glyph = "0.2" hostname = "0.4" -uuid = { version = "1.0", features = ["v4"] } rpassword = "7.0" sha2 = "0.10" csv = "1.3" diff --git a/crates/tokscale-cli/src/antigravity.rs b/crates/tokscale-cli/src/antigravity.rs deleted file mode 100644 index 202a8af48..000000000 --- a/crates/tokscale-cli/src/antigravity.rs +++ /dev/null @@ -1,3115 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use std::fs; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::OnceLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -const MAX_RPC_BODY_BYTES: usize = 16 * 1024 * 1024; -const MAX_IDENTITY_PROBE_BYTES: usize = 4096; -const ANTIGRAVITY_MANIFEST_VERSION: i32 = 1; -#[cfg(test)] -const SYNC_LOCK_STALE_SECS: u64 = 600; -static HTTPS_RPC_RUNTIME: OnceLock = OnceLock::new(); -static HTTPS_RPC_CLIENT: OnceLock = OnceLock::new(); - -fn home_dir() -> Result { - dirs::home_dir().context("Could not determine home directory") -} - -fn antigravity_data_roots() -> Result> { - let gemini_dir = home_dir()?.join(".gemini"); - let mut roots = Vec::new(); - for name in ["antigravity-ide", "antigravity", "antigravity-backup"] { - let root = gemini_dir.join(name); - if !roots.contains(&root) { - roots.push(root); - } - } - Ok(roots) -} - -pub fn get_antigravity_cache_dir() -> Result { - // Route through `paths::get_config_dir()` so `TOKSCALE_CONFIG_DIR` - // covers the antigravity sync cache too — without this, an isolated - // CI profile would still leak to the host's - // `~/.config/tokscale/antigravity-cache/`. On macOS and Linux without - // an override the resolved path is byte-identical to the historic - // hardcoded `~/.config/tokscale/antigravity-cache/`, so existing - // users see no path change and no data migration is required. - Ok(crate::paths::get_config_dir().join("antigravity-cache")) -} - -pub fn get_antigravity_sessions_dir() -> Result { - Ok(get_antigravity_cache_dir()?.join("sessions")) -} - -pub fn get_antigravity_manifest_path() -> Result { - Ok(get_antigravity_cache_dir()?.join("manifest.json")) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AntigravityManifest { - pub version: i32, - #[serde(rename = "syncedAt")] - pub synced_at: Option, - pub connections: Vec, - pub sessions: Vec, -} - -#[derive(Debug, Clone)] -pub struct AntigravityConnection { - pub pid: u32, - pub port: u16, - pub csrf_token: String, - pub fingerprint: String, -} - -#[derive(Debug, Clone)] -struct ProcessCandidate { - pid: u32, - ppid: u32, - declared_port: Option, - csrf_token: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrajectorySummary { - #[serde(rename = "sessionId")] - pub session_id: String, - #[serde(rename = "lastModifiedMs")] - pub last_modified_ms: Option, - #[serde(rename = "stepCount")] - pub step_count: Option, - #[serde(rename = "connectionFingerprint")] - pub connection_fingerprint: String, -} - -impl Default for AntigravityManifest { - fn default() -> Self { - Self { - version: ANTIGRAVITY_MANIFEST_VERSION, - synced_at: None, - connections: Vec::new(), - sessions: Vec::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManifestConnectionEntry { - pub fingerprint: String, - pub pid: u32, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManifestSessionEntry { - #[serde(rename = "sessionId")] - pub session_id: String, - #[serde(rename = "artifactPath")] - pub artifact_path: String, - #[serde(rename = "lastModifiedMs")] - pub last_modified_ms: Option, - #[serde(rename = "stepCount")] - pub step_count: Option, - #[serde(rename = "connectionFingerprint")] - pub connection_fingerprint: String, - #[serde(rename = "artifactHash")] - pub artifact_hash: Option, -} - -#[derive(Debug, Serialize)] -struct AntigravityStatus { - #[serde(rename = "cacheDir")] - cache_dir: String, - #[serde(rename = "manifestPath")] - manifest_path: String, - #[serde(rename = "cacheExists")] - cache_exists: bool, - #[serde(rename = "sessionsDirExists")] - sessions_dir_exists: bool, - #[serde(rename = "manifestExists")] - manifest_exists: bool, - #[serde(rename = "detectedConnections")] - detected_connections: usize, - #[serde(rename = "cachedSessions")] - cached_sessions: usize, - #[serde(rename = "lastSyncedAt")] - last_synced_at: Option, -} - -#[derive(Debug, Clone)] -struct SessionArtifact { - contents: String, - last_modified_ms: Option, - step_count: Option, - artifact_hash: Option, -} - -#[derive(Debug, Clone)] -struct SessionCandidate { - session_id: String, - last_modified_ms: Option, - artifact_path: Option, -} - -pub fn run_antigravity_sync() -> Result<()> { - use colored::Colorize; - - let cache_dir = get_antigravity_cache_dir()?; - let sessions_dir = get_antigravity_sessions_dir()?; - ensure_config_dir()?; - ensure_dir(&cache_dir)?; - ensure_dir(&sessions_dir)?; - - let _lock = SyncLockGuard::acquire(&cache_dir)?; - - let manifest = load_antigravity_manifest()?; - let connections = detect_antigravity_connections()?; - let summaries = list_trajectory_summaries(&connections)?; - let filesystem_candidates = scan_filesystem_session_candidates()?; - let export_candidates = merge_export_candidates(&manifest, &summaries, &filesystem_candidates); - let mut next_manifest = AntigravityManifest { - version: ANTIGRAVITY_MANIFEST_VERSION, - synced_at: Some(chrono::Utc::now().to_rfc3339()), - connections: connections - .iter() - .map(|connection| ManifestConnectionEntry { - fingerprint: connection.fingerprint.clone(), - pid: connection.pid, - port: connection.port, - }) - .collect(), - sessions: Vec::new(), - }; - - for candidate in &export_candidates { - if let Some(summary) = find_summary_for_candidate(&summaries, &candidate.session_id) { - if let Some(artifact) = fetch_session_artifact(summary, &connections)? { - let path = write_session_artifact(&summary.session_id, &artifact.contents)?; - let relative_path = to_relative_artifact_path(&path)?; - - next_manifest.sessions.push(ManifestSessionEntry { - session_id: summary.session_id.clone(), - artifact_path: relative_path, - last_modified_ms: artifact.last_modified_ms, - step_count: artifact.step_count, - connection_fingerprint: summary.connection_fingerprint.clone(), - artifact_hash: artifact.artifact_hash, - }); - continue; - } - } - - if let Some(entry) = - fetch_historical_session_artifact(&candidate.session_id, &connections, candidate)? - { - next_manifest.sessions.push(entry); - continue; - } - - if let Some(previous) = manifest - .sessions - .iter() - .find(|entry| entry.session_id == candidate.session_id) - { - next_manifest.sessions.push(previous.clone()); - } - } - - next_manifest - .sessions - .sort_by(|left, right| left.session_id.cmp(&right.session_id)); - next_manifest - .sessions - .dedup_by(|left, right| left.session_id == right.session_id); - save_antigravity_manifest(&next_manifest)?; - cleanup_stale_session_artifacts(&manifest, &next_manifest)?; - - println!("\n {}", "Antigravity sync".cyan()); - println!( - " {}", - "Synced local Antigravity cache from running language servers.".bright_black() - ); - println!( - " {}", - format!("cache: {}", cache_dir.display()).bright_black() - ); - println!( - " {}", - format!("known sessions: {}", manifest.sessions.len()).bright_black() - ); - println!( - " {}", - format!("detected connections: {}", connections.len()).bright_black() - ); - println!( - " {}", - format!("detected sessions: {}", summaries.len()).bright_black() - ); - println!( - " {}", - format!("filesystem candidates: {}", filesystem_candidates.len()).bright_black() - ); - println!( - " {}", - format!("export candidates: {}", export_candidates.len()).bright_black() - ); - println!( - " {}", - format!( - "cached sessions after sync: {}", - next_manifest.sessions.len() - ) - .bright_black() - ); - println!(); - Ok(()) -} - -pub fn run_antigravity_status(json: bool) -> Result<()> { - use colored::Colorize; - - let cache_dir = get_antigravity_cache_dir()?; - let sessions_dir = get_antigravity_sessions_dir()?; - let manifest_path = get_antigravity_manifest_path()?; - let connections = detect_antigravity_connections()?; - let manifest = load_antigravity_manifest()?; - - let status = AntigravityStatus { - cache_dir: cache_dir.display().to_string(), - manifest_path: manifest_path.display().to_string(), - cache_exists: cache_dir.exists(), - sessions_dir_exists: sessions_dir.exists(), - manifest_exists: manifest_path.exists(), - detected_connections: connections.len(), - cached_sessions: manifest.sessions.len(), - last_synced_at: manifest.synced_at, - }; - - if json { - println!("{}", serde_json::to_string_pretty(&status)?); - return Ok(()); - } - - println!("\n {}", "Antigravity status".cyan()); - println!( - " {}", - format!("cache dir: {}", status.cache_dir).bright_black() - ); - println!( - " {}", - format!("sessions dir: {}", bool_label(status.sessions_dir_exists)).bright_black() - ); - println!( - " {}", - format!("manifest: {}", bool_label(status.manifest_exists)).bright_black() - ); - println!( - " {}", - format!("detected connections: {}", status.detected_connections).bright_black() - ); - println!( - " {}", - format!("cached sessions: {}", status.cached_sessions).bright_black() - ); - if let Some(last_synced_at) = &status.last_synced_at { - println!( - " {}", - format!("last synced: {}", last_synced_at).bright_black() - ); - } - println!( - " {}", - "Run `tokscale antigravity sync` to refresh the local cache before reporting." - .bright_black() - ); - println!(); - Ok(()) -} - -pub fn run_antigravity_purge_cache() -> Result<()> { - use colored::Colorize; - - let cache_dir = get_antigravity_cache_dir()?; - if cache_dir.exists() { - fs::remove_dir_all(&cache_dir)?; - println!( - "\n {}\n", - format!("✓ Deleted {}", cache_dir.display()).green() - ); - } else { - println!("\n {}\n", "No Antigravity cache to delete.".bright_black()); - } - Ok(()) -} - -fn ensure_dir(path: &Path) -> Result<()> { - if !path.exists() { - fs::create_dir_all(path)?; - } - Ok(()) -} - -fn ensure_config_dir() -> Result<()> { - let config_dir = crate::paths::get_config_dir(); - if !config_dir.exists() { - fs::create_dir_all(&config_dir)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700))?; - } - } - Ok(()) -} - -#[derive(Debug)] -struct SyncLockGuard { - path: PathBuf, -} - -const SYNC_LOCK_ACQUIRE_ATTEMPTS: usize = 3; - -impl SyncLockGuard { - fn acquire(cache_dir: &Path) -> Result { - let lock_path = cache_dir.join("sync.lock"); - let mut stale_recoveries = 0usize; - loop { - match std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&lock_path) - { - Ok(mut file) => { - let pid = std::process::id(); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let _ = writeln!(file, "{pid} {timestamp}"); - return Ok(SyncLockGuard { path: lock_path }); - } - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - // Only evict the lock when its owner is provably dead. - // Long-running syncs MUST keep exclusive access as - // long as their PID is alive, or two processes will - // overlap on the manifest and delete each other's - // artifacts. Age-based eviction was removed for this - // reason. - if let Some((existing_pid, _)) = read_sync_lock(&lock_path) { - if pid_is_alive(existing_pid) { - anyhow::bail!( - "Another tokscale antigravity sync is in progress (pid {existing_pid}); aborting" - ); - } - } - if stale_recoveries >= SYNC_LOCK_ACQUIRE_ATTEMPTS { - anyhow::bail!( - "Could not acquire Antigravity sync lock after {SYNC_LOCK_ACQUIRE_ATTEMPTS} stale-lock recoveries; another process keeps recreating the lock file" - ); - } - stale_recoveries += 1; - let _ = std::fs::remove_file(&lock_path); - continue; - } - Err(err) => { - return Err( - anyhow::Error::new(err).context("Failed to acquire Antigravity sync lock") - ); - } - } - } - } -} - -impl Drop for SyncLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} - -fn read_sync_lock(path: &Path) -> Option<(u32, u64)> { - let contents = std::fs::read_to_string(path).ok()?; - let mut parts = contents.split_whitespace(); - let pid = parts.next()?.parse::().ok()?; - let timestamp = parts.next()?.parse::().ok()?; - Some((pid, timestamp)) -} - -fn pid_is_alive(pid: u32) -> bool { - if pid == 0 { - return false; - } - #[cfg(unix)] - { - let result = unsafe { libc_kill(pid as i32, 0) }; - result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(1) - } - #[cfg(not(unix))] - { - let _ = pid; - false - } -} - -#[cfg(unix)] -extern "C" { - #[link_name = "kill"] - fn libc_kill(pid: i32, sig: i32) -> i32; -} - -pub fn load_antigravity_manifest() -> Result { - let manifest_path = get_antigravity_manifest_path()?; - if !manifest_path.exists() { - return Ok(AntigravityManifest::default()); - } - - let content = fs::read_to_string(&manifest_path).with_context(|| { - format!( - "Failed to read Antigravity manifest at {}", - manifest_path.display() - ) - })?; - - let manifest = match serde_json::from_str::(&content) { - Ok(manifest) => manifest, - Err(err) => { - let backup_path = backup_corrupted_manifest(&manifest_path); - eprintln!( - "Warning: Antigravity manifest at {} is corrupted: {err}; starting fresh{}", - manifest_path.display(), - backup_path - .map(|p| format!(" (moved aside to {})", p.display())) - .unwrap_or_default() - ); - return Ok(AntigravityManifest::default()); - } - }; - - if manifest.version > ANTIGRAVITY_MANIFEST_VERSION { - anyhow::bail!( - "Manifest from a newer tokscale version detected; refusing to overwrite (got version {}, supported {})", - manifest.version, - ANTIGRAVITY_MANIFEST_VERSION - ); - } - - if manifest.version < ANTIGRAVITY_MANIFEST_VERSION { - eprintln!( - "Info: Antigravity manifest at {} is at version {} (current {}); starting fresh", - manifest_path.display(), - manifest.version, - ANTIGRAVITY_MANIFEST_VERSION - ); - return Ok(AntigravityManifest::default()); - } - - Ok(manifest) -} - -fn backup_corrupted_manifest(manifest_path: &Path) -> Option { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let file_name = manifest_path.file_name()?.to_string_lossy().to_string(); - let backup_name = format!("{file_name}.corrupt-{timestamp}"); - let backup_path = manifest_path.with_file_name(backup_name); - fs::rename(manifest_path, &backup_path).ok()?; - Some(backup_path) -} - -pub fn save_antigravity_manifest(manifest: &AntigravityManifest) -> Result<()> { - ensure_config_dir()?; - let manifest_path = get_antigravity_manifest_path()?; - let json = serde_json::to_string_pretty(manifest)?; - atomic_write_file(&manifest_path, &json) -} - -pub fn write_session_artifact(session_id: &str, contents: &str) -> Result { - let file_name = session_artifact_file_stem(session_id); - let path = get_antigravity_sessions_dir()?.join(format!("{}.jsonl", file_name)); - atomic_write_file(&path, contents)?; - Ok(path) -} - -fn to_relative_artifact_path(path: &Path) -> Result { - Ok(path - .strip_prefix(get_antigravity_cache_dir()?) - .map(|value| value.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.to_string_lossy().to_string())) -} - -fn delete_artifact_relative_path(relative_path: &str) -> Result { - let artifact_path = resolve_cache_relative_artifact_path(relative_path)?; - if artifact_path.exists() { - fs::remove_file(&artifact_path)?; - return Ok(true); - } - Ok(false) -} - -fn resolve_cache_relative_artifact_path(relative_path: &str) -> Result { - let relative = Path::new(relative_path); - if relative.is_absolute() { - anyhow::bail!("Artifact path must stay within cache root"); - } - - if relative.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) { - anyhow::bail!("Artifact path must stay within cache root"); - } - - let path_text = relative.to_string_lossy(); - if !path_text.starts_with("sessions/") || !path_text.ends_with(".jsonl") { - anyhow::bail!("Artifact path must point to a session artifact"); - } - - let cache_dir = get_antigravity_cache_dir()?; - let candidate = cache_dir.join(relative); - - let canonical_root = cache_dir - .canonicalize() - .unwrap_or_else(|_| cache_dir.clone()); - let canonical_sessions = canonical_root.join("sessions"); - - if candidate.exists() { - let canonical_candidate = candidate.canonicalize().with_context(|| { - format!( - "Failed to canonicalize artifact path {}", - candidate.display() - ) - })?; - if !canonical_candidate.starts_with(&canonical_sessions) { - anyhow::bail!("Artifact path must stay within sessions cache root"); - } - return Ok(canonical_candidate); - } - - Ok(candidate) -} - -#[cfg(test)] -pub fn delete_session_artifact(relative_path: &str) -> Result { - delete_artifact_relative_path(relative_path) -} - -fn sanitize_session_id(session_id: &str) -> String { - let sanitized: String = session_id - .trim() - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { - c - } else { - '-' - } - }) - .collect(); - - let trimmed = sanitized.trim_matches('-'); - if trimmed.is_empty() { - "session".to_string() - } else { - trimmed.to_string() - } -} - -fn session_artifact_file_stem(session_id: &str) -> String { - use sha2::{Digest, Sha256}; - - let sanitized = sanitize_session_id(session_id); - let hash = Sha256::digest(session_id.as_bytes()); - let hash_prefix = format!("{:x}", hash); - format!("{}-{}", sanitized, &hash_prefix[..16]) -} - -fn atomic_write_file(path: &Path, contents: &str) -> Result<()> { - tokscale_core::fs_atomic::write_atomic(path, contents.as_bytes()) - .with_context(|| format!("Failed to persist file atomically: {}", path.display())) -} - -fn bool_label(value: bool) -> &'static str { - if value { - "yes" - } else { - "no" - } -} - -pub fn detect_antigravity_connections() -> Result> { - let candidates = detect_process_candidates()?; - let mut connections = Vec::new(); - - for candidate in candidates { - let ports = candidate_probe_ports(&candidate, find_listening_ports(candidate.pid)?); - for port in ports { - if probe_heartbeat(port, &candidate.csrf_token) { - connections.push(AntigravityConnection { - pid: candidate.pid, - port, - csrf_token: candidate.csrf_token.clone(), - fingerprint: format!("pid:{}:port:{}", candidate.pid, port), - }); - break; - } - } - } - - connections.sort_by(|left, right| { - right - .pid - .cmp(&left.pid) - .then_with(|| left.port.cmp(&right.port)) - }); - connections.dedup_by(|left, right| left.pid == right.pid && left.port == right.port); - - Ok(connections) -} - -fn candidate_probe_ports(candidate: &ProcessCandidate, mut ports: Vec) -> Vec { - if let Some(declared_port) = candidate.declared_port { - if !ports.contains(&declared_port) { - ports.push(declared_port); - } - } - - ports.sort_unstable(); - ports.dedup(); - ports -} - -fn detect_process_candidates() -> Result> { - #[cfg(target_os = "windows")] - { - return detect_windows_process_candidates(); - } - - #[cfg(not(target_os = "windows"))] - { - detect_unix_process_candidates() - } -} - -#[cfg(not(target_os = "windows"))] -fn detect_unix_process_candidates() -> Result> { - let output = run_command("ps", &["-ww", "-eo", "pid,ppid,args"])?; - let mut candidates = Vec::new(); - - for line in output.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.len() < 3 { - continue; - } - - let Ok(pid) = parts[0].parse::() else { - continue; - }; - let Ok(ppid) = parts[1].parse::() else { - continue; - }; - let command = parts[2..].join(" "); - if !is_antigravity_process(&command) { - continue; - } - - // Defense-in-depth: a same-user process can advertise matching CLI - // args to poison cache discovery. When exe-path introspection is - // available, accept the candidate only if the binary path looks - // like a language server or an antigravity binary, since - // `is_antigravity_process` already validated the antigravity - // affiliation via argv (e.g. `--app_data_dir antigravity` invoked - // against a generic `language_server` binary). Default to true on - // platforms where exe-path lookup is unavailable so detection does - // not regress. - let exe_ok = process_executable_path(pid) - .map(|path| { - let lower = path.to_string_lossy().to_lowercase(); - lower.contains("antigravity") || lower.contains("language_server") - }) - .unwrap_or(true); - if !exe_ok { - continue; - } - - let Some(csrf_token) = extract_csrf_token(&command) else { - continue; - }; - let declared_port = extract_declared_port(&command); - - candidates.push(ProcessCandidate { - pid, - ppid, - declared_port, - csrf_token, - }); - } - - candidates.sort_by(|left, right| { - right - .pid - .cmp(&left.pid) - .then_with(|| right.ppid.cmp(&left.ppid)) - .then_with(|| right.declared_port.cmp(&left.declared_port)) - }); - candidates.dedup_by(|left, right| left.pid == right.pid); - - Ok(candidates) -} - -#[cfg(target_os = "windows")] -fn detect_windows_process_candidates() -> Result> { - let script = "$ErrorActionPreference = 'Stop'; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress"; - let output = run_windows_powershell(script)?; - if output.trim().is_empty() { - anyhow::bail!( - "Windows process discovery returned no data; cannot discover Antigravity language servers" - ); - } - parse_windows_process_candidates(&output) -} - -#[cfg(any(test, target_os = "windows"))] -fn parse_windows_process_candidates(output: &str) -> Result> { - let value: Value = serde_json::from_str(output.trim()) - .context("Failed to parse Windows process discovery JSON")?; - let items: Vec<&Value> = match &value { - Value::Array(values) => values.iter().collect(), - Value::Object(_) => vec![&value], - Value::Null => Vec::new(), - _ => { - anyhow::bail!("Windows process discovery JSON must be an object or array"); - } - }; - let mut candidates = Vec::new(); - - for item in items { - let Some(pid) = item - .get("ProcessId") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - else { - continue; - }; - let ppid = item - .get("ParentProcessId") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(0); - let command = item - .get("CommandLine") - .and_then(Value::as_str) - .unwrap_or_default(); - if !is_antigravity_process(command) { - continue; - } - - let executable_path = item.get("ExecutablePath").and_then(Value::as_str); - if !windows_candidate_executable_ok(executable_path, command) { - continue; - } - - let Some(csrf_token) = extract_csrf_token(command) else { - continue; - }; - let declared_port = extract_declared_port(command); - - candidates.push(ProcessCandidate { - pid, - ppid, - declared_port, - csrf_token, - }); - } - - candidates.sort_by(|left, right| { - right - .pid - .cmp(&left.pid) - .then_with(|| right.ppid.cmp(&left.ppid)) - .then_with(|| right.declared_port.cmp(&left.declared_port)) - }); - candidates.dedup_by(|left, right| left.pid == right.pid); - - Ok(candidates) -} - -#[cfg(any(test, target_os = "windows"))] -fn windows_candidate_executable_ok(executable_path: Option<&str>, command: &str) -> bool { - executable_path - .filter(|path| !path.trim().is_empty()) - .map(executable_path_looks_antigravity) - .unwrap_or_else(|| command_line_executable_looks_antigravity(command)) -} - -#[cfg(any(test, target_os = "windows"))] -fn executable_path_looks_antigravity(path: &str) -> bool { - let lower = path.to_lowercase(); - lower.contains("antigravity") || lower.contains("language_server") -} - -#[cfg(any(test, target_os = "windows"))] -fn command_line_executable_looks_antigravity(command: &str) -> bool { - let first = command - .trim_start() - .strip_prefix('"') - .and_then(|rest| rest.split('"').next()) - .unwrap_or_else(|| command.split_whitespace().next().unwrap_or_default()); - executable_path_looks_antigravity(first) -} - -fn is_antigravity_process(command: &str) -> bool { - let lower = command.to_lowercase(); - (lower.contains("language_server") - && (lower.contains("antigravity") || lower.contains("--app_data_dir antigravity"))) - || lower.contains("/antigravity/") - || lower.contains("\\antigravity\\") -} - -fn process_executable_path(pid: u32) -> Option { - #[cfg(target_os = "linux")] - { - let link = format!("/proc/{pid}/exe"); - std::fs::read_link(&link).ok() - } - #[cfg(target_os = "macos")] - { - let pid_str = pid.to_string(); - let output = run_command("lsof", &["-p", &pid_str, "-Fn"]).ok()?; - for line in output.lines() { - if let Some(rest) = line.strip_prefix('n') { - if rest.contains(".app/Contents/MacOS/") { - return Some(PathBuf::from(rest)); - } - } - } - None - } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - let _ = pid; - None - } -} - -fn extract_csrf_token(command: &str) -> Option { - let token = extract_flag_value(command, "--csrf_token")?; - if token.len() >= 32 && token.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-') { - Some(token) - } else { - None - } -} - -fn extract_declared_port(command: &str) -> Option { - extract_flag_value(command, "--extension_server_port")? - .parse::() - .ok() -} - -fn extract_flag_value(command: &str, flag: &str) -> Option { - let compact = format!("{}=", flag); - if let Some(idx) = command.find(&compact) { - let rest = &command[idx + compact.len()..]; - return rest - .split_whitespace() - .next() - .map(|value| value.to_string()); - } - - let idx = command.find(flag)?; - let rest = &command[idx + flag.len()..]; - rest.split_whitespace() - .find(|value| !value.is_empty()) - .map(|value| value.trim().to_string()) -} - -fn find_listening_ports(pid: u32) -> Result> { - #[cfg(target_os = "windows")] - { - return find_windows_listening_ports(pid); - } - - #[cfg(not(target_os = "windows"))] - { - find_unix_listening_ports(pid) - } -} - -#[cfg(not(target_os = "windows"))] -fn find_unix_listening_ports(pid: u32) -> Result> { - let pid_str = pid.to_string(); - let mut ports = run_port_query( - "lsof", - "lsof", - &["-Pan", "-p", &pid_str, "-iTCP", "-sTCP:LISTEN"], - )?; - - if ports.is_empty() { - ports = run_port_query("lsof", "lsof", &["-Pan", "-p", &pid_str, "-i"])?; - } - - ports.sort_unstable(); - ports.dedup(); - Ok(ports) -} - -#[cfg(target_os = "windows")] -fn find_windows_listening_ports(pid: u32) -> Result> { - let output = run_command_required("netstat", &["-ano", "-p", "TCP"]) - .context("Failed to discover Windows TCP listeners with netstat")?; - Ok(parse_windows_netstat_ports(&output, pid)) -} - -#[cfg(any(test, target_os = "windows"))] -fn parse_windows_netstat_ports(output: &str, pid: u32) -> Vec { - let mut ports = Vec::new(); - let pid_text = pid.to_string(); - - for line in output.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 5 { - continue; - } - if !parts[0].eq_ignore_ascii_case("TCP") { - continue; - } - if !parts[3].eq_ignore_ascii_case("LISTENING") || parts[4] != pid_text { - continue; - } - if let Some(port) = parse_port_from_windows_address(parts[1]) { - ports.push(port); - } - } - - ports.sort_unstable(); - ports.dedup(); - ports -} - -#[cfg(any(test, target_os = "windows"))] -fn parse_port_from_windows_address(address: &str) -> Option { - let (_, port) = address.rsplit_once(':')?; - port.parse::().ok() -} - -fn run_port_query(program: &str, warning_label: &str, args: &[&str]) -> Result> { - match run_command(program, args) { - Ok(output) => Ok(parse_ports(&output)), - Err(err) if is_command_not_found(&err) => { - eprintln!( - "Warning: {} is unavailable; skipping port discovery", - warning_label - ); - Ok(Vec::new()) - } - Err(err) => Err(err), - } -} - -fn is_command_not_found(err: &anyhow::Error) -> bool { - err.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) - }) -} - -fn parse_ports(output: &str) -> Vec { - let mut ports = Vec::new(); - for line in output.lines() { - if let Some(port) = parse_port_from_line(line) { - ports.push(port); - } - } - ports -} - -fn parse_port_from_line(line: &str) -> Option { - for token in line.split_whitespace() { - if let Some(port) = token - .strip_prefix("127.0.0.1:") - .or_else(|| token.strip_prefix("localhost:")) - .or_else(|| token.strip_prefix("*:")) - .or_else(|| token.strip_prefix("::1:")) - { - let cleaned = port.trim_end_matches("(LISTEN)").trim_end_matches(','); - if let Ok(parsed) = cleaned.parse::() { - return Some(parsed); - } - } - } - - if let Some(idx) = line.rfind(':') { - let rest = line[idx + 1..].trim(); - let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect(); - if !digits.is_empty() { - return digits.parse::().ok(); - } - } - - None -} - -fn probe_heartbeat(port: u16, csrf_token: &str) -> bool { - if probe_plain_http_heartbeat(port, csrf_token) { - return true; - } - - probe_https_heartbeat(port, csrf_token) -} - -fn probe_https_heartbeat(port: u16, csrf_token: &str) -> bool { - let connection = AntigravityConnection { - pid: 0, - port, - csrf_token: csrf_token.to_string(), - fingerprint: format!("port:{port}"), - }; - let body = serde_json::json!({ "uuid": "00000000-0000-0000-0000-000000000000" }); - let Ok(response) = https_rpc_request(&connection, "Heartbeat", &body) else { - return false; - }; - if !heartbeat_value_looks_well_formed(&response) { - return false; - } - - true -} - -fn probe_plain_http_heartbeat(port: u16, csrf_token: &str) -> bool { - let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)) else { - return false; - }; - - let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); - - let body = r#"{"uuid":"00000000-0000-0000-0000-000000000000"}"#; - let request = format!( - "POST /exa.language_server_pb.LanguageServerService/Heartbeat HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnect-Protocol-Version: 1\r\nX-Codeium-Csrf-Token: {}\r\nConnection: close\r\n\r\n{}", - port, - body.len(), - csrf_token, - body - ); - - if stream.write_all(request.as_bytes()).is_err() { - return false; - } - - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - if reader.read_line(&mut status_line).is_err() { - return false; - } - - let status_ok = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()) - .is_some_and(|status| status == 200); - if !status_ok { - return false; - } - - loop { - let mut header = String::new(); - if reader.read_line(&mut header).is_err() { - return false; - } - if header.trim().is_empty() { - break; - } - } - - let mut buffer = String::new(); - let _ = reader - .by_ref() - .take(MAX_IDENTITY_PROBE_BYTES as u64) - .read_to_string(&mut buffer); - - if !heartbeat_response_looks_well_formed(&buffer) { - return false; - } - - probe_endpoint_identity(port, csrf_token) -} - -fn heartbeat_value_looks_well_formed(value: &Value) -> bool { - value.is_object() || value.is_array() -} - -fn heartbeat_response_looks_well_formed(body: &str) -> bool { - let trimmed = body.trim_start(); - let json_start = trimmed.find(['{', '[']).map(|idx| &trimmed[idx..]); - let Some(slice) = json_start else { - return false; - }; - serde_json::from_str::(slice).is_ok() -} - -fn probe_endpoint_identity(port: u16, csrf_token: &str) -> bool { - for method in [ - "GetCascadeTrajectoryGeneratorMetadata", - "GetAllCascadeTrajectories", - ] { - if let Some(body) = identity_probe_request(port, csrf_token, method) { - if response_contains_antigravity_marker(&body) { - return true; - } - } - } - false -} - -fn identity_probe_request(port: u16, csrf_token: &str, method: &str) -> Option { - if let Some(body) = plain_http_identity_probe_request(port, csrf_token, method) { - return Some(body); - } - - https_identity_probe_request(port, csrf_token, method) -} - -fn https_identity_probe_request(port: u16, csrf_token: &str, method: &str) -> Option { - let connection = AntigravityConnection { - pid: 0, - port, - csrf_token: csrf_token.to_string(), - fingerprint: format!("port:{port}"), - }; - let response = https_rpc_request(&connection, method, &serde_json::json!({})).ok()?; - serde_json::to_string(&response).ok() -} - -fn plain_http_identity_probe_request(port: u16, csrf_token: &str, method: &str) -> Option { - let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; - let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); - - let body = r#"{}"#; - let request = format!( - "POST /exa.language_server_pb.LanguageServerService/{} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnect-Protocol-Version: 1\r\nX-Codeium-Csrf-Token: {}\r\nConnection: close\r\n\r\n{}", - method, - port, - body.len(), - csrf_token, - body - ); - - if stream.write_all(request.as_bytes()).is_err() { - return None; - } - - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - if reader.read_line(&mut status_line).is_err() { - return None; - } - - let status_ok = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()) - .is_some_and(|status| status == 200); - - let mut content_length: Option = None; - let mut chunked = false; - loop { - let mut header = String::new(); - if reader.read_line(&mut header).is_err() { - return None; - } - let trimmed = header.trim(); - if trimmed.is_empty() { - break; - } - - let lower = trimmed.to_ascii_lowercase(); - if let Some(value) = lower.strip_prefix("content-length:") { - content_length = value.trim().parse::().ok(); - } - if lower.contains("transfer-encoding") && lower.contains("chunked") { - chunked = true; - } - } - - if !status_ok { - return None; - } - - // RFC 7230 §3.3.3: when Transfer-Encoding is present, Content-Length MUST - // be ignored. Check chunked first so a server that sets both headers is - // decoded correctly. - if chunked { - return read_chunked_body_prefix(&mut reader, MAX_IDENTITY_PROBE_BYTES).ok(); - } - - if let Some(length) = content_length { - let read_length = length.min(MAX_IDENTITY_PROBE_BYTES); - let mut bytes = vec![0_u8; read_length]; - reader.read_exact(&mut bytes).ok()?; - return String::from_utf8(bytes).ok(); - } - - let mut buffer = String::new(); - reader - .by_ref() - .take(MAX_IDENTITY_PROBE_BYTES as u64) - .read_to_string(&mut buffer) - .ok()?; - Some(buffer) -} - -fn response_contains_antigravity_marker(body: &str) -> bool { - let trimmed = body.trim_start(); - let json_start = trimmed.find(['{', '[']); - let Some(idx) = json_start else { - return false; - }; - let Ok(value) = serde_json::from_str::(&trimmed[idx..]) else { - return prefix_contains_antigravity_marker(&trimmed[idx..]); - }; - contains_antigravity_marker(&value) -} - -fn prefix_contains_antigravity_marker(body: &str) -> bool { - let trimmed = body.trim_start(); - if !trimmed.starts_with(['{', '[']) { - return false; - } - - [ - "\"cascadeId\"", - "\"cascadeTrajectories\"", - "\"trajectorySummaries\"", - "\"generatorMetadata\"", - "\"serverInfo\"", - "\"serverCapabilities\"", - ] - .iter() - .any(|marker| { - trimmed - .split(marker) - .skip(1) - .any(|suffix| suffix.trim_start().starts_with(':')) - }) -} - -fn contains_antigravity_marker(value: &Value) -> bool { - const MARKERS: &[&str] = &[ - "cascadeId", - "cascadeTrajectories", - "trajectorySummaries", - "generatorMetadata", - "serverInfo", - "serverCapabilities", - ]; - match value { - Value::Object(map) => { - for (key, val) in map { - if MARKERS.iter().any(|m| m.eq_ignore_ascii_case(key)) { - return true; - } - if contains_antigravity_marker(val) { - return true; - } - } - false - } - Value::Array(items) => items.iter().any(contains_antigravity_marker), - _ => false, - } -} - -fn run_command(program: &str, args: &[&str]) -> Result { - let output = run_command_output(program, args)?; - - if !output.status.success() && output.stdout.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - eprintln!( - "Warning: {} {} exited with status {}{}", - program, - args.join(" "), - output.status, - if stderr.trim().is_empty() { - String::new() - } else { - format!(": {}", stderr.trim()) - } - ); - return Ok(String::new()); - } - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -#[cfg(target_os = "windows")] -fn run_command_required(program: &str, args: &[&str]) -> Result { - let output = run_command_output(program, args)?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!( - "{} {} exited with status {}{}", - program, - args.join(" "), - output.status, - if stderr.trim().is_empty() { - String::new() - } else { - format!(": {}", stderr.trim()) - } - ); - } - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -#[cfg(target_os = "windows")] -fn run_windows_powershell(script: &str) -> Result { - let args = [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ]; - match run_command_required("powershell", &args) { - Ok(output) => Ok(output), - Err(err) if is_command_not_found(&err) => run_command_required("powershell.exe", &args), - Err(err) => Err(err), - } -} - -fn run_command_output(program: &str, args: &[&str]) -> Result { - let output = Command::new(program) - .args(args) - .output() - .with_context(|| format!("Failed to run {} {}", program, args.join(" ")))?; - Ok(output) -} - -pub fn list_trajectory_summaries( - connections: &[AntigravityConnection], -) -> Result> { - let mut merged: HashMap = HashMap::new(); - - for connection in connections { - let response = match rpc_request( - connection, - "GetAllCascadeTrajectories", - &serde_json::json!({}), - ) { - Ok(response) => response, - Err(err) => { - eprintln!( - "Warning: failed to list Antigravity trajectories for {}: {err:#}", - connection.fingerprint - ); - continue; - } - }; - - for summary in normalize_trajectory_summaries(&response, &connection.fingerprint) { - merge_summary(&mut merged, summary); - } - } - - let mut values: Vec = merged.into_values().collect(); - values.sort_by(|left, right| { - right - .last_modified_ms - .unwrap_or_default() - .cmp(&left.last_modified_ms.unwrap_or_default()) - .then_with(|| { - right - .step_count - .unwrap_or_default() - .cmp(&left.step_count.unwrap_or_default()) - }) - .then_with(|| left.session_id.cmp(&right.session_id)) - }); - Ok(values) -} - -fn merge_summary(merged: &mut HashMap, summary: TrajectorySummary) { - match merged.get(&summary.session_id) { - Some(existing) if !is_better_summary(&summary, existing) => {} - _ => { - merged.insert(summary.session_id.clone(), summary); - } - } -} - -fn scan_filesystem_session_candidates() -> Result> { - let mut candidates: HashMap = HashMap::new(); - - for root in antigravity_data_roots()? { - let brain_dir = root.join("brain"); - let conversations_dir = root.join("conversations"); - - if brain_dir.exists() { - for entry in fs::read_dir(&brain_dir)? { - let entry = entry?; - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let session_id = entry.file_name().to_string_lossy().to_string(); - if session_id.trim().is_empty() { - continue; - } - - let modified = latest_modified_in_dir(&path)?; - merge_candidate( - &mut candidates, - SessionCandidate { - session_id, - last_modified_ms: modified, - artifact_path: None, - }, - ); - } - } - - if conversations_dir.exists() { - for entry in fs::read_dir(&conversations_dir)? { - let entry = entry?; - let path = entry.path(); - if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("pb") { - continue; - } - - let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - - let modified = file_modified_ms(&path)?; - merge_candidate( - &mut candidates, - SessionCandidate { - session_id: stem.to_string(), - last_modified_ms: modified, - artifact_path: None, - }, - ); - } - } - } - - let mut values: Vec = candidates.into_values().collect(); - values.sort_by(|left, right| { - right - .last_modified_ms - .unwrap_or_default() - .cmp(&left.last_modified_ms.unwrap_or_default()) - .then_with(|| left.session_id.cmp(&right.session_id)) - }); - Ok(values) -} - -fn merge_export_candidates( - manifest: &AntigravityManifest, - summaries: &[TrajectorySummary], - filesystem: &[SessionCandidate], -) -> Vec { - let mut merged: HashMap = HashMap::new(); - - for summary in summaries { - merge_candidate( - &mut merged, - SessionCandidate { - session_id: summary.session_id.clone(), - last_modified_ms: summary.last_modified_ms, - artifact_path: None, - }, - ); - } - - for candidate in filesystem { - merge_candidate(&mut merged, candidate.clone()); - } - - for session in &manifest.sessions { - merge_candidate( - &mut merged, - SessionCandidate { - session_id: session.session_id.clone(), - last_modified_ms: session.last_modified_ms, - artifact_path: Some(session.artifact_path.clone()), - }, - ); - } - - let mut values: Vec = merged.into_values().collect(); - values.sort_by(|left, right| { - right - .last_modified_ms - .unwrap_or_default() - .cmp(&left.last_modified_ms.unwrap_or_default()) - .then_with(|| left.session_id.cmp(&right.session_id)) - }); - values -} - -fn merge_candidate(target: &mut HashMap, next: SessionCandidate) { - match target.get(&next.session_id) { - Some(existing) - if existing.last_modified_ms.unwrap_or_default() - > next.last_modified_ms.unwrap_or_default() => {} - Some(existing) - if existing.last_modified_ms == next.last_modified_ms - && existing.artifact_path.is_some() => {} - _ => { - target.insert(next.session_id.clone(), next); - } - } -} - -fn latest_modified_in_dir(path: &Path) -> Result> { - let mut latest = file_modified_ms(path)?; - for entry in fs::read_dir(path)? { - let entry = entry?; - let modified = file_modified_ms(&entry.path())?; - if modified.unwrap_or_default() > latest.unwrap_or_default() { - latest = modified; - } - } - Ok(latest) -} - -fn file_modified_ms(path: &Path) -> Result> { - let Ok(metadata) = fs::metadata(path) else { - return Ok(None); - }; - let Ok(modified) = metadata.modified() else { - return Ok(None); - }; - let datetime = chrono::DateTime::::from(modified); - Ok(Some(datetime.timestamp_millis())) -} - -fn find_summary_for_candidate<'a>( - summaries: &'a [TrajectorySummary], - session_id: &str, -) -> Option<&'a TrajectorySummary> { - summaries - .iter() - .find(|summary| summary.session_id == session_id) -} - -fn fetch_historical_session_artifact( - session_id: &str, - connections: &[AntigravityConnection], - candidate: &SessionCandidate, -) -> Result> { - let fallback_summary = TrajectorySummary { - session_id: session_id.to_string(), - last_modified_ms: candidate.last_modified_ms, - step_count: None, - connection_fingerprint: connections - .first() - .map(|connection| connection.fingerprint.clone()) - .unwrap_or_default(), - }; - - if let Some(artifact) = fetch_session_artifact(&fallback_summary, connections)? { - let path = write_session_artifact(session_id, &artifact.contents)?; - return Ok(Some(ManifestSessionEntry { - session_id: session_id.to_string(), - artifact_path: to_relative_artifact_path(&path)?, - last_modified_ms: artifact.last_modified_ms, - step_count: artifact.step_count, - connection_fingerprint: fallback_summary.connection_fingerprint, - artifact_hash: artifact.artifact_hash, - })); - } - - Ok(None) -} - -fn rpc_request(connection: &AntigravityConnection, method: &str, body: &Value) -> Result { - match rpc_request_plain_http(connection, method, body) { - Ok(value) => Ok(value), - Err(http_err) => https_rpc_request(connection, method, body).with_context(|| { - format!( - "HTTP RPC failed ({http_err:#}); HTTPS fallback also failed for Antigravity RPC {method}" - ) - }), - } -} - -fn https_rpc_request( - connection: &AntigravityConnection, - method: &str, - body: &Value, -) -> Result { - antigravity_https_runtime().block_on(async { - let url = format!( - "https://127.0.0.1:{}/exa.language_server_pb.LanguageServerService/{}", - connection.port, method - ); - let response = antigravity_https_client() - .post(url) - .header("Content-Type", "application/json") - .header("Connect-Protocol-Version", "1") - .header("X-Codeium-Csrf-Token", &connection.csrf_token) - .json(body) - .send() - .await?; - let status = response.status(); - let response_body = read_reqwest_response_with_cap(response, MAX_RPC_BODY_BYTES).await?; - if !status.is_success() { - anyhow::bail!( - "Antigravity HTTPS RPC {} failed with status {}: {}", - method, - status, - response_body - ); - } - Ok(serde_json::from_str(&response_body)?) - }) -} - -fn antigravity_https_runtime() -> &'static tokio::runtime::Runtime { - HTTPS_RPC_RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to create Antigravity HTTPS RPC runtime") - }) -} - -fn antigravity_https_client() -> &'static reqwest::Client { - HTTPS_RPC_CLIENT.get_or_init(|| { - reqwest::Client::builder() - .danger_accept_invalid_certs(true) - .no_proxy() - .timeout(Duration::from_secs(10)) - .build() - .expect("failed to create Antigravity HTTPS RPC client") - }) -} - -async fn read_reqwest_response_with_cap( - mut response: reqwest::Response, - max_body_bytes: usize, -) -> Result { - if let Some(length) = response.content_length() { - if length > max_body_bytes as u64 { - anyhow::bail!("Antigravity RPC body of {length} bytes exceeds {max_body_bytes} cap"); - } - } - - let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if body.len().saturating_add(chunk.len()) > max_body_bytes { - anyhow::bail!( - "Antigravity RPC body of {} bytes exceeds {} cap", - body.len().saturating_add(chunk.len()), - max_body_bytes - ); - } - body.extend_from_slice(&chunk); - } - - Ok(String::from_utf8(body)?) -} - -fn rpc_request_plain_http( - connection: &AntigravityConnection, - method: &str, - body: &Value, -) -> Result { - let mut stream = TcpStream::connect(("127.0.0.1", connection.port)).with_context(|| { - format!( - "Failed to connect to Antigravity RPC on port {}", - connection.port - ) - })?; - - stream.set_read_timeout(Some(Duration::from_secs(10)))?; - stream.set_write_timeout(Some(Duration::from_secs(5)))?; - - let body_text = serde_json::to_string(body)?; - let request = format!( - "POST /exa.language_server_pb.LanguageServerService/{} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnect-Protocol-Version: 1\r\nX-Codeium-Csrf-Token: {}\r\nConnection: close\r\n\r\n{}", - method, - connection.port, - body_text.len(), - connection.csrf_token, - body_text - ); - - stream.write_all(request.as_bytes())?; - - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - reader.read_line(&mut status_line)?; - - let status_code = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()) - .ok_or_else(|| anyhow::anyhow!("Malformed HTTP response from Antigravity RPC"))?; - - let mut content_length: Option = None; - let mut chunked = false; - loop { - let mut header = String::new(); - reader.read_line(&mut header)?; - let trimmed = header.trim(); - if trimmed.is_empty() { - break; - } - - let lower = trimmed.to_ascii_lowercase(); - if let Some(value) = lower.strip_prefix("content-length:") { - content_length = value.trim().parse::().ok(); - } - if lower.contains("transfer-encoding") && lower.contains("chunked") { - chunked = true; - } - } - - let response_body = if chunked { - read_chunked_body(&mut reader)? - } else if let Some(length) = content_length { - if length > MAX_RPC_BODY_BYTES { - anyhow::bail!( - "Antigravity RPC body of {length} bytes exceeds {MAX_RPC_BODY_BYTES} cap" - ); - } - let mut bytes = vec![0_u8; length]; - reader.read_exact(&mut bytes)?; - String::from_utf8(bytes)? - } else { - let mut text = String::new(); - reader - .by_ref() - .take(MAX_RPC_BODY_BYTES as u64 + 1) - .read_to_string(&mut text)?; - if text.len() > MAX_RPC_BODY_BYTES { - anyhow::bail!( - "Antigravity RPC body of {} bytes exceeds {MAX_RPC_BODY_BYTES} cap", - text.len() - ); - } - text - }; - - if status_code != 200 { - return Err(anyhow::anyhow!( - "Antigravity RPC {} failed with status {}: {}", - method, - status_code, - response_body - )); - } - - Ok(serde_json::from_str(&response_body)?) -} - -fn read_chunked_body(reader: &mut BufReader) -> Result { - read_chunked_body_with_cap(reader, MAX_RPC_BODY_BYTES) -} - -fn read_chunked_body_prefix( - reader: &mut BufReader, - max_body_bytes: usize, -) -> Result { - let mut body = Vec::new(); - while body.len() < max_body_bytes { - let mut size_line = String::new(); - reader.read_line(&mut size_line)?; - let chunk_size = parse_chunk_size_line(&size_line)?; - if chunk_size == 0 { - break; - } - - let remaining = max_body_bytes - body.len(); - let read_size = chunk_size.min(remaining); - let mut chunk = vec![0_u8; read_size]; - reader.read_exact(&mut chunk)?; - body.extend_from_slice(&chunk); - - if read_size < chunk_size { - break; - } - - let mut crlf = [0_u8; 2]; - reader.read_exact(&mut crlf)?; - } - - Ok(String::from_utf8(body)?) -} - -fn read_chunked_body_with_cap( - reader: &mut BufReader, - max_body_bytes: usize, -) -> Result { - let mut body = Vec::new(); - loop { - let mut size_line = String::new(); - reader.read_line(&mut size_line)?; - let chunk_size = parse_chunk_size_line(&size_line)?; - if chunk_size == 0 { - break; - } - - if chunk_size > max_body_bytes || body.len().saturating_add(chunk_size) > max_body_bytes { - anyhow::bail!( - "Antigravity RPC body of {} bytes exceeds {} cap", - body.len().saturating_add(chunk_size), - max_body_bytes - ); - } - - let mut chunk = vec![0_u8; chunk_size]; - reader.read_exact(&mut chunk)?; - body.extend_from_slice(&chunk); - - let mut crlf = [0_u8; 2]; - reader.read_exact(&mut crlf)?; - } - - Ok(String::from_utf8(body)?) -} - -fn parse_chunk_size_line(size_line: &str) -> Result { - let trimmed = size_line.trim(); - let chunk_size = trimmed - .split(';') - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow::anyhow!("Missing chunk size"))?; - - usize::from_str_radix(chunk_size, 16) - .with_context(|| format!("Invalid chunk size line: {trimmed}")) -} - -fn normalize_trajectory_summaries(response: &Value, fingerprint: &str) -> Vec { - let items: Vec = if let Some(array) = response - .get("trajectorySummaries") - .and_then(Value::as_array) - { - array.to_vec() - } else if let Some(object) = response - .get("trajectorySummaries") - .and_then(Value::as_object) - { - object - .iter() - .map(|(key, value)| { - let mut entry = value.clone(); - if entry.get("cascadeId").is_none() { - entry["cascadeId"] = Value::String(key.clone()); - } - entry - }) - .collect() - } else if let Some(array) = response - .get("cascadeTrajectories") - .and_then(Value::as_array) - { - array.to_vec() - } else { - Vec::new() - }; - - items - .into_iter() - .filter_map(|item| normalize_trajectory_summary(&item, fingerprint)) - .collect() -} - -fn fetch_session_artifact( - summary: &TrajectorySummary, - connections: &[AntigravityConnection], -) -> Result> { - let preferred = connections - .iter() - .find(|connection| connection.fingerprint == summary.connection_fingerprint); - - let mut ordered: Vec<&AntigravityConnection> = Vec::new(); - if let Some(preferred_connection) = preferred { - ordered.push(preferred_connection); - } - ordered.extend( - connections - .iter() - .filter(|connection| connection.fingerprint != summary.connection_fingerprint), - ); - - for connection in ordered { - if let Some(artifact) = try_fetch_session_artifact(summary, connection)? { - return Ok(Some(artifact)); - } - } - - Ok(None) -} - -fn try_fetch_session_artifact( - summary: &TrajectorySummary, - connection: &AntigravityConnection, -) -> Result> { - let response = match rpc_request( - connection, - "GetCascadeTrajectoryGeneratorMetadata", - &serde_json::json!({ "cascadeId": summary.session_id }), - ) { - Ok(value) => value, - Err(_) => return Ok(None), - }; - - let metadata = response - .get("generatorMetadata") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - if metadata.is_empty() { - return Ok(None); - } - - let lines = normalize_session_metadata(&summary.session_id, &metadata)?; - if lines.is_empty() { - return Ok(None); - } - - let contents = format!("{}\n", lines.join("\n")); - let artifact_hash = { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(contents.as_bytes()); - Some(format!("sha256:{:x}", hasher.finalize())) - }; - - Ok(Some(SessionArtifact { - contents, - last_modified_ms: summary.last_modified_ms, - step_count: summary.step_count, - artifact_hash, - })) -} - -fn normalize_session_metadata(session_id: &str, metadata: &[Value]) -> Result> { - let mut lines = Vec::new(); - - for meta in metadata { - let chat_model = meta.get("chatModel").unwrap_or(meta); - let model_id = resolve_model_id(chat_model); - let created_at = chat_model - .get("chatStartMetadata") - .and_then(|value| value.get("createdAt")) - .and_then(parse_timestamp_value); - - lines.push(serde_json::to_string(&serde_json::json!({ - "type": "session_meta", - "sessionId": session_id, - "modelId": model_id, - "timestamp": created_at, - }))?); - - if let Some(retry_infos) = chat_model.get("retryInfos").and_then(Value::as_array) { - for retry in retry_infos { - let usage = retry.get("usage").unwrap_or(retry); - let input = to_safe_i64(usage.get("inputTokens")); - let output = to_safe_i64(usage.get("outputTokens")); - let cache_read = to_safe_i64(usage.get("cacheReadTokens")); - let reasoning = to_safe_i64(usage.get("thinkingOutputTokens")); - let timestamp = usage - .get("createdAt") - .or_else(|| usage.get("timestamp")) - .and_then(parse_timestamp_value) - .or(created_at); - - if input == 0 && output == 0 && cache_read == 0 && reasoning == 0 { - continue; - } - - lines.push(serde_json::to_string(&serde_json::json!({ - "type": "usage", - "sessionId": session_id, - "modelId": model_id, - "timestamp": timestamp, - "input": input, - "output": output, - "cacheRead": cache_read, - "cacheWrite": 0, - "reasoning": reasoning, - "responseId": usage.get("responseId").and_then(Value::as_str), - }))?); - } - } - } - - Ok(lines) -} - -fn resolve_model_id(chat_model: &Value) -> String { - chat_model - .get("responseModel") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .or_else(|| { - chat_model - .get("model") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - }) - .unwrap_or("unknown") - .to_string() -} - -fn to_safe_i64(value: Option<&Value>) -> i64 { - value - .and_then(|inner| { - inner - .as_i64() - .or_else(|| inner.as_u64().and_then(|number| i64::try_from(number).ok())) - .or_else(|| inner.as_str().and_then(|text| text.parse::().ok())) - }) - .unwrap_or(0) - .max(0) -} - -fn stale_relative_paths(previous: &AntigravityManifest, next: &AntigravityManifest) -> Vec { - let next_paths: std::collections::HashSet<&str> = next - .sessions - .iter() - .map(|session| session.artifact_path.as_str()) - .collect(); - - previous - .sessions - .iter() - .filter(|session| !next_paths.contains(session.artifact_path.as_str())) - .map(|session| session.artifact_path.clone()) - .collect() -} - -fn cleanup_stale_session_artifacts( - previous: &AntigravityManifest, - next: &AntigravityManifest, -) -> Result<()> { - for relative_path in stale_relative_paths(previous, next) { - delete_artifact_relative_path(&relative_path)?; - } - - Ok(()) -} - -fn parse_timestamp_value(value: &Value) -> Option { - value - .as_i64() - .or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok())) - .or_else(|| { - value.as_str().and_then(|text| { - text.parse::().ok().or_else(|| { - chrono::DateTime::parse_from_rfc3339(text) - .ok() - .map(|datetime| datetime.timestamp_millis()) - }) - }) - }) - .filter(|timestamp| *timestamp > 0) -} - -fn normalize_trajectory_summary(item: &Value, fingerprint: &str) -> Option { - let session_id = first_string(&[ - item.get("cascadeId"), - item.get("trajectoryId"), - item.get("id"), - item.get("sessionId"), - ])?; - - Some(TrajectorySummary { - session_id, - last_modified_ms: parse_timestamp(&[ - item.get("lastModifiedTime"), - item.get("lastModified"), - item.get("updatedAt"), - item.get("modifiedAt"), - ]), - step_count: first_i32(&[ - item.get("stepCount"), - item.get("numSteps"), - item.get("totalSteps"), - ]), - connection_fingerprint: fingerprint.to_string(), - }) -} - -fn is_better_summary(next: &TrajectorySummary, current: &TrajectorySummary) -> bool { - let next_modified = next.last_modified_ms.unwrap_or_default(); - let current_modified = current.last_modified_ms.unwrap_or_default(); - if next_modified != current_modified { - return next_modified > current_modified; - } - - next.step_count.unwrap_or_default() > current.step_count.unwrap_or_default() -} - -fn first_string(values: &[Option<&Value>]) -> Option { - values.iter().find_map(|value| { - value - .and_then(|inner| inner.as_str()) - .filter(|text| !text.trim().is_empty()) - .map(|text| text.to_string()) - }) -} - -fn first_i32(values: &[Option<&Value>]) -> Option { - values.iter().find_map(|value| { - value.and_then(|inner| { - inner - .as_i64() - .and_then(|number| i32::try_from(number).ok()) - .or_else(|| inner.as_u64().and_then(|number| i32::try_from(number).ok())) - .or_else(|| inner.as_str().and_then(|text| text.parse::().ok())) - }) - }) -} - -fn parse_timestamp(values: &[Option<&Value>]) -> Option { - values.iter().find_map(|value| { - value.and_then(|inner| { - inner - .as_i64() - .or_else(|| inner.as_u64().and_then(|number| i64::try_from(number).ok())) - .or_else(|| { - inner - .as_str() - .and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok()) - .map(|datetime| datetime.timestamp_millis()) - }) - }) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - use std::ffi::OsString; - - /// RAII guard that redirects every tokscale config-dir lookup into a - /// caller-supplied directory and restores the previous environment on - /// drop (including on panic). Setting `HOME` alone is not sufficient on - /// Linux CI runners because `dirs::config_dir()` honors - /// `$XDG_CONFIG_HOME` first; tokscale's own `paths::get_config_dir()` - /// short-circuits on `TOKSCALE_CONFIG_DIR`, which is the canonical - /// hermetic override for tests. - struct TestEnvGuard { - prev_home: Option, - prev_config_dir: Option, - } - - impl TestEnvGuard { - fn redirect_to(path: &Path) -> Self { - let prev_home = std::env::var_os("HOME"); - let prev_config_dir = std::env::var_os("TOKSCALE_CONFIG_DIR"); - std::env::set_var("HOME", path); - std::env::set_var("TOKSCALE_CONFIG_DIR", path); - Self { - prev_home, - prev_config_dir, - } - } - } - - impl Drop for TestEnvGuard { - fn drop(&mut self) { - match self.prev_home.take() { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - match self.prev_config_dir.take() { - Some(dir) => std::env::set_var("TOKSCALE_CONFIG_DIR", dir), - None => std::env::remove_var("TOKSCALE_CONFIG_DIR"), - } - } - } - - fn sample_manifest() -> AntigravityManifest { - AntigravityManifest { - version: ANTIGRAVITY_MANIFEST_VERSION, - synced_at: Some("2026-03-24T00:00:00Z".to_string()), - connections: vec![ManifestConnectionEntry { - fingerprint: "pid:1:port:1234".to_string(), - pid: 1, - port: 1234, - }], - sessions: vec![ManifestSessionEntry { - session_id: "session-1".to_string(), - artifact_path: "sessions/session-1.jsonl".to_string(), - last_modified_ms: Some(100), - step_count: Some(2), - connection_fingerprint: "pid:1:port:1234".to_string(), - artifact_hash: Some("sha256:abc".to_string()), - }], - } - } - - #[test] - fn extract_flag_value_supports_space_and_equals() { - assert_eq!( - extract_flag_value("binary --csrf_token abcd-1234", "--csrf_token"), - Some("abcd-1234".to_string()) - ); - assert_eq!( - extract_flag_value( - "binary --extension_server_port=4321", - "--extension_server_port" - ), - Some("4321".to_string()) - ); - } - - #[test] - fn parse_port_from_line_reads_lsof_output() { - assert_eq!( - parse_port_from_line("proc 123 user 12u IPv4 0x0 0t0 TCP 127.0.0.1:41234 (LISTEN)"), - Some(41234) - ); - } - - #[test] - fn windows_process_candidates_parse_powershell_json() { - let output = r#"[ - { - "ProcessId": 4242, - "ParentProcessId": 100, - "ExecutablePath": "C:\\Users\\me\\AppData\\Local\\Programs\\Antigravity\\language_server.exe", - "CommandLine": "\"C:\\Users\\me\\AppData\\Local\\Programs\\Antigravity\\language_server.exe\" --app_data_dir antigravity --extension_server_port=49321 --csrf_token=abcdef0123456789abcdef0123456789" - }, - { - "ProcessId": 5000, - "ParentProcessId": 100, - "ExecutablePath": "C:\\Windows\\System32\\notepad.exe", - "CommandLine": "notepad.exe --app_data_dir antigravity --extension_server_port=49322 --csrf_token=abcdef0123456789abcdef0123456789" - } - ]"#; - - let candidates = parse_windows_process_candidates(output).unwrap(); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].pid, 4242); - assert_eq!(candidates[0].ppid, 100); - assert_eq!(candidates[0].declared_port, Some(49321)); - assert_eq!(candidates[0].csrf_token, "abcdef0123456789abcdef0123456789"); - } - - #[test] - fn windows_process_candidates_accept_single_json_object() { - let output = r#"{ - "ProcessId": 4243, - "ParentProcessId": 101, - "ExecutablePath": null, - "CommandLine": "\"C:\\Antigravity\\language_server.exe\" --extension_server_port 49323 --csrf_token abcdef0123456789abcdef0123456789" - }"#; - - let candidates = parse_windows_process_candidates(output).unwrap(); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].pid, 4243); - assert_eq!(candidates[0].declared_port, Some(49323)); - } - - #[test] - fn windows_netstat_ports_filter_listeners_by_pid() { - let output = r#" - Proto Local Address Foreign Address State PID - TCP 127.0.0.1:49321 0.0.0.0:0 LISTENING 4242 - TCP [::1]:49322 [::]:0 LISTENING 4242 - TCP 127.0.0.1:49323 0.0.0.0:0 ESTABLISHED 4242 - TCP 127.0.0.1:49324 0.0.0.0:0 LISTENING 5000 -"#; - - assert_eq!( - parse_windows_netstat_ports(output, 4242), - vec![49321, 49322] - ); - } - - #[test] - fn windows_parse_port_from_address_ipv4() { - assert_eq!( - parse_port_from_windows_address("127.0.0.1:49321"), - Some(49321) - ); - assert_eq!(parse_port_from_windows_address("0.0.0.0:8080"), Some(8080)); - } - - #[test] - fn windows_parse_port_from_address_ipv6() { - assert_eq!(parse_port_from_windows_address("[::1]:49322"), Some(49322)); - assert_eq!(parse_port_from_windows_address("[::]:0"), Some(0)); - } - - #[test] - fn windows_parse_port_from_address_invalid() { - assert_eq!(parse_port_from_windows_address("no-colon"), None); - assert_eq!(parse_port_from_windows_address("127.0.0.1:notaport"), None); - assert_eq!(parse_port_from_windows_address(""), None); - } - - #[test] - fn windows_executable_path_looks_antigravity_matches_case_insensitively() { - assert!(executable_path_looks_antigravity( - r"C:\Users\me\AppData\Local\Programs\Antigravity\language_server.exe" - )); - assert!(executable_path_looks_antigravity( - r"C:\ANTIGRAVITY\LANGUAGE_SERVER.EXE" - )); - assert!(executable_path_looks_antigravity( - r"D:\tools\antigravity\app.exe" - )); - assert!(executable_path_looks_antigravity( - r"C:\path\to\language_server.exe" - )); - } - - #[test] - fn windows_executable_path_rejects_unrelated_programs() { - assert!(!executable_path_looks_antigravity( - r"C:\Windows\System32\notepad.exe" - )); - assert!(!executable_path_looks_antigravity( - r"C:\Program Files\SomeApp\app.exe" - )); - assert!(!executable_path_looks_antigravity("")); - } - - #[test] - fn windows_command_line_executable_extracts_quoted_path() { - assert!(command_line_executable_looks_antigravity( - r#""C:\Antigravity\language_server.exe" --port=1234"# - )); - assert!(!command_line_executable_looks_antigravity( - r#""C:\Windows\System32\notepad.exe" somefile.txt"# - )); - } - - #[test] - fn windows_command_line_executable_extracts_unquoted_path() { - assert!(command_line_executable_looks_antigravity( - r"C:\Antigravity\language_server.exe --flag" - )); - assert!(!command_line_executable_looks_antigravity( - r"notepad.exe file.txt" - )); - } - - #[test] - fn windows_candidate_executable_ok_prefers_path_when_available() { - assert!(windows_candidate_executable_ok( - Some(r"C:\Programs\Antigravity\language_server.exe"), - r#"notepad.exe --csrf_token=abc"# - )); - assert!(!windows_candidate_executable_ok( - Some(r"C:\Windows\notepad.exe"), - r#""C:\Antigravity\language_server.exe" --flag"# - )); - } - - #[test] - fn windows_candidate_executable_ok_falls_back_to_command_line() { - assert!(windows_candidate_executable_ok( - None, - r#""C:\Antigravity\language_server.exe" --csrf_token=abc"# - )); - assert!(windows_candidate_executable_ok( - Some(""), - r#""C:\path\language_server.exe" --flag"# - )); - assert!(windows_candidate_executable_ok( - Some(" "), - r#"C:\antigravity\app.exe"# - )); - assert!(!windows_candidate_executable_ok( - None, - r"notepad.exe file.txt" - )); - } - - #[test] - fn is_antigravity_process_matches_language_server_variants() { - assert!(is_antigravity_process( - "language_server.exe --app_data_dir antigravity --port=1234" - )); - assert!(is_antigravity_process( - "/Applications/Antigravity.app/Contents/MacOS/language_server --flag" - )); - assert!(is_antigravity_process( - r"C:\Users\me\AppData\Local\Antigravity\language_server.exe --flag" - )); - } - - #[test] - fn is_antigravity_process_matches_directory_patterns() { - assert!(is_antigravity_process( - "/home/user/.config/antigravity/server" - )); - assert!(is_antigravity_process( - r"C:\Programs\antigravity\server.exe" - )); - } - - #[test] - fn is_antigravity_process_rejects_unrelated_commands() { - assert!(!is_antigravity_process("notepad.exe somefile.txt")); - assert!(!is_antigravity_process("language_server --other_app")); - assert!(!is_antigravity_process("some_other_gravity_app")); - assert!(!is_antigravity_process("")); - } - - #[test] - fn normalize_trajectory_summary_prefers_expected_fields() { - let value = serde_json::json!({ - "cascadeId": "session-123", - "lastModifiedTime": "2026-03-24T10:00:00Z", - "stepCount": 9 - }); - - let summary = normalize_trajectory_summary(&value, "pid:1:port:1000").unwrap(); - assert_eq!(summary.session_id, "session-123"); - assert_eq!(summary.step_count, Some(9)); - assert_eq!(summary.connection_fingerprint, "pid:1:port:1000"); - assert!(summary.last_modified_ms.is_some()); - } - - #[test] - fn session_artifact_file_stem_avoids_collisions_for_sanitized_ids() { - let left = session_artifact_file_stem("session/one"); - let right = session_artifact_file_stem("session:one"); - - assert_ne!(left, right); - assert!(left.starts_with("session-one-")); - assert!(right.starts_with("session-one-")); - } - - #[test] - fn parse_chunk_size_line_supports_extensions() { - assert_eq!(parse_chunk_size_line("1a;foo=bar\r\n").unwrap(), 26); - } - - #[test] - fn parse_chunk_size_line_rejects_invalid_sizes() { - let err = parse_chunk_size_line("bogus\r\n").unwrap_err(); - assert!(err.to_string().contains("Invalid chunk size line")); - } - - #[test] - fn merge_summary_prefers_better_entries() { - let mut merged = HashMap::new(); - merge_summary( - &mut merged, - TrajectorySummary { - session_id: "session-1".to_string(), - last_modified_ms: Some(10), - step_count: Some(1), - connection_fingerprint: "pid:1:port:1111".to_string(), - }, - ); - merge_summary( - &mut merged, - TrajectorySummary { - session_id: "session-1".to_string(), - last_modified_ms: Some(20), - step_count: Some(3), - connection_fingerprint: "pid:2:port:2222".to_string(), - }, - ); - - let summary = merged.get("session-1").unwrap(); - assert_eq!(summary.last_modified_ms, Some(20)); - assert_eq!(summary.step_count, Some(3)); - assert_eq!(summary.connection_fingerprint, "pid:2:port:2222"); - } - - #[test] - fn run_port_query_treats_missing_lsof_as_empty() { - let ports = run_port_query( - "__tokscale_missing_lsof__", - "lsof", - &["-Pan", "-p", "1", "-i"], - ) - .unwrap(); - - assert!(ports.is_empty()); - } - - #[test] - fn candidate_probe_ports_falls_back_to_declared_port() { - let candidate = ProcessCandidate { - pid: 1, - ppid: 0, - declared_port: Some(4242), - csrf_token: "token".to_string(), - }; - - assert_eq!(candidate_probe_ports(&candidate, Vec::new()), vec![4242]); - assert_eq!(candidate_probe_ports(&candidate, vec![4242]), vec![4242]); - assert_eq!( - candidate_probe_ports(&candidate, vec![5555]), - vec![4242, 5555] - ); - } - - #[test] - fn antigravity_process_detection_accepts_antigravity_ide_language_server() { - assert!(is_antigravity_process( - "/opt/antigravity-ide/resources/app/extensions/antigravity/bin/language_server_linux_x64 --csrf_token abc --app_data_dir antigravity-ide" - )); - } - - #[test] - fn normalize_session_metadata_emits_meta_and_usage_rows() { - let metadata = vec![serde_json::json!({ - "chatModel": { - "responseModel": "claude-sonnet-4.6", - "chatStartMetadata": { "createdAt": "2026-03-24T10:00:00Z" }, - "retryInfos": [{ - "usage": { - "inputTokens": 10, - "outputTokens": 5, - "cacheReadTokens": 2, - "thinkingOutputTokens": 1, - "responseId": "resp-1" - } - }] - } - })]; - - let lines = normalize_session_metadata("session-1", &metadata).unwrap(); - assert_eq!(lines.len(), 2); - assert!(lines - .iter() - .any(|line| line.contains("\"type\":\"session_meta\""))); - assert!(lines.iter().any(|line| line.contains("\"type\":\"usage\""))); - } - - #[test] - fn normalize_session_metadata_accepts_numeric_retry_timestamps() { - let metadata = vec![serde_json::json!({ - "chatModel": { - "responseModel": "claude-sonnet-4.6", - "retryInfos": [{ - "usage": { - "inputTokens": 10, - "outputTokens": 5, - "cacheReadTokens": 2, - "thinkingOutputTokens": 1, - "timestamp": 1_711_447_200_000_i64, - "responseId": "resp-1" - } - }] - } - })]; - - let lines = normalize_session_metadata("session-1", &metadata).unwrap(); - let usage: Value = serde_json::from_str(&lines[1]).unwrap(); - assert_eq!( - usage.get("timestamp").and_then(Value::as_i64), - Some(1_711_447_200_000) - ); - } - - #[test] - fn stale_relative_paths_finds_removed_artifacts() { - let previous = sample_manifest(); - let next = AntigravityManifest::default(); - assert_eq!( - stale_relative_paths(&previous, &next), - vec!["sessions/session-1.jsonl".to_string()] - ); - } - - #[test] - #[serial] - fn cleanup_stale_session_artifacts_removes_legacy_files_after_migration() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - let sessions_dir = get_antigravity_sessions_dir().unwrap(); - std::fs::create_dir_all(&sessions_dir).unwrap(); - - let legacy_relative = "sessions/session-one.jsonl".to_string(); - let legacy_path = get_antigravity_cache_dir().unwrap().join(&legacy_relative); - std::fs::write(&legacy_path, "legacy\n").unwrap(); - - let new_path = write_session_artifact("session/one", "new\n").unwrap(); - let new_relative = to_relative_artifact_path(&new_path).unwrap(); - - let previous = AntigravityManifest { - sessions: vec![ManifestSessionEntry { - session_id: "session/one".to_string(), - artifact_path: legacy_relative, - last_modified_ms: None, - step_count: None, - connection_fingerprint: "pid:1:port:1111".to_string(), - artifact_hash: None, - }], - ..AntigravityManifest::default() - }; - let next = AntigravityManifest { - sessions: vec![ManifestSessionEntry { - session_id: "session/one".to_string(), - artifact_path: new_relative, - last_modified_ms: None, - step_count: None, - connection_fingerprint: "pid:1:port:1111".to_string(), - artifact_hash: None, - }], - ..AntigravityManifest::default() - }; - - cleanup_stale_session_artifacts(&previous, &next).unwrap(); - assert!(!legacy_path.exists()); - assert!(new_path.exists()); - } - - #[test] - #[serial] - fn delete_artifact_relative_path_rejects_paths_outside_cache_root() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - let err = delete_artifact_relative_path("../outside.jsonl").unwrap_err(); - assert!(err.to_string().contains("cache root")); - - let absolute = temp_dir.path().join("outside.jsonl"); - let err = delete_artifact_relative_path(absolute.to_str().unwrap()).unwrap_err(); - assert!(err.to_string().contains("cache root")); - - let err = delete_artifact_relative_path("manifest.json").unwrap_err(); - assert!(err.to_string().contains("session artifact")); - } - - #[test] - #[serial] - #[cfg(unix)] - fn delete_artifact_relative_path_rejects_symlink_escape() { - use std::os::unix::fs::symlink; - - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - let cache_dir = get_antigravity_cache_dir().unwrap(); - let sessions_dir = cache_dir.join("sessions"); - std::fs::create_dir_all(&sessions_dir).unwrap(); - - let outside_dir = temp_dir.path().join("escape"); - std::fs::create_dir_all(&outside_dir).unwrap(); - let outside_file = outside_dir.join("secret.jsonl"); - std::fs::write(&outside_file, "secret").unwrap(); - - let symlink_path = sessions_dir.join("escape.jsonl"); - symlink(&outside_file, &symlink_path).unwrap(); - - let err = delete_artifact_relative_path("sessions/escape.jsonl").unwrap_err(); - assert!(err.to_string().contains("sessions cache root")); - assert!(outside_file.exists()); - } - - #[test] - #[serial] - fn filesystem_scan_finds_brain_and_conversation_candidates() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - let legacy_root = temp_dir.path().join(".gemini/antigravity"); - std::fs::create_dir_all(legacy_root.join("brain/session-a")).unwrap(); - std::fs::create_dir_all(legacy_root.join("brain/session-b")).unwrap(); - std::fs::create_dir_all(legacy_root.join("conversations")).unwrap(); - std::fs::write(legacy_root.join("conversations/session-c.pb"), b"pb").unwrap(); - - let ide_root = temp_dir.path().join(".gemini/antigravity-ide"); - std::fs::create_dir_all(ide_root.join("brain/session-d")).unwrap(); - std::fs::create_dir_all(ide_root.join("conversations")).unwrap(); - std::fs::write(ide_root.join("conversations/session-e.pb"), b"pb").unwrap(); - - let backup_root = temp_dir.path().join(".gemini/antigravity-backup"); - std::fs::create_dir_all(backup_root.join("conversations")).unwrap(); - std::fs::write(backup_root.join("conversations/session-f.pb"), b"pb").unwrap(); - - let candidates = scan_filesystem_session_candidates().unwrap(); - let ids: Vec = candidates - .into_iter() - .map(|candidate| candidate.session_id) - .collect(); - assert!(ids.contains(&"session-a".to_string())); - assert!(ids.contains(&"session-b".to_string())); - assert!(ids.contains(&"session-c".to_string())); - assert!(ids.contains(&"session-d".to_string())); - assert!(ids.contains(&"session-e".to_string())); - assert!(ids.contains(&"session-f".to_string())); - } - - #[test] - fn merge_export_candidates_keeps_summary_filesystem_and_manifest_union() { - let manifest = sample_manifest(); - let summaries = vec![TrajectorySummary { - session_id: "session-2".to_string(), - last_modified_ms: Some(200), - step_count: Some(3), - connection_fingerprint: "pid:2:port:2222".to_string(), - }]; - let filesystem = vec![SessionCandidate { - session_id: "session-3".to_string(), - last_modified_ms: Some(300), - artifact_path: None, - }]; - - let merged = merge_export_candidates(&manifest, &summaries, &filesystem); - let ids: Vec = merged - .into_iter() - .map(|candidate| candidate.session_id) - .collect(); - assert!(ids.contains(&"session-1".to_string())); - assert!(ids.contains(&"session-2".to_string())); - assert!(ids.contains(&"session-3".to_string())); - } - - #[test] - #[serial] - fn manifest_round_trip_and_artifact_write() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - let manifest = sample_manifest(); - save_antigravity_manifest(&manifest).unwrap(); - let loaded = load_antigravity_manifest().unwrap(); - assert_eq!(loaded.sessions.len(), 1); - assert_eq!(loaded.connections.len(), 1); - - let artifact_path = write_session_artifact("session/one", "{}\n").unwrap(); - assert!(artifact_path.exists()); - assert!(artifact_path - .file_name() - .and_then(|value| value.to_str()) - .is_some_and(|value| value.starts_with("session-one-"))); - - let cache_dir = get_antigravity_cache_dir().unwrap(); - let relative = artifact_path - .strip_prefix(cache_dir) - .unwrap() - .to_string_lossy() - .to_string(); - assert!(delete_session_artifact(&relative).unwrap()); - assert!(!artifact_path.exists()); - } - - use std::net::TcpListener; - use std::thread; - - fn serve_once(body: Vec, headers_extra: &str) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let header_owned = headers_extra.to_string(); - thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut buf = [0u8; 4096]; - let _ = std::io::Read::read(&mut stream, &mut buf); - let response = format!( - "HTTP/1.1 200 OK\r\n{}Connection: close\r\n\r\n", - header_owned - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.write_all(&body); - }); - port - } - - #[test] - fn rpc_request_rejects_oversized_content_length_body() { - let port = serve_once( - vec![b'a'; 32], - &format!("Content-Length: {}\r\n", MAX_RPC_BODY_BYTES + 1), - ); - let connection = AntigravityConnection { - pid: 1, - port, - csrf_token: "abcdef0123456789abcdef0123456789".to_string(), - fingerprint: format!("pid:1:port:{port}"), - }; - let err = rpc_request(&connection, "X", &serde_json::json!({})).unwrap_err(); - assert!( - err.to_string().contains("exceeds"), - "expected cap error, got: {err:#}" - ); - } - - #[test] - fn read_chunked_body_rejects_oversized_accumulated_chunks() { - let chunk_size = MAX_RPC_BODY_BYTES / 4 + 1; - let mut body = Vec::new(); - for _ in 0..5 { - body.extend_from_slice(format!("{:x}\r\n", chunk_size).as_bytes()); - body.extend(std::iter::repeat_n(b'a', chunk_size)); - body.extend_from_slice(b"\r\n"); - } - body.extend_from_slice(b"0\r\n\r\n"); - let port = serve_once(body, "Transfer-Encoding: chunked\r\n"); - let connection = AntigravityConnection { - pid: 1, - port, - csrf_token: "abcdef0123456789abcdef0123456789".to_string(), - fingerprint: format!("pid:1:port:{port}"), - }; - let err = rpc_request(&connection, "X", &serde_json::json!({})).unwrap_err(); - assert!( - err.to_string().contains("exceeds"), - "expected cap error, got: {err:#}" - ); - } - - #[test] - fn identity_probe_request_decodes_chunked_antigravity_response() { - let json = r#"{"trajectorySummaries":{"session-1":{"cascadeId":"session-1"}}}"#; - let mut body = Vec::new(); - body.extend_from_slice(format!("{:x}\r\n", json.len()).as_bytes()); - body.extend_from_slice(json.as_bytes()); - body.extend_from_slice(b"\r\n0\r\n\r\n"); - - let port = serve_once(body, "Transfer-Encoding: chunked\r\n"); - let response = identity_probe_request( - port, - "abcdef0123456789abcdef0123456789", - "GetAllCascadeTrajectories", - ) - .unwrap(); - - assert!(response_contains_antigravity_marker(&response)); - } - - #[test] - fn identity_probe_request_uses_probe_cap_for_large_bodies() { - let prefix = r#"{"trajectorySummaries":{"session-1":{"cascadeId":"session-1"}}}"#; - let mut content_length_body = prefix.as_bytes().to_vec(); - content_length_body.resize(MAX_IDENTITY_PROBE_BYTES + 1, b'a'); - let content_length_port = serve_once( - content_length_body, - &format!("Content-Length: {}\r\n", MAX_IDENTITY_PROBE_BYTES + 1), - ); - let content_length_response = identity_probe_request( - content_length_port, - "abcdef0123456789abcdef0123456789", - "GetAllCascadeTrajectories", - ) - .unwrap(); - assert_eq!(content_length_response.len(), MAX_IDENTITY_PROBE_BYTES); - assert!(response_contains_antigravity_marker( - &content_length_response - )); - - let chunk_size = MAX_IDENTITY_PROBE_BYTES + 1; - let mut chunked_body = Vec::new(); - chunked_body.extend_from_slice(format!("{:x}\r\n", chunk_size).as_bytes()); - chunked_body.extend_from_slice(prefix.as_bytes()); - chunked_body.extend(std::iter::repeat_n(b'a', chunk_size - prefix.len())); - chunked_body.extend_from_slice(b"\r\n0\r\n\r\n"); - let chunked_port = serve_once(chunked_body, "Transfer-Encoding: chunked\r\n"); - let chunked_response = identity_probe_request( - chunked_port, - "abcdef0123456789abcdef0123456789", - "GetAllCascadeTrajectories", - ) - .unwrap(); - assert_eq!(chunked_response.len(), MAX_IDENTITY_PROBE_BYTES); - assert!(response_contains_antigravity_marker(&chunked_response)); - } - - #[test] - fn identity_probe_request_prefers_chunked_over_content_length() { - let json = r#"{"trajectorySummaries":{"session-1":{"cascadeId":"session-1"}}}"#; - let mut body = Vec::new(); - body.extend_from_slice(format!("{:x}\r\n", json.len()).as_bytes()); - body.extend_from_slice(json.as_bytes()); - body.extend_from_slice(b"\r\n0\r\n\r\n"); - - let port = serve_once(body, "Transfer-Encoding: chunked\r\nContent-Length: 1\r\n"); - let response = identity_probe_request( - port, - "abcdef0123456789abcdef0123456789", - "GetAllCascadeTrajectories", - ) - .unwrap(); - - assert!(response_contains_antigravity_marker(&response)); - } - - #[test] - fn contains_antigravity_marker_accepts_known_keys() { - let v: Value = serde_json::json!({ - "trajectorySummaries": [{"cascadeId": "abc"}] - }); - assert!(contains_antigravity_marker(&v)); - - let nested: Value = serde_json::json!({ - "data": {"serverInfo": {"name": "x"}} - }); - assert!(contains_antigravity_marker(&nested)); - } - - #[test] - fn contains_antigravity_marker_rejects_html_and_arbitrary_json() { - assert!(!response_contains_antigravity_marker( - "not json" - )); - assert!(!response_contains_antigravity_marker(r#"{"foo":"bar"}"#)); - assert!(!response_contains_antigravity_marker(r#"[]"#)); - } - - #[test] - fn response_contains_antigravity_marker_accepts_real_shape() { - let body = r#"{"trajectorySummaries":[{"cascadeId":"sess-1","stepCount":3}]}"#; - assert!(response_contains_antigravity_marker(body)); - } - - #[test] - #[serial] - fn load_antigravity_manifest_rejects_newer_version() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - ensure_config_dir().unwrap(); - let cache_dir = get_antigravity_cache_dir().unwrap(); - std::fs::create_dir_all(&cache_dir).unwrap(); - let manifest_path = get_antigravity_manifest_path().unwrap(); - std::fs::write( - &manifest_path, - r#"{"version":2,"syncedAt":null,"connections":[],"sessions":[]}"#, - ) - .unwrap(); - - let err = load_antigravity_manifest().unwrap_err(); - assert!(err.to_string().contains("newer tokscale version")); - } - - #[test] - #[serial] - fn load_antigravity_manifest_treats_older_version_as_fresh_start() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - ensure_config_dir().unwrap(); - let cache_dir = get_antigravity_cache_dir().unwrap(); - std::fs::create_dir_all(&cache_dir).unwrap(); - let manifest_path = get_antigravity_manifest_path().unwrap(); - std::fs::write( - &manifest_path, - r#"{"version":0,"syncedAt":null,"connections":[],"sessions":[]}"#, - ) - .unwrap(); - - let manifest = load_antigravity_manifest().unwrap(); - assert_eq!(manifest.version, ANTIGRAVITY_MANIFEST_VERSION); - assert!(manifest.sessions.is_empty()); - } - - #[test] - #[serial] - fn load_antigravity_manifest_recovers_from_corrupted_json() { - let temp_dir = tempfile::tempdir().unwrap(); - let _env = TestEnvGuard::redirect_to(temp_dir.path()); - - ensure_config_dir().unwrap(); - let cache_dir = get_antigravity_cache_dir().unwrap(); - std::fs::create_dir_all(&cache_dir).unwrap(); - let manifest_path = get_antigravity_manifest_path().unwrap(); - std::fs::write(&manifest_path, "{ this is not valid json").unwrap(); - - let manifest = load_antigravity_manifest().unwrap(); - assert_eq!(manifest.version, ANTIGRAVITY_MANIFEST_VERSION); - assert!(manifest.sessions.is_empty()); - - let parent = manifest_path.parent().unwrap(); - let backups: Vec<_> = std::fs::read_dir(parent) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| { - e.file_name() - .to_string_lossy() - .starts_with("manifest.json.corrupt-") - }) - .collect(); - assert_eq!(backups.len(), 1, "expected one backup file"); - } - - #[test] - #[serial] - fn sync_lock_guard_blocks_when_self_pid_lock_present() { - let temp_dir = tempfile::tempdir().unwrap(); - let cache_dir = temp_dir.path().to_path_buf(); - let lock_path = cache_dir.join("sync.lock"); - let pid = std::process::id(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - std::fs::write(&lock_path, format!("{pid} {now}")).unwrap(); - - let err = SyncLockGuard::acquire(&cache_dir).unwrap_err(); - assert!( - err.to_string() - .contains("Another tokscale antigravity sync"), - "got: {err:#}" - ); - - std::fs::remove_file(&lock_path).unwrap(); - } - - #[test] - #[serial] - fn sync_lock_guard_acquires_when_no_lock_present() { - let temp_dir = tempfile::tempdir().unwrap(); - let cache_dir = temp_dir.path().to_path_buf(); - { - let _guard = SyncLockGuard::acquire(&cache_dir).unwrap(); - assert!(cache_dir.join("sync.lock").exists()); - } - assert!( - !cache_dir.join("sync.lock").exists(), - "guard drop should remove lock" - ); - } - - #[test] - #[serial] - fn sync_lock_guard_overwrites_stale_lock() { - let temp_dir = tempfile::tempdir().unwrap(); - let cache_dir = temp_dir.path().to_path_buf(); - let lock_path = cache_dir.join("sync.lock"); - let stale_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(SYNC_LOCK_STALE_SECS + 60); - std::fs::write(&lock_path, format!("999999 {stale_ts}")).unwrap(); - - let _guard = SyncLockGuard::acquire(&cache_dir).unwrap(); - assert!(lock_path.exists()); - } -} diff --git a/crates/tokscale-cli/src/cli.rs b/crates/tokscale-cli/src/cli.rs index 13cfc875b..9ebd81df0 100644 --- a/crates/tokscale-cli/src/cli.rs +++ b/crates/tokscale-cli/src/cli.rs @@ -1,7 +1,6 @@ use std::ffi::OsString; use std::io::IsTerminal; use std::path::PathBuf; -use std::time::Duration; use anyhow::Result; use chrono::NaiveDate; @@ -77,13 +76,6 @@ pub(crate) fn legacy_invocation_hint(arguments: &[String]) -> Option { } } - if first == "headless" && !arguments.iter().any(|argument| argument == "--") { - return Some( - "separate Tokscale options from the child command with `--`, for example `tokscale headless codex --format jsonl -- codex exec ...`" - .to_string(), - ); - } - if contains_long_option(arguments, "write-cache") || contains_long_option(arguments, "no-write-cache") { @@ -345,18 +337,11 @@ pub(crate) enum Commands { }, #[command(about = "Generate year-in-review wrapped image")] Wrapped(WrappedArgs), - #[command(about = "Capture subprocess output for token usage tracking")] - Headless(HeadlessArgs), #[command(about = "Maintain local Tokscale caches")] Cache { #[command(subcommand)] subcommand: CacheSubcommand, }, - #[command(about = "Antigravity integration commands")] - Antigravity { - #[command(subcommand)] - subcommand: AntigravitySubcommand, - }, #[command(about = "Warp/Oz aggregate usage integration commands")] Warp { #[command(subcommand)] @@ -481,26 +466,6 @@ impl From for WrappedRanking { } } -#[derive(Args, Debug)] -pub(crate) struct HeadlessArgs { - #[arg(value_enum, help = "Usage adapter for the captured process")] - pub(crate) source: HeadlessSource, - #[arg(long, value_enum, help = "Captured output format")] - pub(crate) format: Option, - #[arg(long, value_name = "PATH", help = "Write captured output to this file")] - pub(crate) output: Option, - #[arg(long, help = "Do not add source-specific structured-output flags")] - pub(crate) no_auto_flags: bool, - #[arg( - last = true, - required = true, - num_args = 1.., - value_name = "COMMAND", - help = "Child command and arguments after `--`" - )] - pub(crate) command: Vec, -} - #[derive(Args, Clone, Debug, Default)] pub(crate) struct SourceScopeArgs { #[arg( @@ -602,19 +567,6 @@ pub(crate) enum CacheSubcommand { Prune, } -#[derive(Subcommand, Debug)] -pub(crate) enum AntigravitySubcommand { - #[command(about = "Sync usage from running Antigravity language servers")] - Sync, - #[command(about = "Show Antigravity sync status")] - Status { - #[arg(long, help = "Output as JSON")] - json: bool, - }, - #[command(about = "Delete cached Antigravity usage artifacts")] - PurgeCache, -} - #[derive(Subcommand, Debug)] pub(crate) enum WarpSubcommand { #[command(about = "Save Warp GraphQL authentication")] @@ -692,34 +644,6 @@ impl PricingSource { } } -#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum HeadlessSource { - Codex, -} - -impl HeadlessSource { - pub(crate) const fn as_str(self) -> &'static str { - match self { - Self::Codex => "codex", - } - } -} - -#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum HeadlessFormat { - Json, - Jsonl, -} - -impl HeadlessFormat { - pub(crate) const fn as_str(self) -> &'static str { - match self { - Self::Json => "json", - Self::Jsonl => "jsonl", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct TerminalState { pub(crate) stdin: bool, @@ -807,16 +731,6 @@ pub(crate) struct WrappedPlan { pub(crate) no_spinner: bool, } -#[derive(Debug)] -pub(crate) struct HeadlessPlan { - pub(crate) source: HeadlessSource, - pub(crate) command: Vec, - pub(crate) format: Option, - pub(crate) output: Option, - pub(crate) no_auto_flags: bool, - pub(crate) timeout: Duration, -} - #[derive(Debug)] pub(crate) enum ExecutionPlan { Tui(TuiPlan), @@ -829,10 +743,8 @@ pub(crate) enum ExecutionPlan { Pricing(PricingSubcommand), Usage { json: bool }, Wrapped(WrappedPlan), - Headless(HeadlessPlan), CachePrune, CacheWarm(ResolvedSourceScope), - Antigravity(AntigravitySubcommand), Warp(WarpSubcommand), } @@ -861,12 +773,10 @@ impl ExecutionPlan { Commands::Pricing { subcommand } => Ok(Self::Pricing(subcommand)), Commands::Usage { json } => Ok(Self::Usage { json }), Commands::Wrapped(args) => resolve_wrapped(args).map(Self::Wrapped), - Commands::Headless(args) => resolve_headless(args).map(Self::Headless), Commands::Cache { subcommand } => match subcommand { CacheSubcommand::Prune => Ok(Self::CachePrune), CacheSubcommand::Warm { source } => resolve_source(source).map(Self::CacheWarm), }, - Commands::Antigravity { subcommand } => Ok(Self::Antigravity(subcommand)), Commands::Warp { subcommand } => Ok(Self::Warp(subcommand)), } } @@ -950,30 +860,6 @@ fn resolve_report(args: ReportArgs) -> Result { }) } -fn resolve_headless(args: HeadlessArgs) -> Result { - if args - .command - .first() - .is_none_or(|program| program.trim().is_empty()) - { - return Err(CliFailure::invalid_message( - "headless child command must start with a non-empty executable".to_string(), - )); - } - - let settings = tui::settings::Settings::load()?; - let timeout = settings.get_native_timeout()?; - - Ok(HeadlessPlan { - source: args.source, - command: args.command, - format: args.format, - output: args.output, - no_auto_flags: args.no_auto_flags, - timeout, - }) -} - fn resolve_source(args: SourceScopeArgs) -> Result { let home = args.home.map(|path| path.to_string_lossy().into_owned()); let clients = build_client_filter(args.clients, &home)?; diff --git a/crates/tokscale-cli/src/commands/clients.rs b/crates/tokscale-cli/src/commands/clients.rs index 229324f3f..6ddc1c4c4 100644 --- a/crates/tokscale-cli/src/commands/clients.rs +++ b/crates/tokscale-cli/src/commands/clients.rs @@ -59,10 +59,6 @@ pub(crate) fn run_clients_command( .map_err(anyhow::Error::new)?; let mut health = client_counts.health.clone(); - let headless_roots = - tokscale_core::scanner::headless_roots_with_env_strategy(&home_dir, use_env_roots); - let headless_codex_count = client_counts.headless_codex_count; - #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ClientRow { @@ -75,10 +71,6 @@ pub(crate) fn run_clients_command( #[serde(skip_serializing_if = "Vec::is_empty")] legacy_paths: Vec, message_count: i32, - headless_supported: bool, - #[serde(skip_serializing_if = "Vec::is_empty")] - headless_paths: Vec, - headless_message_count: i32, #[serde(skip_serializing_if = "Option::is_none")] exporter_status: Option, #[serde(skip_serializing_if = "Vec::is_empty")] @@ -101,13 +93,6 @@ pub(crate) fn run_clients_command( exists: bool, } - #[derive(serde::Serialize)] - #[serde(rename_all = "camelCase")] - struct HeadlessPath { - path: String, - exists: bool, - } - #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ExtraPath { @@ -198,13 +183,6 @@ pub(crate) fn run_clients_command( exists: true, })); } - if client == ClientId::Antigravity { - let path = antigravity_cli_conversations_path(&home_dir_str, use_env_roots); - additional_paths.push(AdditionalPath { - path: path.to_string_lossy().to_string(), - exists: path.exists(), - }); - } let legacy_paths = if client == ClientId::OpenClaw { vec![ LegacyPath { @@ -232,26 +210,6 @@ pub(crate) fn run_clients_command( } else { vec![] }; - let (headless_supported, headless_paths, headless_message_count) = - if client == ClientId::Codex { - ( - true, - headless_roots - .iter() - .map(|root| { - let path = root.join(client.as_str()); - HeadlessPath { - path: path.to_string_lossy().to_string(), - exists: path.exists(), - } - }) - .collect(), - headless_codex_count, - ) - } else { - (false, vec![], 0) - }; - let label = client.display_name().to_string(); let mut extra_paths: Vec = settings_extra_dirs @@ -293,9 +251,6 @@ pub(crate) fn run_clients_command( additional_paths, legacy_paths, message_count: client_counts.counts.get(client), - headless_supported, - headless_paths, - headless_message_count, exporter_status: (client == ClientId::Copilot && copilot_exporter_path.is_some()) .then(|| "configured".to_string()), @@ -311,19 +266,10 @@ pub(crate) fn run_clients_command( #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ClientsData { - headless_roots: Vec, clients: Vec, - note: String, } - let data = ClientsData { - headless_roots: headless_roots - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(), - clients, - note: "Headless capture is supported for Codex CLI only.".to_string(), - }; + let data = ClientsData { clients }; let output = ReportEnvelope::new(data, health, start.elapsed().as_millis() as u64); println!("{}", serde_json::to_string_pretty(&output)?); @@ -331,18 +277,6 @@ pub(crate) fn run_clients_command( use colored::Colorize; println!("\n {}", "Local clients & session counts".cyan()); - println!( - " {}", - format!( - "Headless roots: {}", - headless_roots - .iter() - .map(|p| p.to_string_lossy()) - .collect::>() - .join(", ") - ) - .bright_black() - ); println!(); for row in clients { @@ -416,31 +350,10 @@ pub(crate) fn run_clients_command( ); } - if row.headless_supported { - let headless_desc: Vec = row - .headless_paths - .iter() - .map(|hp| describe_path_for_home(&hp.path, hp.exists, &home_dir)) - .collect(); - println!( - " {}", - format!("headless: {}", headless_desc.join(", ")).bright_black() - ); - println!( - " {}", - format!( - "messages: {} (headless: {})", - format_number(row.message_count), - format_number(row.headless_message_count) - ) - .bright_black() - ); - } else { - println!( - " {}", - format!("messages: {}", format_number(row.message_count)).bright_black() - ); - } + println!( + " {}", + format!("messages: {}", format_number(row.message_count)).bright_black() + ); for diagnostic in &row.diagnostics { println!( @@ -452,26 +365,11 @@ pub(crate) fn run_clients_command( println!(); } - - println!( - " {}", - "Note: Headless capture is supported for Codex CLI only.".bright_black() - ); - println!(); } Ok(()) } -pub(crate) fn antigravity_cli_conversations_path(home_dir: &str, use_env_roots: bool) -> PathBuf { - let root = tokscale_core::PathRoot::EnvVar { - var: "GEMINI_CLI_HOME", - fallback_relative: ".gemini", - } - .resolve_with_env_strategy(home_dir, use_env_roots); - root.join("antigravity-cli/conversations") -} - pub(crate) fn describe_path_for_home(path: &str, exists: bool, home: &Path) -> String { let path_display = path.replace(&home.to_string_lossy().to_string(), "~"); if exists { diff --git a/crates/tokscale-cli/src/commands/headless.rs b/crates/tokscale-cli/src/commands/headless.rs deleted file mode 100644 index 12e392e86..000000000 --- a/crates/tokscale-cli/src/commands/headless.rs +++ /dev/null @@ -1,192 +0,0 @@ -use anyhow::Result; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokscale_core::scanner::headless_roots_with_env_strategy; - -pub(crate) struct CaptureCommandOutcome { - exit_code: i32, - timed_out: bool, -} - -pub(crate) fn run_capture_command( - command: &str, - args: &[String], - output_path: &Path, - timeout: Duration, -) -> Result { - use std::io::{Read, Write}; - use std::process::Command; - use std::thread; - use std::time::Instant; - - let mut child = Command::new(command) - .args(args) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .stdin(std::process::Stdio::inherit()) - .spawn() - .map_err(|e| anyhow::anyhow!("Failed to spawn '{}': {}", command, e))?; - - let stdout = child - .stdout - .take() - .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout from command"))?; - - let mut output_file = std::fs::File::create(output_path).map_err(|e| { - anyhow::anyhow!( - "Failed to create output file '{}': {}", - output_path.display(), - e - ) - })?; - - let output_handle = thread::spawn(move || -> Result<()> { - let mut reader = std::io::BufReader::new(stdout); - let mut buffer = [0; 8192]; - loop { - match reader.read(&mut buffer) { - Ok(0) => return Ok(()), - Ok(n) => output_file - .write_all(&buffer[..n]) - .map_err(|e| anyhow::anyhow!("Failed to write to output file: {}", e))?, - Err(e) => { - return Err(anyhow::anyhow!( - "Failed to read from subprocess stdout: {}", - e - )); - } - } - } - }); - - let deadline = Instant::now() + timeout; - let mut timed_out = false; - let status = loop { - if let Some(status) = child - .try_wait() - .map_err(|e| anyhow::anyhow!("Failed to wait for subprocess: {}", e))? - { - break status; - } - - if Instant::now() >= deadline { - timed_out = true; - let _ = child.kill(); - break child - .wait() - .map_err(|e| anyhow::anyhow!("Failed to wait for timed-out subprocess: {}", e))?; - } - - thread::sleep(Duration::from_millis(25)); - }; - - let output_result = output_handle - .join() - .map_err(|_| anyhow::anyhow!("Subprocess stdout reader thread panicked"))?; - if !timed_out { - output_result?; - } - - Ok(CaptureCommandOutcome { - exit_code: status.code().unwrap_or(1), - timed_out, - }) -} - -pub(crate) fn run_headless_command( - source: &str, - command: Vec, - format: Option<&str>, - output: Option, - no_auto_flags: bool, - timeout: Duration, -) -> Result<()> { - use chrono::Utc; - use uuid::Uuid; - - let source_lower = source.to_lowercase(); - anyhow::ensure!( - source_lower == "codex", - "unsupported headless source `{source}`" - ); - let (program, child_args) = command - .split_first() - .ok_or_else(|| anyhow::anyhow!("headless child command must not be empty"))?; - - let resolved_format = match format { - Some(f) if f == "json" || f == "jsonl" => f.to_string(), - Some(f) => anyhow::bail!("invalid headless format `{f}`"), - None => "jsonl".to_string(), - }; - - let mut final_args = child_args.to_vec(); - if !no_auto_flags && source_lower == "codex" && !final_args.contains(&"--json".to_string()) { - final_args.push("--json".to_string()); - } - - let home_dir = - dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; - let headless_roots = headless_roots_with_env_strategy(&home_dir, true); - - let output_path: PathBuf = if let Some(custom_output) = output { - let path = PathBuf::from(custom_output); - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent)?; - path - } else { - let root = headless_roots - .first() - .cloned() - .unwrap_or_else(|| home_dir.join(".config/tokscale/headless")); - let dir = root.join(&source_lower); - std::fs::create_dir_all(&dir)?; - - let now = Utc::now(); - let timestamp = now.format("%Y-%m-%dT%H-%M-%S-%3fZ").to_string(); - let uuid_short = Uuid::new_v4() - .to_string() - .replace("-", "") - .chars() - .take(8) - .collect::(); - let filename = format!( - "{}-{}-{}.{}", - source_lower, timestamp, uuid_short, resolved_format - ); - - dir.join(filename) - }; - - use colored::Colorize; - eprintln!("\n {}", "Headless capture".cyan()); - eprintln!(" {}", format!("source: {}", source_lower).bright_black()); - eprintln!( - " {}", - format!("output: {}", output_path.display()).bright_black() - ); - eprintln!( - " {}", - format!("timeout: {}s", timeout.as_secs()).bright_black() - ); - eprintln!(); - - let outcome = run_capture_command(program, &final_args, &output_path, timeout)?; - - if outcome.timed_out { - eprintln!( - "{}", - format!("\n Subprocess timed out after {}s", timeout.as_secs()).red() - ); - eprintln!("{}", " Partial output saved. Increase timeout with TOKSCALE_NATIVE_TIMEOUT_MS or settings.json".bright_black()); - std::process::exit(124); - } - - eprintln!("{}", "✓ Headless output saved".green()); - println!("{}", output_path.display()); - - if outcome.exit_code != 0 { - std::process::exit(outcome.exit_code); - } - - Ok(()) -} diff --git a/crates/tokscale-cli/src/commands/integrations.rs b/crates/tokscale-cli/src/commands/integrations.rs index 0a9f243c8..f67b4e88d 100644 --- a/crates/tokscale-cli/src/commands/integrations.rs +++ b/crates/tokscale-cli/src/commands/integrations.rs @@ -1,15 +1,7 @@ -use crate::cli::{AntigravitySubcommand, WarpSubcommand}; -use crate::{antigravity, warp}; +use crate::cli::WarpSubcommand; +use crate::warp; use anyhow::Result; -pub(crate) fn run_antigravity_command(subcommand: AntigravitySubcommand) -> Result<()> { - match subcommand { - AntigravitySubcommand::Sync => antigravity::run_antigravity_sync(), - AntigravitySubcommand::Status { json } => antigravity::run_antigravity_status(json), - AntigravitySubcommand::PurgeCache => antigravity::run_antigravity_purge_cache(), - } -} - pub(crate) fn run_warp_command(subcommand: WarpSubcommand) -> Result<()> { match subcommand { WarpSubcommand::Login { token, cookie } => warp::run_warp_login(token, cookie), diff --git a/crates/tokscale-cli/src/commands/mod.rs b/crates/tokscale-cli/src/commands/mod.rs index 6ae1745ba..8e5cb5039 100644 --- a/crates/tokscale-cli/src/commands/mod.rs +++ b/crates/tokscale-cli/src/commands/mod.rs @@ -1,7 +1,6 @@ pub mod cache; pub mod clients; pub mod graph; -pub mod headless; pub mod hourly; pub mod integrations; pub mod models; diff --git a/crates/tokscale-cli/src/failure.rs b/crates/tokscale-cli/src/failure.rs index 17ff3a6ef..8b1df04e1 100644 --- a/crates/tokscale-cli/src/failure.rs +++ b/crates/tokscale-cli/src/failure.rs @@ -2,7 +2,7 @@ use std::fmt; use tokscale_core::LocalReportError; -use crate::tui::settings::{NativeTimeoutError, SettingsLoadError}; +use crate::tui::settings::SettingsLoadError; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FailureClass { @@ -37,7 +37,6 @@ impl CliFailure { fn classify(error: &anyhow::Error) -> FailureClass { if error.is::() - || error.is::() || error .downcast_ref::() .is_some_and(LocalReportError::is_invalid_invocation) @@ -67,12 +66,6 @@ impl From for CliFailure { } } -impl From for CliFailure { - fn from(error: NativeTimeoutError) -> Self { - Self::from(anyhow::Error::new(error)) - } -} - impl fmt::Display for CliFailure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { if self.error.is::() { diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 4e9540cc0..63a80364b 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -1,4 +1,3 @@ -mod antigravity; mod claude_diagnostics; mod cli; mod commands; @@ -8,16 +7,12 @@ mod tui; mod warp; use anyhow::Result; -use cli::{ - Cli, ExecutionPlan, HeadlessFormat, PricingSource, PricingSubcommand, TerminalState, - WrappedPlan, -}; +use cli::{Cli, ExecutionPlan, PricingSource, PricingSubcommand, TerminalState, WrappedPlan}; use commands::cache::{run_source_cache_prune, run_warm_tui_cache}; use commands::clients::run_clients_command; use commands::graph::run_graph_command; -use commands::headless::run_headless_command; use commands::hourly::run_hourly_report; -use commands::integrations::{run_antigravity_command, run_warp_command}; +use commands::integrations::run_warp_command; use commands::models::run_models_report; use commands::monthly::run_monthly_report; use commands::pricing::{run_pricing_list_overrides, run_pricing_lookup}; @@ -161,17 +156,8 @@ fn execute(plan: ExecutionPlan) -> std::result::Result commands::usage::run(json), ExecutionPlan::Wrapped(plan) => run_wrapped_command(plan), - ExecutionPlan::Headless(args) => run_headless_command( - args.source.as_str(), - args.command, - args.format.map(HeadlessFormat::as_str), - args.output, - args.no_auto_flags, - args.timeout, - ), ExecutionPlan::CachePrune => run_source_cache_prune(), ExecutionPlan::CacheWarm(source) => run_warm_tui_cache(source.home, source.clients), - ExecutionPlan::Antigravity(subcommand) => run_antigravity_command(subcommand), ExecutionPlan::Warp(subcommand) => run_warp_command(subcommand), }?; diff --git a/crates/tokscale-cli/src/main_tests.rs b/crates/tokscale-cli/src/main_tests.rs index 9daa2f167..cec057bb7 100644 --- a/crates/tokscale-cli/src/main_tests.rs +++ b/crates/tokscale-cli/src/main_tests.rs @@ -1,9 +1,8 @@ use crate::cli::*; -use crate::commands::clients::*; use crate::commands::render::*; use crate::commands::shared::*; use clap::Parser; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokscale_core::ClientId; #[test] @@ -709,11 +708,6 @@ fn legacy_v4_invocations_get_one_migration_hint_without_becoming_aliases() { None, "migration hints must never suggest another invalid invocation" ); - assert_eq!( - legacy_invocation_hint(&strings(&["headless", "codex", "--", "tui", "--json"])), - None, - "child-process arguments after -- must not influence migration hints" - ); for unrelated in [ &["wrapped", "--json"][..], &["clients", "--benchmark"], @@ -806,23 +800,6 @@ fn effective_spinner_policy_keeps_json_quiet_without_erasing_explicit_intent() { assert!(super::effective_no_spinner(true, true)); } -#[test] -fn headless_plan_rejects_a_blank_child_executable() { - let cli = Cli::try_parse_from(["tokscale", "headless", "codex", "--", ""]) - .expect("Clap accepts the present but empty COMMAND token"); - let error = ExecutionPlan::resolve( - cli, - TerminalState { - stdin: false, - stdout: false, - }, - ) - .expect_err("a blank executable must not enter the execution plan"); - - assert_eq!(error.exit_code(), 2); - assert!(error.to_string().contains("non-empty executable")); -} - #[test] fn tui_execution_plan_requires_both_interactive_streams() { for terminal in [ @@ -956,27 +933,30 @@ fn client_id_parses_grok() { } #[test] -fn clap_rejects_antigravity_cli_as_separate_client() { +fn antigravity_is_a_local_client_without_an_integration_command_namespace() { assert!(Cli::try_parse_from(["tokscale", "models", "--client", "antigravity"]).is_ok()); assert!(Cli::try_parse_from(["tokscale", "models", "--client", "antigravity-cli"]).is_err()); + assert!(Cli::try_parse_from(["tokscale", "antigravity", "status"]).is_err()); } #[test] -fn antigravity_cli_conversations_path_uses_home_when_env_roots_disabled() { +fn antigravity_local_source_uses_home_when_env_roots_are_disabled() { + let def = ClientId::Antigravity.local_def().unwrap(); assert_eq!( - antigravity_cli_conversations_path("/tmp/home", false), + def.resolve_path_with_env_strategy("/tmp/home", false), PathBuf::from("/tmp/home/.gemini/antigravity-cli/conversations") ); } #[test] #[serial_test::serial] -fn antigravity_cli_conversations_path_falls_back_for_blank_env() { +fn antigravity_local_source_falls_back_for_blank_env() { let previous = std::env::var("GEMINI_CLI_HOME").ok(); unsafe { std::env::set_var("GEMINI_CLI_HOME", " ") }; + let def = ClientId::Antigravity.local_def().unwrap(); assert_eq!( - antigravity_cli_conversations_path("/tmp/home", true), + def.resolve_path_with_env_strategy("/tmp/home", true), PathBuf::from("/tmp/home/.gemini/antigravity-cli/conversations") ); @@ -985,44 +965,3 @@ fn antigravity_cli_conversations_path_falls_back_for_blank_env() { None => unsafe { std::env::remove_var("GEMINI_CLI_HOME") }, } } - -#[test] -#[serial_test::serial] -fn headless_roots_ignore_blank_env_override() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", " ") }; - - let roots = tokscale_core::scanner::headless_roots_with_env_strategy( - Path::new("/tmp/tokscale-home"), - true, - ); - - assert!(!roots.contains(&PathBuf::from(" "))); - assert!(roots.contains(&PathBuf::from( - "/tmp/tokscale-home/.config/tokscale/headless" - ))); - - match previous { - Some(value) => unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", value) }, - None => unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }, - } -} - -#[test] -#[serial_test::serial] -fn headless_roots_trim_env_override() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", " /tmp/custom-headless ") }; - - let roots = tokscale_core::scanner::headless_roots_with_env_strategy( - Path::new("/tmp/tokscale-home"), - true, - ); - - assert_eq!(roots, vec![PathBuf::from("/tmp/custom-headless")]); - - match previous { - Some(value) => unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", value) }, - None => unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }, - } -} diff --git a/crates/tokscale-cli/src/tui/settings.rs b/crates/tokscale-cli/src/tui/settings.rs index 258b327f9..682c13524 100644 --- a/crates/tokscale-cli/src/tui/settings.rs +++ b/crates/tokscale-cli/src/tui/settings.rs @@ -13,10 +13,6 @@ const DEFAULT_AUTO_REFRESH_MS: u64 = 60_000; const MIN_AUTO_REFRESH_MS: u64 = 30_000; const MAX_AUTO_REFRESH_MS: u64 = 3_600_000; -const DEFAULT_NATIVE_TIMEOUT_MS: u64 = 300_000; -const MIN_NATIVE_TIMEOUT_MS: u64 = 5_000; -const MAX_NATIVE_TIMEOUT_MS: u64 = 3_600_000; - #[derive(Debug, thiserror::Error)] pub(crate) enum SettingsLoadError { #[error(transparent)] @@ -51,30 +47,12 @@ impl SettingsLoadError { pub(crate) enum SettingsValidationError { #[error("invalid autoRefreshMs {value}; expected {min}..={max}")] AutoRefreshRange { value: u64, min: u64, max: u64 }, - #[error("invalid nativeTimeoutMs {value}; expected {min}..={max}")] - NativeTimeoutRange { value: u64, min: u64, max: u64 }, #[error("invalid colorPalette `{value}`; expected one of: {valid}")] ColorPalette { value: String, valid: String }, #[error("invalid scanner settings: {0}")] Scanner(#[from] ScannerSettingsError), } -#[derive(Debug, thiserror::Error)] -pub(crate) enum NativeTimeoutError { - #[error("TOKSCALE_NATIVE_TIMEOUT_MS must be a positive integer: {source}")] - NotInteger { - #[source] - source: std::num::ParseIntError, - }, - #[error("failed to read TOKSCALE_NATIVE_TIMEOUT_MS: {source}")] - NotUnicode { - #[source] - source: std::env::VarError, - }, - #[error("invalid TOKSCALE_NATIVE_TIMEOUT_MS {value}; expected {min}..={max}")] - OutOfRange { value: u64, min: u64, max: u64 }, -} - #[derive(Debug, Clone, Copy)] enum ExplicitHomeConfigLayout { UnixDotConfig, @@ -102,8 +80,6 @@ pub struct Settings { pub auto_refresh_ms: u64, #[serde(default)] pub include_unused_models: bool, - #[serde(default = "default_native_timeout_ms")] - pub native_timeout_ms: u64, /// Persistent scanner configuration. Allows users to pin additional /// OpenCode SQLite paths (and, in future, other scanner overrides) /// without having to set env vars on every invocation. @@ -147,10 +123,6 @@ fn default_auto_refresh_ms() -> u64 { DEFAULT_AUTO_REFRESH_MS } -fn default_native_timeout_ms() -> u64 { - DEFAULT_NATIVE_TIMEOUT_MS -} - impl Default for Settings { fn default() -> Self { Self { @@ -158,7 +130,6 @@ impl Default for Settings { auto_refresh_enabled: false, auto_refresh_ms: DEFAULT_AUTO_REFRESH_MS, include_unused_models: false, - native_timeout_ms: DEFAULT_NATIVE_TIMEOUT_MS, scanner: ScannerSettings::default(), default_clients: Vec::new(), usage_tab_enabled: false, @@ -197,13 +168,6 @@ impl Settings { max: MAX_AUTO_REFRESH_MS, }); } - if !(MIN_NATIVE_TIMEOUT_MS..=MAX_NATIVE_TIMEOUT_MS).contains(&self.native_timeout_ms) { - return Err(SettingsValidationError::NativeTimeoutRange { - value: self.native_timeout_ms, - min: MIN_NATIVE_TIMEOUT_MS, - max: MAX_NATIVE_TIMEOUT_MS, - }); - } if self.color_palette.parse::().is_err() { let valid = ThemeName::all() .iter() @@ -352,26 +316,6 @@ impl Settings { None } } - - pub fn get_native_timeout(&self) -> std::result::Result { - let timeout_ms = match std::env::var("TOKSCALE_NATIVE_TIMEOUT_MS") { - Ok(value) => value - .parse::() - .map_err(|source| NativeTimeoutError::NotInteger { source })?, - Err(std::env::VarError::NotPresent) => self.native_timeout_ms, - Err(source) => { - return Err(NativeTimeoutError::NotUnicode { source }); - } - }; - if !(MIN_NATIVE_TIMEOUT_MS..=MAX_NATIVE_TIMEOUT_MS).contains(&timeout_ms) { - return Err(NativeTimeoutError::OutOfRange { - value: timeout_ms, - min: MIN_NATIVE_TIMEOUT_MS, - max: MAX_NATIVE_TIMEOUT_MS, - }); - } - Ok(Duration::from_millis(timeout_ms)) - } } #[cfg(test)] @@ -516,7 +460,7 @@ mod tests { let temp = tempfile::TempDir::new().unwrap(); let path = Settings::explicit_home_config_path(temp.path()); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, r#"{"autoRefreshMs":1,"nativeTimeoutMs":300000}"#).unwrap(); + fs::write(&path, r#"{"autoRefreshMs":1}"#).unwrap(); let error = Settings::load_for_home_override(Some(temp.path())).unwrap_err(); let message = format!("{error:#}"); @@ -540,24 +484,6 @@ mod tests { assert!(error.is_invalid_environment()); } - #[test] - #[serial_test::serial] - fn native_timeout_environment_rejects_invalid_values() { - let previous = std::env::var_os("TOKSCALE_NATIVE_TIMEOUT_MS"); - unsafe { std::env::set_var("TOKSCALE_NATIVE_TIMEOUT_MS", "not-a-number") }; - - let error = Settings::default().get_native_timeout().unwrap_err(); - assert!( - format!("{error:#}").contains("must be a positive integer"), - "{error:#}" - ); - - match previous { - Some(value) => unsafe { std::env::set_var("TOKSCALE_NATIVE_TIMEOUT_MS", value) }, - None => unsafe { std::env::remove_var("TOKSCALE_NATIVE_TIMEOUT_MS") }, - } - } - #[test] fn settings_load_backfills_scanner_when_missing_from_json() { // Older settings.json files predate the `scanner` key. They must @@ -566,8 +492,7 @@ mod tests { "colorPalette": "blue", "autoRefreshEnabled": false, "autoRefreshMs": 60000, - "includeUnusedModels": false, - "nativeTimeoutMs": 300000 + "includeUnusedModels": false }"#; let parsed: Settings = serde_json::from_str(json).unwrap(); assert!(parsed.scanner.opencode_db_paths.is_empty()); @@ -580,7 +505,6 @@ mod tests { "autoRefreshEnabled": false, "autoRefreshMs": 60000, "includeUnusedModels": false, - "nativeTimeoutMs": 300000, "scanner": { "opencodeDbPaths": [ "/custom/one.db", @@ -605,7 +529,6 @@ mod tests { "autoRefreshEnabled": false, "autoRefreshMs": 60000, "includeUnusedModels": false, - "nativeTimeoutMs": 300000, "scanner": { "extraScanPaths": { "codex": ["/tmp/project-a/.codex/sessions"], @@ -634,7 +557,6 @@ mod tests { "autoRefreshEnabled": false, "autoRefreshMs": 60000, "includeUnusedModels": false, - "nativeTimeoutMs": 300000, "scanner": {} }"#; let parsed: Settings = serde_json::from_str(json).unwrap(); @@ -662,7 +584,6 @@ mod tests { "autoRefreshEnabled": false, "autoRefreshMs": 60000, "includeUnusedModels": false, - "nativeTimeoutMs": 300000, "scanner": { "extraScanPaths": { "gemini": ["/tmp/imports/gemini/tmp"] @@ -704,8 +625,7 @@ mod tests { "colorPalette": "blue", "autoRefreshEnabled": false, "autoRefreshMs": 60000, - "includeUnusedModels": false, - "nativeTimeoutMs": 300000 + "includeUnusedModels": false }"#; let parsed: Settings = serde_json::from_str(json).unwrap(); assert!(parsed.default_clients.is_empty()); @@ -721,7 +641,6 @@ mod tests { "autoRefreshEnabled": false, "autoRefreshMs": 60000, "includeUnusedModels": false, - "nativeTimeoutMs": 300000, "defaultClients": ["opencode", "claude", "zed"] }"#; let parsed: Settings = serde_json::from_str(json).unwrap(); diff --git a/crates/tokscale-cli/tests/cli_tests.rs b/crates/tokscale-cli/tests/cli_tests.rs index d05a3d7db..521fa5574 100644 --- a/crates/tokscale-cli/tests/cli_tests.rs +++ b/crates/tokscale-cli/tests/cli_tests.rs @@ -4,7 +4,7 @@ use predicates::prelude::*; use rusqlite::Connection; use std::fs; use std::path::Path; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::TempDir; // ── Fixture helpers ──────────────────────────────────────────────────────── @@ -149,146 +149,6 @@ fn create_temp_fixture_dir() -> TempDir { create_temp_fixture_dir_with_pricing_cache(true) } -fn create_fake_codex_bin() -> TempDir { - let tmp = TempDir::new().expect("failed to create fake codex dir"); - let codex_path = tmp.path().join("codex"); - fs::write( - &codex_path, - r#"#!/bin/sh -case "$TOKSCALE_FAKE_CODEX_MODE" in - success) - printf 'captured ok' - exit 0 - ;; - fail) - printf 'captured fail' - exit 17 - ;; - slow) - exec sleep 20 - ;; - *) - echo "unknown TOKSCALE_FAKE_CODEX_MODE" >&2 - exit 2 - ;; -esac -"#, - ) - .unwrap(); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(&codex_path).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&codex_path, permissions).unwrap(); - } - - tmp -} - -fn headless_capture_command(fake_bin: &Path, output_path: &Path, mode: &str) -> Command { - let mut cmd = cargo_bin_cmd!("tokscale"); - let path = std::env::var_os("PATH").unwrap_or_default(); - let joined_path = std::env::join_paths( - std::iter::once(fake_bin.to_path_buf()).chain(std::env::split_paths(&path)), - ) - .unwrap(); - - cmd.env("HOME", fake_bin) - .env("TOKSCALE_FAKE_CODEX_MODE", mode) - .env("TOKSCALE_NATIVE_TIMEOUT_MS", "10000") - .env("PATH", joined_path) - .args([ - "headless", - "--output", - output_path.to_str().unwrap(), - "--no-auto-flags", - "codex", - "--", - "codex", - ]); - - cmd -} - -#[test] -fn headless_capture_fast_success_does_not_wait_for_timeout() { - let fake_bin = create_fake_codex_bin(); - let output_path = fake_bin.path().join("success.jsonl"); - - let started = Instant::now(); - headless_capture_command(fake_bin.path(), &output_path, "success") - .assert() - .success(); - let elapsed = started.elapsed(); - - assert!( - elapsed < Duration::from_secs(8), - "fast success waited too long: {elapsed:?}" - ); - assert_eq!(fs::read_to_string(output_path).unwrap(), "captured ok"); -} - -#[test] -fn headless_capture_fast_nonzero_preserves_exit_code() { - let fake_bin = create_fake_codex_bin(); - let output_path = fake_bin.path().join("fail.jsonl"); - - let started = Instant::now(); - headless_capture_command(fake_bin.path(), &output_path, "fail") - .assert() - .failure() - .code(17); - let elapsed = started.elapsed(); - - assert!( - elapsed < Duration::from_secs(8), - "fast failure waited too long: {elapsed:?}" - ); - assert_eq!(fs::read_to_string(output_path).unwrap(), "captured fail"); -} - -#[test] -fn headless_rejects_invalid_native_timeout_before_starting_child() { - for value in ["bogus", "1"] { - let fake_bin = create_fake_codex_bin(); - let output_path = fake_bin - .path() - .join(format!("invalid-timeout-{value}.jsonl")); - - headless_capture_command(fake_bin.path(), &output_path, "success") - .env("TOKSCALE_NATIVE_TIMEOUT_MS", value) - .assert() - .code(2) - .stdout(predicate::str::is_empty()) - .stderr(predicate::str::contains("TOKSCALE_NATIVE_TIMEOUT_MS")); - - assert!( - !output_path.exists(), - "invalid execution environment must fail before creating output" - ); - } -} - -#[test] -fn headless_capture_slow_command_times_out() { - let fake_bin = create_fake_codex_bin(); - let output_path = fake_bin.path().join("slow.jsonl"); - - let started = Instant::now(); - headless_capture_command(fake_bin.path(), &output_path, "slow") - .assert() - .failure() - .code(124); - let elapsed = started.elapsed(); - - assert!( - elapsed >= Duration::from_secs(10) && elapsed < Duration::from_secs(14), - "slow command timeout duration was unexpected: {elapsed:?}" - ); -} - fn create_temp_fixture_dir_without_pricing_cache() -> TempDir { create_temp_fixture_dir_with_pricing_cache(false) } @@ -651,7 +511,6 @@ fn cmd_with_home(tmp: &Path) -> Command { // codefuse mirror tracking) makes the scanner read real session data // and breaks fixture-count assertions. Hermetic on CI either way. .env_remove("TOKSCALE_EXTRA_DIRS") - .env_remove("TOKSCALE_HEADLESS_DIR") .env_remove("CODEX_HOME") .env_remove("COPILOT_OTEL_FILE_EXPORTER_PATH") .env_remove("GOOSE_PATH_ROOT") @@ -687,7 +546,6 @@ fn offline_cmd_with_home(tmp: &Path) -> Command { .env("ALL_PROXY", "http://127.0.0.1:9") // Clear scan-path overrides (mirrors cmd_with_home) .env_remove("TOKSCALE_EXTRA_DIRS") - .env_remove("TOKSCALE_HEADLESS_DIR") .env_remove("CODEX_HOME") .env_remove("COPILOT_OTEL_FILE_EXPORTER_PATH") .env_remove("GOOSE_PATH_ROOT") @@ -1046,13 +904,20 @@ fn test_help_exposes_only_leaf_owned_options() { } #[test] -fn test_headless_command_help() { - let mut cmd = cargo_bin_cmd!("tokscale"); - cmd.arg("headless") +fn test_headless_command_is_not_registered() { + cargo_bin_cmd!("tokscale") .arg("--help") .assert() .success() - .stdout(predicate::str::contains("Capture subprocess output")); + .stdout(predicate::str::contains("headless").not()); + + cargo_bin_cmd!("tokscale") + .arg("headless") + .assert() + .code(2) + .stderr(predicate::str::contains( + "unrecognized subcommand 'headless'", + )); } #[test] @@ -1088,33 +953,6 @@ fn test_pricing_command_missing_model() { cmd.arg("pricing").assert().failure(); } -#[test] -fn test_headless_command_missing_client() { - let mut cmd = cargo_bin_cmd!("tokscale"); - cmd.arg("headless").assert().failure(); -} - -#[test] -fn test_headless_command_invalid_client() { - let mut cmd = cargo_bin_cmd!("tokscale"); - cmd.arg("headless") - .arg("invalid-client") - .arg("test") - .assert() - .failure(); -} - -#[test] -fn test_headless_requires_explicit_child_command_separator() { - cargo_bin_cmd!("tokscale") - .args(["headless", "codex", "codex", "exec"]) - .assert() - .code(2) - .stderr(predicate::str::contains( - "separate Tokscale options from the child command with `--`", - )); -} - #[test] fn test_models_with_invalid_date_format() { let tmp = create_empty_fixture_dir(); @@ -3141,10 +2979,7 @@ fn excluded_crush_default_client_fails_before_report_output() { #[test] fn invalid_settings_range_is_invalid_execution_environment() { let tmp = create_empty_fixture_dir(); - write_settings_json( - tmp.path(), - r#"{"autoRefreshMs":1,"nativeTimeoutMs":300000}"#, - ); + write_settings_json(tmp.path(), r#"{"autoRefreshMs":1}"#); cmd_with_home(tmp.path()) .args(["clients", "--home", tmp.path().to_str().unwrap()]) @@ -3213,14 +3048,8 @@ fn test_clients_json() { json["data"].get("clients").is_some(), "Should have 'clients' field" ); - assert!( - json["data"].get("headlessRoots").is_some(), - "Should have 'headlessRoots' field" - ); - assert!( - json["data"].get("note").is_some(), - "Should have 'note' field" - ); + assert!(json["data"].get("headlessRoots").is_none()); + assert!(json["data"].get("note").is_none()); assert_eq!(json["health"]["complete"], true); let arr = json["data"]["clients"].as_array().unwrap(); diff --git a/crates/tokscale-cli/tests/copilot_memory.rs b/crates/tokscale-cli/tests/copilot_memory.rs index 37bd29e30..75cedd6e6 100644 --- a/crates/tokscale-cli/tests/copilot_memory.rs +++ b/crates/tokscale-cli/tests/copilot_memory.rs @@ -180,7 +180,6 @@ fn run_copilot_report(home: &Path) -> (Vec, u64) { .env("TOKSCALE_PRICING_CACHE_ONLY", "1") .env_remove("TOKSCALE_CONFIG_DIR") .env_remove("TOKSCALE_EXTRA_DIRS") - .env_remove("TOKSCALE_HEADLESS_DIR") .env_remove("COPILOT_OTEL_FILE_EXPORTER_PATH") .output() .unwrap(); diff --git a/crates/tokscale-core/src/adapters/antigravity.rs b/crates/tokscale-core/src/adapters/antigravity.rs index 6cf842762..ef9eaab6f 100644 --- a/crates/tokscale-core/src/adapters/antigravity.rs +++ b/crates/tokscale-core/src/adapters/antigravity.rs @@ -9,14 +9,10 @@ use crate::adapters::{ AdapterScanContext, FingerprintPolicy, FoldContext, LocalSourceAdapter, MessageSink, ParseContext, ParsedBatchSource, ParsedUnit, SourceDiscoveryError, SourceUnit, SourceUnitMeta, }; -use crate::clients::{ClientId, PathRoot}; +use crate::clients::ClientId; use crate::message_cache::{ParserId, ParserVersion}; use crate::sessions; -const CLI_RELATIVE_PATH: &str = "antigravity-cli/conversations"; -const CLI_PATTERN: &str = "*.db"; -const ANTIGRAVITY_CACHE_RECORD_REJECTION_REVISION: u32 = - crate::adapters::MODEL_ID_CANONICALIZATION_REVISION + 1; const ANTIGRAVITY_CLI_RECORD_REJECTION_REVISION: u32 = crate::adapters::EXPLICIT_TOKEN_OVERFLOW_REVISION + 1; @@ -34,62 +30,29 @@ impl LocalSourceAdapter for AntigravityAdapter { let def = ClientId::Antigravity .local_def() .expect("Antigravity adapter requires a local scan policy"); - let default_ide_root = def.resolve_path_with_env_strategy(ctx.home_dir, ctx.use_env_roots); - let extra_roots = antigravity_extra_roots(ctx)?; + let mut roots = vec![def.resolve_path_with_env_strategy(ctx.home_dir, ctx.use_env_roots)]; + roots.extend(antigravity_extra_roots(ctx)?); - let mut ide_roots = vec![default_ide_root]; - ide_roots.extend(extra_roots.iter().cloned()); - let mut units = adapter_discover::source_units_from_paths( + Ok(adapter_discover::source_units_from_paths( ClientId::Antigravity, - adapter_discover::scan_roots(ClientId::Antigravity, ide_roots, def.pattern)?, - FingerprintPolicy::NoMessageCache, + adapter_discover::scan_roots(ClientId::Antigravity, roots, def.pattern)?, + FingerprintPolicy::SqliteWithWal, )? .into_iter() .map(|unit| { - unit.with_meta(SourceUnitMeta::AntigravityCacheJsonl) + unit.with_meta(SourceUnitMeta::AntigravityCliSqlite) .with_parser_version(ParserVersion::new( - ParserId::AntigravityCacheJsonl, - ANTIGRAVITY_CACHE_RECORD_REJECTION_REVISION, + ParserId::AntigravityCliSqlite, + ANTIGRAVITY_CLI_RECORD_REJECTION_REVISION, )) }) - .collect::>(); - - let cli_root = PathRoot::EnvVar { - var: "GEMINI_CLI_HOME", - fallback_relative: ".gemini", - } - .resolve_with_env_strategy(ctx.home_dir, ctx.use_env_roots); - let mut cli_roots = vec![cli_root.join(CLI_RELATIVE_PATH)]; - cli_roots.extend(extra_roots); - - units.extend( - adapter_discover::source_units_from_paths( - ClientId::Antigravity, - adapter_discover::scan_roots(ClientId::Antigravity, cli_roots, CLI_PATTERN)?, - FingerprintPolicy::SqliteWithWal, - )? - .into_iter() - .map(|unit| { - unit.with_meta(SourceUnitMeta::AntigravityCliSqlite) - .with_parser_version(ParserVersion::new( - ParserId::AntigravityCliSqlite, - ANTIGRAVITY_CLI_RECORD_REJECTION_REVISION, - )) - }), - ); - - Ok(units) + .collect()) } fn parse_checked(&self, units: Vec, ctx: &ParseContext<'_>) -> Vec { units .into_par_iter() .map(|unit| match unit.meta { - SourceUnitMeta::AntigravityCacheJsonl => adapter_cache::load_or_scan_unit_with( - unit, - ctx, - sessions::antigravity::parse_antigravity_file, - ), SourceUnitMeta::AntigravityCliSqlite => adapter_cache::load_or_scan_unit_with( unit, ctx, @@ -102,7 +65,7 @@ impl LocalSourceAdapter for AntigravityAdapter { | SourceUnitMeta::KiroGlobalStorage | SourceUnitMeta::CodeBuddyJsonl | SourceUnitMeta::CodeBuddyExtensionLog { .. } - | SourceUnitMeta::Codex { .. } => { + | SourceUnitMeta::Codex => { unreachable!("unexpected Antigravity source unit meta") } }) @@ -241,51 +204,36 @@ mod tests { } #[test] - fn discovers_ide_cache_and_cli_databases_as_antigravity_sources() { + fn discovers_provider_owned_cli_databases() { let home = tempfile::TempDir::new().unwrap(); - let cache_path = home - .path() - .join(".config/tokscale/antigravity-cache/sessions/session.jsonl"); let cli_path = home .path() .join(".gemini/antigravity-cli/conversations/session.db"); - for path in [&cache_path, &cli_path] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, "").unwrap(); - } + std::fs::create_dir_all(cli_path.parent().unwrap()).unwrap(); + std::fs::write(&cli_path, "").unwrap(); let settings = ScannerSettings::default(); let units = ANTIGRAVITY_ADAPTER .discover_checked(&scan_context(home.path(), &settings)) .unwrap(); - assert_eq!(units.len(), 2); - assert!(units.iter().any(|unit| { - unit.client == ClientId::Antigravity - && unit.path == cache_path - && unit.fingerprint_policy == FingerprintPolicy::NoMessageCache - && unit.parser_version - == ParserVersion::new( - ParserId::AntigravityCacheJsonl, - ANTIGRAVITY_CACHE_RECORD_REJECTION_REVISION, - ) - && matches!(unit.meta, SourceUnitMeta::AntigravityCacheJsonl) - })); - assert!(units.iter().any(|unit| { - unit.client == ClientId::Antigravity - && unit.path == cli_path - && unit.fingerprint_policy == FingerprintPolicy::SqliteWithWal - && unit.parser_version - == ParserVersion::new( - ParserId::AntigravityCliSqlite, - ANTIGRAVITY_CLI_RECORD_REJECTION_REVISION, - ) - && matches!(unit.meta, SourceUnitMeta::AntigravityCliSqlite) - })); + assert_eq!(units.len(), 1); + let unit = &units[0]; + assert_eq!(unit.client, ClientId::Antigravity); + assert_eq!(unit.path, cli_path); + assert_eq!(unit.fingerprint_policy, FingerprintPolicy::SqliteWithWal); + assert_eq!( + unit.parser_version, + ParserVersion::new( + ParserId::AntigravityCliSqlite, + ANTIGRAVITY_CLI_RECORD_REJECTION_REVISION, + ) + ); + assert!(matches!(unit.meta, SourceUnitMeta::AntigravityCliSqlite)); } #[test] - fn discovers_extra_roots_as_ide_jsonl_and_cli_sqlite_sources() { + fn extra_roots_accept_cli_databases_but_ignore_shadow_jsonl() { let home = tempfile::TempDir::new().unwrap(); let extra = tempfile::TempDir::new().unwrap(); let jsonl_path = extra.path().join("extra-session.jsonl"); @@ -303,16 +251,17 @@ mod tests { .discover_checked(&scan_context(home.path(), &settings)) .unwrap(); - assert!(units.iter().any(|unit| { - unit.path == jsonl_path - && unit.fingerprint_policy == FingerprintPolicy::NoMessageCache - && matches!(unit.meta, SourceUnitMeta::AntigravityCacheJsonl) - })); - assert!(units.iter().any(|unit| { - unit.path == db_path - && unit.fingerprint_policy == FingerprintPolicy::SqliteWithWal - && matches!(unit.meta, SourceUnitMeta::AntigravityCliSqlite) - })); + assert_eq!(units.len(), 1); + assert_eq!(units[0].path, db_path); + assert_eq!( + units[0].fingerprint_policy, + FingerprintPolicy::SqliteWithWal + ); + assert!(matches!( + units[0].meta, + SourceUnitMeta::AntigravityCliSqlite + )); + assert!(!units.iter().any(|unit| unit.path == jsonl_path)); } #[test] @@ -362,31 +311,31 @@ mod tests { } #[test] - fn fold_dedupes_shared_ide_and_cli_response_ids() { + fn fold_dedupes_response_ids_across_cli_databases() { let dir = tempfile::TempDir::new().unwrap(); - let dedup_key = sessions::antigravity::response_dedup_key("resp-shared"); - let ide = parsed_unit( - &dir.path().join("ide.jsonl"), - SourceUnitMeta::AntigravityCacheJsonl, - antigravity_message("ide-session", Some(dedup_key)), + let dedup_key = sessions::antigravity_cli::response_dedup_key("resp-shared"); + let first = parsed_unit( + &dir.path().join("first.db"), + SourceUnitMeta::AntigravityCliSqlite, + antigravity_message("first-session", Some(dedup_key)), ); - let cli = parsed_unit( - &dir.path().join("cli.db"), + let second = parsed_unit( + &dir.path().join("second.db"), SourceUnitMeta::AntigravityCliSqlite, - antigravity_message("cli-session", Some(dedup_key)), + antigravity_message("second-session", Some(dedup_key)), ); let mut cache = message_cache::SourceMessageCache::default(); let mut messages = Vec::new(); ANTIGRAVITY_ADAPTER .fold( - vec![ide, cli], + vec![first, second], &mut FoldContext::new(&mut cache, None), &mut messages, ) .unwrap(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].session_id.as_ref(), "ide-session"); + assert_eq!(messages[0].session_id.as_ref(), "first-session"); } } diff --git a/crates/tokscale-core/src/adapters/cache.rs b/crates/tokscale-core/src/adapters/cache.rs index a35a3bd24..fbb3111ef 100644 --- a/crates/tokscale-core/src/adapters/cache.rs +++ b/crates/tokscale-core/src/adapters/cache.rs @@ -490,8 +490,8 @@ pub(crate) fn resolve_messages( crate::finalize_token_priced_messages(&mut messages, ctx.pricing); Ok(messages) } - UnitMessageSource::CodexFresh { .. } - | UnitMessageSource::CodexCacheHit { .. } + UnitMessageSource::CodexFresh(_) + | UnitMessageSource::CodexCacheHit(_) | UnitMessageSource::CodexAppend(_) => { unreachable!("codex deferred messages must be resolved by CodexAdapter") } diff --git a/crates/tokscale-core/src/adapters/claude.rs b/crates/tokscale-core/src/adapters/claude.rs index 1a9e7df82..dd9c028e3 100644 --- a/crates/tokscale-core/src/adapters/claude.rs +++ b/crates/tokscale-core/src/adapters/claude.rs @@ -15,7 +15,7 @@ use crate::clients::ClientId; use crate::message_cache::{ParserId, ParserVersion}; use crate::{cc_mirror, sessions}; -const CLAUDE_RECORD_HEALTH_REVISION: u32 = MODEL_ID_CANONICALIZATION_REVISION + 4; +const CLAUDE_RECORD_HEALTH_REVISION: u32 = MODEL_ID_CANONICALIZATION_REVISION + 5; pub(crate) struct ClaudeAdapter; diff --git a/crates/tokscale-core/src/adapters/codex.rs b/crates/tokscale-core/src/adapters/codex.rs index 39342b77d..af248096b 100644 --- a/crates/tokscale-core/src/adapters/codex.rs +++ b/crates/tokscale-core/src/adapters/codex.rs @@ -13,7 +13,7 @@ use crate::adapters::{ }; use crate::clients::ClientId; use crate::source_health::SourceStatus; -use crate::{message_cache, pricing, scanner, sessions, UnifiedMessage}; +use crate::{message_cache, pricing, sessions, UnifiedMessage}; pub(crate) struct CodexAdapter; @@ -22,7 +22,6 @@ pub(crate) struct CodexAppendSource { path: PathBuf, read_plan: message_cache::CacheReadPlan, parser_version: message_cache::ParserVersion, - is_headless: bool, tail_messages: Vec, cache_write: Option>, } @@ -40,13 +39,10 @@ impl LocalSourceAdapter for CodexAdapter { .local_def() .expect("Codex adapter must have local scan policy"); let codex_home = codex_home(ctx.home_dir, ctx.use_env_roots); - let headless_roots = - scanner::headless_roots_with_env_strategy(Path::new(ctx.home_dir), ctx.use_env_roots); let mut roots = vec![ def.resolve_path_with_env_strategy(ctx.home_dir, ctx.use_env_roots), codex_home.join("archived_sessions"), ]; - roots.extend(headless_roots.iter().map(|root| root.join("codex"))); roots.extend(adapter_discover::extra_roots_for_client( ClientId::Codex, ctx, @@ -58,10 +54,7 @@ impl LocalSourceAdapter for CodexAdapter { FingerprintPolicy::PlainFile, )? .into_iter() - .map(|unit| { - let is_headless = is_headless_path(&unit.path, &headless_roots); - unit.with_meta(SourceUnitMeta::Codex { is_headless }) - }) + .map(|unit| unit.with_meta(SourceUnitMeta::Codex)) .collect(); Ok(units) } @@ -70,12 +63,8 @@ impl LocalSourceAdapter for CodexAdapter { units .into_par_iter() .map(|unit| { - let is_headless = match unit.meta { - SourceUnitMeta::Codex { is_headless } => is_headless, - _ => unreachable!("unexpected Codex source unit meta"), - }; let unit_identity = unit.clone(); - match load_or_parse_codex_unit(unit, is_headless) { + match load_or_parse_codex_unit(unit) { Ok(parsed) => parsed, Err(source) => ParsedUnit::unavailable( unit_identity, @@ -122,10 +111,6 @@ fn plan_exact_codex_cache_hit( mut unit: SourceUnit, source_cache: &message_cache::SourceMessageCache, ) -> Result { - let is_headless = match unit.meta { - SourceUnitMeta::Codex { is_headless } => is_headless, - _ => unreachable!("unexpected Codex source unit meta"), - }; unit.revalidate_snapshot_for_cache_decision()?; let cached = match source_cache.get_meta(&unit.path, unit.parser_version) { Ok(Some(cached)) => cached, @@ -157,10 +142,7 @@ fn plan_exact_codex_cache_hit( unit.release_prepared_snapshot(); let mut parsed = ParsedUnit::healthy( unit, - UnitMessageSource::CodexCacheHit { - read_plan, - is_headless, - }, + UnitMessageSource::CodexCacheHit(read_plan), None, false, ); @@ -214,8 +196,8 @@ fn fold_codex_units( recovery_requires_removal, ctx, )?; - if let Some(finalization) = finalization { - finalize_codex_messages(&mut messages, ctx.pricing, finalization.is_headless); + if finalization { + finalize_codex_messages(&mut messages, ctx.pricing); } sink.extend_messages( messages @@ -262,14 +244,10 @@ fn write_codex_cache_and_apply_recovery( write_result.map_err(Into::into) } -struct CodexFinalization { - is_headless: bool, -} - struct CodexResolvedMessages { messages: Vec, cache_write: Option>, - finalization: Option, + finalization: bool, recovery_requires_removal: bool, health_override: Option, } @@ -285,19 +263,8 @@ fn codex_home(home_dir: &str, use_env_roots: bool) -> PathBuf { } } -fn is_headless_path(path: &Path, headless_roots: &[PathBuf]) -> bool { - headless_roots.iter().any(|root| path.starts_with(root)) -} - -fn apply_headless_agent(message: &mut UnifiedMessage, is_headless: bool) { - if is_headless && message.agent.is_none() { - message.agent = Some(std::sync::Arc::from("headless")); - } -} - fn parse_full_log_source( unit: SourceUnit, - is_headless: bool, source_snapshot: message_cache::SourceInputSnapshot, ) -> crate::sessions::error::SessionParseResult { let path = unit.path.clone(); @@ -355,10 +322,7 @@ fn parse_full_log_source( let invalidate_cache = interrupted.is_some(); let mut parsed = ParsedUnit::healthy( unit, - UnitMessageSource::CodexFresh { - messages, - is_headless, - }, + UnitMessageSource::CodexFresh(messages), cache_write, invalidate_cache, ); @@ -372,12 +336,8 @@ fn parse_full_log_source( fn finalize_codex_messages( messages: &mut Vec, pricing: Option<&pricing::PricingService>, - is_headless: bool, ) { crate::finalize_token_priced_messages(messages, pricing); - for message in messages { - apply_headless_agent(message, is_headless); - } } struct CodexCacheMaterial { @@ -441,7 +401,6 @@ fn build_codex_cache_metadata( fn load_or_parse_codex_unit( mut unit: SourceUnit, - is_headless: bool, ) -> crate::sessions::error::SessionParseResult { let path = unit.path.clone(); let cache_lookup_completed_no_hit = unit.take_cache_lookup_completed_no_hit(); @@ -463,8 +422,7 @@ fn load_or_parse_codex_unit( if let Some(cached) = cached { let reparse_snapshot = source_snapshot.clone(); let reparse_from_start = |invalidate_cache: bool| { - let mut parsed = - parse_full_log_source(unit.clone(), is_headless, reparse_snapshot.clone())?; + let mut parsed = parse_full_log_source(unit.clone(), reparse_snapshot.clone())?; parsed.invalidate_cache = invalidate_cache; Ok(parsed) }; @@ -488,10 +446,7 @@ fn load_or_parse_codex_unit( ); let mut parsed = ParsedUnit::healthy( unit, - UnitMessageSource::CodexCacheHit { - read_plan, - is_headless, - }, + UnitMessageSource::CodexCacheHit(read_plan), None, false, ); @@ -573,7 +528,6 @@ fn load_or_parse_codex_unit( path, read_plan, parser_version, - is_headless, tail_messages: parsed.messages, cache_write, })), @@ -592,7 +546,7 @@ fn load_or_parse_codex_unit( return reparse_from_start(true); } - parse_full_log_source(unit, is_headless, source_snapshot) + parse_full_log_source(unit, source_snapshot) } fn resolve_codex_messages( @@ -603,57 +557,51 @@ fn resolve_codex_messages( UnitMessageSource::Fresh(messages) => Ok(CodexResolvedMessages { messages, cache_write: None, - finalization: None, + finalization: false, recovery_requires_removal: false, health_override: None, }), - UnitMessageSource::CodexFresh { - messages, - is_headless, - } => Ok(CodexResolvedMessages { + UnitMessageSource::CodexFresh(messages) => Ok(CodexResolvedMessages { messages, cache_write: None, - finalization: Some(CodexFinalization { is_headless }), + finalization: true, recovery_requires_removal: false, health_override: None, }), - UnitMessageSource::CodexCacheHit { - read_plan, - is_headless, - } => match ctx.source_cache.take_messages(&read_plan) { - Ok(messages) => Ok(CodexResolvedMessages { - messages, - cache_write: None, - finalization: Some(CodexFinalization { is_headless }), - recovery_requires_removal: false, - health_override: None, - }), - Err(failure) => { - if !failure.can_reparse_source() { - return Err(failure.into()); - } - let recovery_requires_removal = failure.requires_shard_removal(); - if recovery_requires_removal { - ctx.source_cache - .remove(&read_plan.path(), read_plan.parser_version()); - } else { - ctx.source_cache - .invalidate_read(&read_plan.path(), read_plan.parser_version()); + UnitMessageSource::CodexCacheHit(read_plan) => { + match ctx.source_cache.take_messages(&read_plan) { + Ok(messages) => Ok(CodexResolvedMessages { + messages, + cache_write: None, + finalization: true, + recovery_requires_removal: false, + health_override: None, + }), + Err(failure) => { + if !failure.can_reparse_source() { + return Err(failure.into()); + } + let recovery_requires_removal = failure.requires_shard_removal(); + if recovery_requires_removal { + ctx.source_cache + .remove(&read_plan.path(), read_plan.parser_version()); + } else { + ctx.source_cache + .invalidate_read(&read_plan.path(), read_plan.parser_version()); + } + reparse_full_codex_messages( + &read_plan.path(), + read_plan.parser_version(), + recovery_requires_removal, + ) } - reparse_full_codex_messages( - &read_plan.path(), - read_plan.parser_version(), - is_headless, - recovery_requires_removal, - ) } - }, + } UnitMessageSource::CodexAppend(append) => { let CodexAppendSource { path, read_plan, parser_version, - is_headless, tail_messages, cache_write, } = *append; @@ -672,7 +620,6 @@ fn resolve_codex_messages( return reparse_full_codex_messages( &path, parser_version, - is_headless, recovery_requires_removal, ); } @@ -681,7 +628,7 @@ fn resolve_codex_messages( Ok(CodexResolvedMessages { messages: raw_messages, cache_write, - finalization: Some(CodexFinalization { is_headless }), + finalization: true, recovery_requires_removal: false, health_override: None, }) @@ -693,7 +640,6 @@ fn resolve_codex_messages( fn reparse_full_codex_messages( path: &Path, parser_version: message_cache::ParserVersion, - is_headless: bool, recovery_requires_removal: bool, ) -> Result { let source_snapshot = message_cache::SourceInputPolicy::plain(path) @@ -748,7 +694,7 @@ fn reparse_full_codex_messages( Ok(CodexResolvedMessages { messages, cache_write, - finalization: Some(CodexFinalization { is_headless }), + finalization: true, recovery_requires_removal, health_override: Some(crate::adapters::UnitScanHealth { status, rejections }), }) @@ -844,13 +790,12 @@ mod tests { assert_eq!(codex_home("/unused-home", true), path); } - fn codex_unit(path: &Path, is_headless: bool) -> SourceUnit { - SourceUnit::plain_file(ClientId::Codex, path.to_path_buf()) - .with_meta(SourceUnitMeta::Codex { is_headless }) + fn codex_unit(path: &Path) -> SourceUnit { + SourceUnit::plain_file(ClientId::Codex, path.to_path_buf()).with_meta(SourceUnitMeta::Codex) } - fn prepared_codex_unit(path: &Path, is_headless: bool) -> SourceUnit { - codex_unit(path, is_headless) + fn prepared_codex_unit(path: &Path) -> SourceUnit { + codex_unit(path) .prepare_snapshot() .expect("Codex fixture snapshot must succeed") } @@ -977,7 +922,7 @@ mod tests { .unwrap(); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home); let meta = cache @@ -996,19 +941,16 @@ mod tests { } #[test] - fn codex_adapter_discovers_sessions_archived_headless_and_extra_roots() { + fn codex_adapter_discovers_sessions_archived_and_extra_roots() { let home = tempfile::TempDir::new().unwrap(); let default_path = home.path().join(".codex/sessions/default.jsonl"); let archived_path = home .path() .join(".codex/archived_sessions/old/archived.jsonl"); - let headless_path = home - .path() - .join(".config/tokscale/headless/codex/headless.jsonl"); let extra_root = home.path().join("extra-codex"); let extra_path = extra_root.join("nested/extra.jsonl"); - for path in [&default_path, &archived_path, &headless_path, &extra_path] { + for path in [&default_path, &archived_path, &extra_path] { write_file(path, FIRST_CODEX_ENTRY); } @@ -1026,7 +968,6 @@ mod tests { let expected = vec![ default_path.clone(), archived_path.clone(), - headless_path.clone(), extra_path.clone(), ]; @@ -1034,19 +975,27 @@ mod tests { assert!(units .iter() .all(|unit| unit.fingerprint_policy == FingerprintPolicy::PlainFile)); - assert!(units.iter().any(|unit| { - unit.path == headless_path - && matches!(unit.meta, SourceUnitMeta::Codex { is_headless: true }) - })); - assert!( - units - .iter() - .filter(|unit| { - matches!(unit.meta, SourceUnitMeta::Codex { is_headless: false }) - }) - .count() - == 3 - ); + assert!(units + .iter() + .all(|unit| matches!(unit.meta, SourceUnitMeta::Codex))); + } + + #[test] + fn codex_adapter_ignores_removed_shadow_capture_root() { + let home = tempfile::TempDir::new().unwrap(); + let shadow_path = home + .path() + .join(".config/tokscale/headless/codex/captured.jsonl"); + write_file(&shadow_path, FIRST_CODEX_ENTRY); + + let units = CODEX_ADAPTER + .discover_checked(&scan_context( + home.path(), + &crate::scanner::ScannerSettings::default(), + )) + .expect("removed shadow root must not affect discovery"); + + assert!(units.is_empty()); } #[test] @@ -1098,7 +1047,7 @@ mod tests { let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); message_cache::reset_source_read_stats(&path); - let actual = parse_and_fold(vec![codex_unit(&path, false)], &mut cache); + let actual = parse_and_fold(vec![codex_unit(&path)], &mut cache); assert_eq!( message_cache::get_source_read_stats(&path), message_cache::SourceReadStats { @@ -1115,7 +1064,7 @@ mod tests { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ), ) .unwrap() @@ -1129,10 +1078,8 @@ mod tests { let path = dir.path().join("malformed.jsonl"); write_file(&path, r#"{"type":7,"payload":{}}"#); - let parsed = CODEX_ADAPTER.parse_checked( - vec![codex_unit(&path, false)], - &ParseContext { pricing: None }, - ); + let parsed = + CODEX_ADAPTER.parse_checked(vec![codex_unit(&path)], &ParseContext { pricing: None }); assert_eq!(parsed.len(), 1); let health = parsed[0].source_health(); @@ -1167,13 +1114,13 @@ mod tests { "\n", r#"{"timestamp":"2026-04-27T10:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":15,"cached_input_tokens":3,"output_tokens":5},"last_token_usage":{"input_tokens":5,"cached_input_tokens":1,"output_tokens":2}}}}"#, "\n", - r#"{"model":"gpt-5.5","type":"metadata"}"#, + r#"{"timestamp":"2026-04-27T10:00:03Z","type":"turn_context","payload":{"model":"gpt-5.5"}}"#, "\n", ), ); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &cache, None); let health = parsed[0].source_health(); assert!(matches!( health.status, @@ -1192,7 +1139,7 @@ mod tests { assert_eq!(ctx.health.partial_sources(), 0); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let cached = ctx .source_cache @@ -1216,7 +1163,7 @@ mod tests { ); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &cache, None); let health = parsed[0].source_health(); assert!(matches!( health.status, @@ -1233,7 +1180,7 @@ mod tests { assert_eq!(ctx.health.partial_sources(), 1); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); assert!(ctx .source_cache @@ -1256,7 +1203,7 @@ mod tests { ); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &cache, None); let health = parsed[0].source_health(); assert!(matches!(health.status, SourceStatus::Partial { .. })); assert_eq!(health.rejections.total(), 1); @@ -1277,7 +1224,7 @@ mod tests { assert_eq!(messages[0].model_id.as_ref(), "gpt-5.4"); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); assert!(ctx .source_cache @@ -1294,17 +1241,17 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let fresh = parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + let fresh = parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); assert_cached_raw_messages_match_parser(cache_home.path(), &path); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); message_cache::reset_source_read_stats(&path); let parsed = vec![expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &cache), "exact Codex stamp should plan a cache hit", )]; assert!(matches!( parsed[0].messages, - UnitMessageSource::CodexCacheHit { .. } + UnitMessageSource::CodexCacheHit(_) )); let cached = fold_parsed(parsed, &mut cache); @@ -1324,10 +1271,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let expected = parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + let expected = parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); message_cache::truncate_shard_after_header_for_test( cache_home.path(), &path, @@ -1336,7 +1283,7 @@ mod tests { let mut repair_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let planned = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &repair_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &repair_cache), "valid header must still plan a Codex hit", ); let repaired = fold_parsed(vec![planned], &mut repair_cache); @@ -1345,7 +1292,7 @@ mod tests { let mut warm_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); message_cache::reset_source_read_stats(&path); let warm = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &warm_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &warm_cache), "successful repair must produce a readable warm shard", ); let warm_messages = fold_parsed(vec![warm], &mut warm_cache); @@ -1365,10 +1312,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); let meta = seed_cache.get_meta(&path, parser_version).unwrap().unwrap(); let raw_messages = seed_cache .take_messages(&message_cache::CacheReadPlan::new( @@ -1392,7 +1339,7 @@ mod tests { let mut warm_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let hit = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &warm_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &warm_cache), "unchanged Codex source must plan an exact warm hit", ); let mut sink = Vec::new(); @@ -1412,10 +1359,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); let meta = seed_cache.get_meta(&path, parser_version).unwrap().unwrap(); let raw_messages = seed_cache .take_messages(&message_cache::CacheReadPlan::new( @@ -1439,7 +1386,7 @@ mod tests { append_file(&path, APPENDED_CODEX_ENTRY); let mut append_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &append_cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &append_cache, None); let mut appended_messages = Vec::new(); let mut append_ctx = FoldContext::new(&mut append_cache, None); CODEX_ADAPTER @@ -1450,7 +1397,7 @@ mod tests { let mut warm_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let hit = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &warm_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &warm_cache), "appended Codex source must be rewritten as an exact warm shard", ); let mut warm_messages = Vec::new(); @@ -1471,10 +1418,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); seed_cache.save_if_dirty().unwrap(); append_file( &path, @@ -1482,7 +1429,7 @@ mod tests { ); let mut append_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &append_cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &append_cache, None); let health = parsed[0].source_health(); assert!(matches!(health.status, SourceStatus::Complete)); assert_eq!(health.rejections.total(), 1); @@ -1509,10 +1456,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); seed_cache.save_if_dirty().unwrap(); append_file( &path, @@ -1523,7 +1470,7 @@ mod tests { ); let mut append_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &append_cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &append_cache, None); let health = parsed[0].source_health(); assert!(matches!(health.status, SourceStatus::Partial { .. })); assert_eq!(health.rejections.total(), 1); @@ -1554,10 +1501,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); seed_cache.save_if_dirty().unwrap(); append_file( &path, @@ -1568,7 +1515,7 @@ mod tests { ); let mut append_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &append_cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &append_cache, None); let health = parsed[0].source_health(); assert!(matches!(health.status, SourceStatus::Partial { .. })); assert_eq!(health.rejections.total(), 1); @@ -1598,10 +1545,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let expected = parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + let expected = parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); message_cache::replace_shard_message_count_for_test( cache_home.path(), &path, @@ -1611,7 +1558,7 @@ mod tests { let mut repair_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let planned = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &repair_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &repair_cache), "message-count corruption retains a valid planning header", ); assert_eq!(fold_parsed(vec![planned], &mut repair_cache), expected); @@ -1626,10 +1573,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); let shard_path = message_cache::truncate_shard_after_header_for_test( cache_home.path(), &path, @@ -1638,7 +1585,7 @@ mod tests { let mut repair_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let planned = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &repair_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &repair_cache), "valid header must still plan a Codex hit", ); write_file(&path, MISSING_TIMESTAMP_CODEX_ENTRY); @@ -1672,16 +1619,16 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); let shard_path = message_cache::shard_path_for_test(cache_home.path(), &path, parser_version); let mut repair_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let planned = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &repair_cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &repair_cache), "the original v4 header must plan a Codex hit", ); let unknown = b"unknown!"; @@ -1718,10 +1665,10 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let mut seed_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); let shard_path = message_cache::truncate_shard_after_header_for_test( cache_home.path(), &path, @@ -1729,7 +1676,7 @@ mod tests { ); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let planned = expect_codex_hit( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &cache), "valid header must still plan a Codex hit", ); let resolved = @@ -1770,22 +1717,19 @@ mod tests { let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let miss = expect_codex_miss( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &cache), "empty cache must plan a Codex miss", ); assert!(miss.cache_lookup_completed_no_hit); assert!(miss.prepared_source_input_snapshot().is_some()); - assert_eq!( - parse_and_fold(vec![codex_unit(&path, false)], &mut cache).len(), - 1 - ); + assert_eq!(parse_and_fold(vec![codex_unit(&path)], &mut cache).len(), 1); message_cache::reset_source_read_stats(&path); let parsed = CODEX_ADAPTER.parse_checked(vec![miss], &ParseContext { pricing: None }); assert!(matches!( parsed[0].messages, - UnitMessageSource::CodexFresh { .. } + UnitMessageSource::CodexFresh(_) )); assert!(!parsed[0].unit.cache_lookup_completed_no_hit); assert_eq!( @@ -1805,11 +1749,11 @@ mod tests { write_file(&path, EMPTY_CODEX_ENTRY); let mut cold_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - assert!(parse_and_fold(vec![codex_unit(&path, false)], &mut cold_cache).is_empty()); + assert!(parse_and_fold(vec![codex_unit(&path)], &mut cold_cache).is_empty()); let parser_version = message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ); let meta = cold_cache .get_meta(&path, parser_version) @@ -1819,10 +1763,10 @@ mod tests { let mut warm_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); message_cache::reset_source_read_stats(&path); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &warm_cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &warm_cache, None); assert!(matches!( parsed[0].messages, - UnitMessageSource::CodexCacheHit { .. } + UnitMessageSource::CodexCacheHit(_) )); assert!(fold_parsed(parsed, &mut warm_cache).is_empty()); assert_eq!( @@ -1833,7 +1777,7 @@ mod tests { } #[test] - fn codex_raw_cache_does_not_persist_headless_or_pricing_derivations() { + fn codex_raw_cache_does_not_persist_pricing_derivations() { let cache_home = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("session.jsonl"); @@ -1841,21 +1785,19 @@ mod tests { let mut cold_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let cold = parse_and_fold_with_pricing( - vec![codex_unit(&path, true)], + vec![codex_unit(&path)], &mut cold_cache, &pricing_service(1.0), ); - assert_eq!(cold[0].agent.as_deref(), Some("headless")); assert!(cold[0].cost > 0.0); assert_cached_raw_messages_match_parser(cache_home.path(), &path); let mut warm_cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); let warm = parse_and_fold_with_pricing( - vec![codex_unit(&path, false)], + vec![codex_unit(&path)], &mut warm_cache, &pricing_service(2.0), ); - assert_eq!(warm[0].agent, None); assert!(warm[0].cost > cold[0].cost); } @@ -1867,13 +1809,13 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let initial = parse_and_fold(vec![codex_unit(&path, false)], &mut cache); + let initial = parse_and_fold(vec![codex_unit(&path)], &mut cache); assert_eq!(initial.len(), 1); append_file(&path, APPENDED_CODEX_ENTRY); message_cache::reset_source_read_stats(&path); let miss = expect_codex_miss( - CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path, false), &cache), + CODEX_ADAPTER.plan_cache_hit(prepared_codex_unit(&path), &cache), "an appended Codex source must remain a parse miss", ); assert!(miss.prepared_source_input_snapshot().is_some()); @@ -1956,9 +1898,9 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); let mut cache = message_cache::SourceMessageCache::with_cache_dir(cache_home.path()); - let initial = parse_and_fold(vec![codex_unit(&path, false)], &mut cache); + let initial = parse_and_fold(vec![codex_unit(&path)], &mut cache); assert_eq!(initial[0].tokens.input, 8); - let prepared = prepared_codex_unit(&path, false); + let prepared = prepared_codex_unit(&path); let replacement = dir.path().join("replacement.jsonl"); let replacement_contents = @@ -1990,7 +1932,7 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let mut seed_cache = message_cache::SourceMessageCache::load().unwrap(); - let initial = parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + let initial = parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); assert_eq!(initial.len(), 1); seed_cache.save_if_dirty().unwrap(); @@ -1998,14 +1940,14 @@ mod tests { let expected = parser_messages(&path); let mut cache_a = message_cache::SourceMessageCache::load().unwrap(); - let parsed_a = plan_and_parse(vec![codex_unit(&path, false)], &cache_a, None); + let parsed_a = plan_and_parse(vec![codex_unit(&path)], &cache_a, None); assert!(matches!( parsed_a[0].messages, UnitMessageSource::CodexAppend(_) )); let mut cache_b = message_cache::SourceMessageCache::load().unwrap(); - let parsed_b = plan_and_parse(vec![codex_unit(&path, false)], &cache_b, None); + let parsed_b = plan_and_parse(vec![codex_unit(&path)], &cache_b, None); assert!(matches!( parsed_b[0].messages, UnitMessageSource::CodexAppend(_) @@ -2020,7 +1962,7 @@ mod tests { cache_a.save_if_dirty().unwrap(); let mut warm_cache = message_cache::SourceMessageCache::load().unwrap(); - let warm_messages = parse_and_fold(vec![codex_unit(&path, false)], &mut warm_cache); + let warm_messages = parse_and_fold(vec![codex_unit(&path)], &mut warm_cache); assert_eq!(warm_messages, expected); } @@ -2035,7 +1977,7 @@ mod tests { write_file(&path, FIRST_CODEX_ENTRY); let mut seed_cache = message_cache::SourceMessageCache::load().unwrap(); - let initial = parse_and_fold(vec![codex_unit(&path, false)], &mut seed_cache); + let initial = parse_and_fold(vec![codex_unit(&path)], &mut seed_cache); assert_eq!(initial.len(), 1); seed_cache.save_if_dirty().unwrap(); @@ -2043,7 +1985,7 @@ mod tests { let expected = parser_messages(&path); let mut cache = message_cache::SourceMessageCache::load().unwrap(); - let parsed = plan_and_parse(vec![codex_unit(&path, false)], &cache, None); + let parsed = plan_and_parse(vec![codex_unit(&path)], &cache, None); assert!(matches!( parsed[0].messages, UnitMessageSource::CodexAppend(_) @@ -2054,7 +1996,7 @@ mod tests { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION, + crate::adapters::CODEX_EXEC_IDENTITY_REVISION, ), ); remover.save_if_dirty().unwrap(); @@ -2065,31 +2007,7 @@ mod tests { assert_cached_raw_messages_match_parser(&cache_home.path().join("cache"), &path); let mut warm_cache = message_cache::SourceMessageCache::load().unwrap(); - let warm_messages = parse_and_fold(vec![codex_unit(&path, false)], &mut warm_cache); + let warm_messages = parse_and_fold(vec![codex_unit(&path)], &mut warm_cache); assert_eq!(warm_messages, expected); } - - #[test] - fn codex_adapter_marks_discovered_headless_messages() { - let home = tempfile::TempDir::new().unwrap(); - let path = home - .path() - .join(".config/tokscale/headless/codex/headless.jsonl"); - write_file(&path, FIRST_CODEX_ENTRY); - let settings = crate::scanner::ScannerSettings::default(); - let units = CODEX_ADAPTER - .discover_checked(&scan_context(home.path(), &settings)) - .expect("Codex fixture discovery must succeed"); - - assert_eq!(units.len(), 1); - assert!(matches!( - units[0].meta, - SourceUnitMeta::Codex { is_headless: true } - )); - - let mut cache = message_cache::SourceMessageCache::default(); - let messages = parse_and_fold(units, &mut cache); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].agent.as_deref(), Some("headless")); - } } diff --git a/crates/tokscale-core/src/adapters/kiro.rs b/crates/tokscale-core/src/adapters/kiro.rs index 7dd9ddce9..9dc1ae8ca 100644 --- a/crates/tokscale-core/src/adapters/kiro.rs +++ b/crates/tokscale-core/src/adapters/kiro.rs @@ -96,12 +96,11 @@ impl LocalSourceAdapter for KiroAdapter { sessions::kiro::parse_kiro_file, ), SourceUnitMeta::None - | SourceUnitMeta::AntigravityCacheJsonl | SourceUnitMeta::AntigravityCliSqlite | SourceUnitMeta::OpenCodeSqlite | SourceUnitMeta::CodeBuddyJsonl | SourceUnitMeta::CodeBuddyExtensionLog { .. } - | SourceUnitMeta::Codex { .. } => unreachable!("unexpected Kiro source unit meta"), + | SourceUnitMeta::Codex => unreachable!("unexpected Kiro source unit meta"), }) .collect() } diff --git a/crates/tokscale-core/src/adapters/mod.rs b/crates/tokscale-core/src/adapters/mod.rs index 1f2305ad5..a51407c86 100644 --- a/crates/tokscale-core/src/adapters/mod.rs +++ b/crates/tokscale-core/src/adapters/mod.rs @@ -45,8 +45,8 @@ pub(crate) const OPENCODE_CURRENT_SQLITE_REVISION: ParserRevision = pub(crate) const EXPLICIT_TOKEN_OVERFLOW_REVISION: ParserRevision = MODEL_ID_CANONICALIZATION_REVISION + 1; pub(crate) const ZED_RECORD_FILTER_REVISION: ParserRevision = EXPLICIT_TOKEN_OVERFLOW_REVISION + 1; -pub(crate) const CODEX_OPTIONAL_TOKEN_INFO_REVISION: ParserRevision = - MODEL_ID_CANONICALIZATION_REVISION + 1; +pub(crate) const CODEX_EXEC_IDENTITY_REVISION: ParserRevision = + MODEL_ID_CANONICALIZATION_REVISION + 2; pub(crate) trait LocalSourceAdapter: Sync { fn client(&self) -> ClientId; @@ -342,7 +342,6 @@ impl SourceUnit { let (name, detail) = match self.meta { SourceUnitMeta::None => ("none", None), SourceUnitMeta::OpenCodeSqlite => ("opencode-sqlite", None), - SourceUnitMeta::AntigravityCacheJsonl => ("antigravity-cache-jsonl", None), SourceUnitMeta::AntigravityCliSqlite => ("antigravity-cli-sqlite", None), SourceUnitMeta::KiroFile => ("kiro-file", None), SourceUnitMeta::KiroSqlite => ("kiro-sqlite", None), @@ -355,14 +354,7 @@ impl SourceUnit { CodeBuddyLogSource::Host => "host", }), ), - SourceUnitMeta::Codex { is_headless } => ( - "codex", - Some(if is_headless { - "headless" - } else { - "interactive" - }), - ), + SourceUnitMeta::Codex => ("codex", None), }; message_cache::hash_inventory_bytes(hasher, name.as_bytes()); message_cache::hash_inventory_bytes(hasher, detail.unwrap_or("").as_bytes()); @@ -450,7 +442,6 @@ pub(crate) enum SourceUnitMeta { #[default] None, OpenCodeSqlite, - AntigravityCacheJsonl, AntigravityCliSqlite, KiroFile, KiroSqlite, @@ -459,9 +450,7 @@ pub(crate) enum SourceUnitMeta { CodeBuddyExtensionLog { source: CodeBuddyLogSource, }, - Codex { - is_headless: bool, - }, + Codex, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -480,10 +469,6 @@ impl SourceUnitMeta { Self::OpenCodeSqlite => { ParserVersion::new(ParserId::OpenCodeSqlite, OPENCODE_CURRENT_SQLITE_REVISION) } - Self::AntigravityCacheJsonl => ParserVersion::new( - ParserId::AntigravityCacheJsonl, - MODEL_ID_CANONICALIZATION_REVISION, - ), Self::AntigravityCliSqlite => ParserVersion::new( ParserId::AntigravityCliSqlite, EXPLICIT_TOKEN_OVERFLOW_REVISION, @@ -501,9 +486,7 @@ impl SourceUnitMeta { Self::CodeBuddyJsonl | Self::CodeBuddyExtensionLog { .. } => { ParserVersion::new(ParserId::CodeBuddy, MODEL_ID_CANONICALIZATION_REVISION) } - Self::Codex { .. } => { - ParserVersion::new(ParserId::Codex, CODEX_OPTIONAL_TOKEN_INFO_REVISION) - } + Self::Codex => ParserVersion::new(ParserId::Codex, CODEX_EXEC_IDENTITY_REVISION), } } } @@ -530,7 +513,7 @@ fn default_parser_id(client: ClientId) -> ParserId { ClientId::Goose => ParserId::Goose, ClientId::Codebuff => ParserId::Codebuff, ClientId::CodeBuddy => ParserId::CodeBuddy, - ClientId::Antigravity => ParserId::Antigravity, + ClientId::Antigravity => ParserId::AntigravityCliSqlite, ClientId::Zed => ParserId::Zed, ClientId::Zcode => ParserId::Zcode, ClientId::Kiro => ParserId::Kiro, @@ -563,15 +546,9 @@ pub(crate) enum FingerprintPolicy { #[derive(Debug)] pub(crate) enum UnitMessageSource { Fresh(Vec), - CodexFresh { - messages: Vec, - is_headless: bool, - }, + CodexFresh(Vec), CacheHit(message_cache::CacheReadPlan), - CodexCacheHit { - read_plan: message_cache::CacheReadPlan, - is_headless: bool, - }, + CodexCacheHit(message_cache::CacheReadPlan), CodexAppend(Box), } diff --git a/crates/tokscale-core/src/adapters/opencode.rs b/crates/tokscale-core/src/adapters/opencode.rs index f64c77d93..2c797ec64 100644 --- a/crates/tokscale-core/src/adapters/opencode.rs +++ b/crates/tokscale-core/src/adapters/opencode.rs @@ -62,14 +62,13 @@ impl LocalSourceAdapter for OpenCodeAdapter { }) } SourceUnitMeta::None - | SourceUnitMeta::AntigravityCacheJsonl | SourceUnitMeta::AntigravityCliSqlite | SourceUnitMeta::KiroFile | SourceUnitMeta::KiroSqlite | SourceUnitMeta::KiroGlobalStorage | SourceUnitMeta::CodeBuddyJsonl | SourceUnitMeta::CodeBuddyExtensionLog { .. } - | SourceUnitMeta::Codex { .. } => { + | SourceUnitMeta::Codex => { unreachable!("unexpected OpenCode source unit meta") } }) diff --git a/crates/tokscale-core/src/lib.rs b/crates/tokscale-core/src/lib.rs index d7c826b2b..a03ddf9f1 100644 --- a/crates/tokscale-core/src/lib.rs +++ b/crates/tokscale-core/src/lib.rs @@ -268,7 +268,6 @@ impl ModelPerformance { #[derive(Debug)] pub struct LocalClientMessageCounts { pub counts: ClientCounts, - pub headless_codex_count: i32, pub processing_time_ms: u32, pub health: source_health::HealthReport, } @@ -744,7 +743,6 @@ impl adapters::MessageSink for AggregationSink<'_> { struct ClientCountSink { counts: ClientCounts, - headless_codex_count: i32, date_range: DateRange, } @@ -752,7 +750,6 @@ impl ClientCountSink { fn new(date_range: DateRange) -> Self { Self { counts: ClientCounts::new(), - headless_codex_count: 0, date_range, } } @@ -765,10 +762,6 @@ impl adapters::MessageSink for ClientCountSink { return; } - if message.client.as_ref() == "codex" && message.agent.as_deref() == Some("headless") { - self.headless_codex_count += message.message_count.max(0); - } - if let Some(client) = client_count_bucket(&message.client) { self.counts.add(client, message.message_count.max(0)); } @@ -1502,7 +1495,6 @@ pub fn count_local_client_messages( let (_, health) = fold_prepared_local_sources_with_pricing(prepared, None, &mut sink)?; Ok(LocalClientMessageCounts { counts: sink.counts, - headless_codex_count: sink.headless_codex_count, processing_time_ms: start.elapsed().as_millis() as u32, health: health.to_report(), }) diff --git a/crates/tokscale-core/src/lib_tests.rs b/crates/tokscale-core/src/lib_tests.rs index 5f962545c..1f83a4f2f 100644 --- a/crates/tokscale-core/src/lib_tests.rs +++ b/crates/tokscale-core/src/lib_tests.rs @@ -691,6 +691,69 @@ fn write_single_opencode_sqlite_fixture(home: &Path) { ); } +fn encode_proto_varint(mut value: u64) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + return bytes; + } + } +} + +fn encode_proto_varint_field(field: u64, value: u64) -> Vec { + let mut bytes = encode_proto_varint(field << 3); + bytes.extend(encode_proto_varint(value)); + bytes +} + +fn encode_proto_len_field(field: u64, payload: &[u8]) -> Vec { + let mut bytes = encode_proto_varint((field << 3) | 2); + bytes.extend(encode_proto_varint(payload.len() as u64)); + bytes.extend_from_slice(payload); + bytes +} + +fn write_single_antigravity_cli_fixture(home: &Path) { + let db_path = home.join(".gemini/antigravity-cli/conversations/session.db"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + let conn = rusqlite::Connection::open(db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE gen_metadata (idx integer, data blob, size integer); + CREATE TABLE trajectory_metadata_blob (id text, data blob);", + ) + .unwrap(); + + let mut usage = Vec::new(); + usage.extend(encode_proto_varint_field(1, 12)); + usage.extend(encode_proto_varint_field(5, 2)); + usage.extend(encode_proto_varint_field(9, 4)); + usage.extend(encode_proto_varint_field(10, 1)); + usage.extend(encode_proto_len_field(11, b"response-1")); + let mut chat_model = encode_proto_len_field(4, &usage); + chat_model.extend(encode_proto_len_field(21, b"Gemini 3.5 Flash (Medium)")); + let generation = encode_proto_len_field(1, &chat_model); + + let created_at = encode_proto_varint_field(1, 1_711_200_000); + let trajectory = encode_proto_len_field(2, &created_at); + + conn.execute( + "INSERT INTO gen_metadata (idx, data, size) VALUES (0, ?1, 0)", + rusqlite::params![generation], + ) + .unwrap(); + conn.execute( + "INSERT INTO trajectory_metadata_blob (id, data) VALUES ('main', ?1)", + rusqlite::params![trajectory], + ) + .unwrap(); +} + fn create_hermes_sqlite_db(db_path: &std::path::Path) -> rusqlite::Connection { let conn = rusqlite::Connection::open(db_path).unwrap(); conn.execute_batch( @@ -1864,33 +1927,6 @@ fn test_client_count_sink_attributes_cc_mirror_variants_to_claude() { assert_eq!(sink.counts.get(ClientId::Claude), 3); } -#[test] -fn test_client_count_sink_counts_folded_headless_codex_messages() { - let mut sink = super::ClientCountSink::new(DateRange::none()); - let mut message = UnifiedMessage::new_with_agent( - "codex", - "gpt-5", - "openai", - "headless-session", - 1_717_977_600_000, - TokenBreakdown { - input: 10, - output: 5, - cache_read: 0, - cache_write: 0, - reasoning: 0, - }, - 0.01, - Some("headless".to_string()), - ); - message.message_count = 4; - - super::adapters::MessageSink::push_message(&mut sink, message); - - assert_eq!(sink.counts.get(ClientId::Codex), 4); - assert_eq!(sink.headless_codex_count, 4); -} - #[test] fn test_retain_for_requested_clients_preserves_kilo_split() { let kilocode_only: HashSet<&str> = HashSet::from(["kilocode"]); @@ -3651,7 +3687,7 @@ fn test_codex_cache_reparses_from_zero_when_incremental_prefix_is_stale() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -3754,7 +3790,7 @@ fn test_codex_untimestamped_token_row_is_partial_without_cache_shard() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -3815,14 +3851,14 @@ fn test_codex_malformed_json_suffix_keeps_prefix_without_cache_shard() { "malformed-record" ); let failure = source.status.failure().unwrap(); - assert_eq!(failure.operation, "decode Codex headless line"); + assert_eq!(failure.operation, "decode Codex JSONL entry"); assert!(message_cache::SourceMessageCache::load() .unwrap() .get_meta( &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -3890,7 +3926,7 @@ fn test_codex_invalid_utf8_suffix_keeps_prefix_without_cache_shard() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -3959,7 +3995,7 @@ fn test_codex_unknown_model_prefix_is_partial_then_parses_when_completed() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -4005,7 +4041,7 @@ fn test_codex_unknown_model_prefix_is_partial_then_parses_when_completed() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -4054,7 +4090,7 @@ fn test_codex_cache_skips_non_newline_terminated_resume_prefix() { &path, message_cache::ParserVersion::new( message_cache::ParserId::Codex, - crate::adapters::CODEX_OPTIONAL_TOKEN_INFO_REVISION + crate::adapters::CODEX_EXEC_IDENTITY_REVISION ) ) .unwrap() @@ -5983,18 +6019,9 @@ fn test_local_message_loader_honors_scanner_extra_scan_paths_for_zed_threads_db( #[test] #[serial_test::serial] -fn test_default_graph_includes_antigravity_cache_rows() { +fn test_default_graph_includes_antigravity_cli_database_rows() { let temp_dir = tempfile::TempDir::new().unwrap(); - let sessions_dir = temp_dir - .path() - .join(".config/tokscale/antigravity-cache/sessions"); - std::fs::create_dir_all(&sessions_dir).unwrap(); - std::fs::write( - sessions_dir.join("ag-local.jsonl"), - r#"{"type":"usage","sessionId":"ag-submit","modelId":"model_placeholder_m84","providerId":"antigravity","timestamp":1711200000000,"input":12,"output":4,"cacheRead":2,"cacheWrite":0,"reasoning":1,"responseId":"resp-ag"} -"#, - ) - .unwrap(); + write_single_antigravity_cli_fixture(temp_dir.path()); let rt = tokio::runtime::Runtime::new().unwrap(); let graph = rt @@ -6014,13 +6041,13 @@ fn test_default_graph_includes_antigravity_cache_rows() { .unwrap(); assert_eq!(graph.summary.clients, vec!["antigravity"]); - assert_eq!(graph.summary.models, vec!["model_placeholder_m84"]); + assert_eq!(graph.summary.models, vec!["gemini-3.5-flash"]); assert_eq!(graph.summary.total_tokens, 19); assert_eq!(graph.contributions.len(), 1); assert_eq!(graph.contributions[0].clients[0].client, "antigravity"); assert_eq!( graph.contributions[0].clients[0].model_id, - "model_placeholder_m84" + "gemini-3.5-flash" ); } diff --git a/crates/tokscale-core/src/local_clients.rs b/crates/tokscale-core/src/local_clients.rs index 65ca73584..ded026370 100644 --- a/crates/tokscale-core/src/local_clients.rs +++ b/crates/tokscale-core/src/local_clients.rs @@ -70,7 +70,6 @@ pub struct LocalClientDef { pub root: PathRoot, pub relative_path: &'static str, pub pattern: &'static str, - pub headless: bool, } impl LocalClientDef { @@ -98,7 +97,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::XdgData, relative_path: "opencode", pattern: "*.db", - headless: false, }, }, LocalClientEntry { @@ -107,7 +105,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".claude/projects", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -119,7 +116,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "sessions", pattern: "*.jsonl", - headless: true, }, }, LocalClientEntry { @@ -131,7 +127,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "tmp", pattern: "*.json|*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -140,7 +135,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::XdgData, relative_path: "amp/threads", pattern: "T-*.json", - headless: false, }, }, LocalClientEntry { @@ -149,7 +143,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".factory/sessions", pattern: "*.settings.json", - headless: false, }, }, LocalClientEntry { @@ -158,7 +151,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".openclaw/agents", pattern: "*.jsonl*", - headless: false, }, }, LocalClientEntry { @@ -167,7 +159,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".pi/agent/sessions", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -176,7 +167,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".omp/agent/sessions", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -188,7 +178,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "sessions", pattern: "wire.jsonl", - headless: false, }, }, LocalClientEntry { @@ -197,7 +186,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".qwen/projects", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -206,7 +194,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks", pattern: "ui_messages.json", - headless: false, }, }, LocalClientEntry { @@ -215,7 +202,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".config/Code/User/globalStorage/kilocode.kilo-code/tasks", pattern: "ui_messages.json", - headless: false, }, }, LocalClientEntry { @@ -224,7 +210,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".mux/sessions", pattern: "session-usage.json", - headless: false, }, }, LocalClientEntry { @@ -233,7 +218,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::XdgData, relative_path: "kilo/kilo.db", pattern: "kilo.db", - headless: false, }, }, LocalClientEntry { @@ -245,7 +229,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "state.db", pattern: "state.db", - headless: false, }, }, LocalClientEntry { @@ -254,7 +237,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".copilot/otel", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -263,7 +245,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::XdgData, relative_path: "goose/sessions/sessions.db", pattern: "sessions.db", - headless: false, }, }, LocalClientEntry { @@ -275,7 +256,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "projects", pattern: "chat-messages.json", - headless: false, }, }, LocalClientEntry { @@ -284,16 +264,17 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".codebuddy/projects", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { client: ClientId::Antigravity, def: LocalClientDef { - root: PathRoot::Config, - relative_path: "antigravity-cache/sessions", - pattern: "*.jsonl", - headless: false, + root: PathRoot::EnvVar { + var: "GEMINI_CLI_HOME", + fallback_relative: ".gemini", + }, + relative_path: "antigravity-cli/conversations", + pattern: "*.db", }, }, LocalClientEntry { @@ -302,7 +283,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::XdgData, relative_path: "zed/threads/threads.db", pattern: "threads.db", - headless: false, }, }, LocalClientEntry { @@ -311,7 +291,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".zcode/projects", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -320,7 +299,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".kiro/sessions/cli", pattern: "*.json", - headless: false, }, }, LocalClientEntry { @@ -329,7 +307,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".junie/sessions", pattern: "events.jsonl", - headless: false, }, }, LocalClientEntry { @@ -338,7 +315,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".local/state/warp-terminal", pattern: "warp.sqlite", - headless: false, }, }, LocalClientEntry { @@ -347,7 +323,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks", pattern: "ui_messages.json", - headless: false, }, }, LocalClientEntry { @@ -356,7 +331,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ root: PathRoot::Home, relative_path: ".commandcode/projects", pattern: "*.jsonl", - headless: false, }, }, LocalClientEntry { @@ -368,7 +342,6 @@ pub const LOCAL_CLIENTS: &[LocalClientEntry] = &[ }, relative_path: "sessions", pattern: "updates.jsonl", - headless: false, }, }, ]; @@ -450,10 +423,6 @@ impl ClientId { pub fn file_pattern(self) -> Option<&'static str> { self.local_def().map(|def| def.pattern) } - - pub fn supports_headless(self) -> bool { - self.local_def().is_some_and(|def| def.headless) - } } #[cfg(test)] @@ -644,7 +613,6 @@ mod tests { root: PathRoot::Home, relative_path: ".test/sessions", pattern: "*.jsonl", - headless: false, }; assert_eq!( diff --git a/crates/tokscale-core/src/message_cache.rs b/crates/tokscale-core/src/message_cache.rs index b92579e1c..a4bb8431f 100644 --- a/crates/tokscale-core/src/message_cache.rs +++ b/crates/tokscale-core/src/message_cache.rs @@ -271,8 +271,8 @@ pub(crate) enum ParserId { Copilot, Goose, Codebuff, - Antigravity, - AntigravityCacheJsonl, + RetiredAntigravity, + RetiredAntigravityCacheJsonl, AntigravityCliSqlite, Zed, Kiro, @@ -329,8 +329,8 @@ impl ParserId { Self::Copilot => "copilot", Self::Goose => "goose", Self::Codebuff => "codebuff", - Self::Antigravity => "antigravity", - Self::AntigravityCacheJsonl => "antigravity-cache-jsonl", + Self::RetiredAntigravity => "antigravity", + Self::RetiredAntigravityCacheJsonl => "antigravity-cache-jsonl", Self::AntigravityCliSqlite => "antigravity-cli-sqlite", Self::Zed => "zed", Self::Kiro => "kiro", @@ -1770,7 +1770,7 @@ struct LegacyV1CodexParseState { current_model: Option, current_turn_start_ms: Option, previous_totals: Option, - session_is_headless: bool, + session_is_exec: bool, session_id_from_meta: Option, session_forked_from_id: Option, forked_child_session_id: Option, diff --git a/crates/tokscale-core/src/scanner.rs b/crates/tokscale-core/src/scanner.rs index d7ae11600..6b17363b3 100644 --- a/crates/tokscale-core/src/scanner.rs +++ b/crates/tokscale-core/src/scanner.rs @@ -222,32 +222,6 @@ impl ScanResult { } } -pub fn headless_roots_with_env_strategy(home_dir: &Path, use_env_roots: bool) -> Vec { - if use_env_roots { - if let Some(path) = configured_path_env("TOKSCALE_HEADLESS_DIR") { - return vec![path]; - } - } - - #[cfg(target_os = "macos")] - { - vec![ - home_dir.join(".config/tokscale/headless"), - home_dir.join("Library/Application Support/tokscale/headless"), - ] - } - - #[cfg(not(target_os = "macos"))] - { - vec![home_dir.join(".config/tokscale/headless")] - } -} - -#[cfg(test)] -fn headless_roots(home_dir: &Path) -> Vec { - headless_roots_with_env_strategy(home_dir, true) -} - pub fn copilot_exporter_path_with_env_strategy(use_env_roots: bool) -> Option { if !use_env_roots { return None; @@ -845,8 +819,6 @@ fn scan_all_clients_with_env_strategy_inner( }; let home_path = Path::new(home_dir); - let headless_roots = headless_roots_with_env_strategy(home_path, use_env_roots); - // Define scan tasks let mut tasks: Vec<(ClientId, String, &str)> = Vec::new(); let mut seen_scan_roots: HashSet<(ClientId, PathBuf)> = HashSet::new(); @@ -967,16 +939,6 @@ fn scan_all_clients_with_env_strategy_inner( ClientId::Codex, codex_archived_path, ); - - // Codex headless: /codex/*.jsonl - for root in &headless_roots { - push_unique_scan_task( - &mut tasks, - &mut seen_scan_roots, - ClientId::Codex, - root.join("codex"), - ); - } } if enabled.contains(&ClientId::OpenClaw) { @@ -1801,93 +1763,6 @@ mod tests { File::create(server.join("ui_messages.json")).unwrap(); } - #[test] - #[serial] - fn test_headless_roots_default() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }; - - let home = Path::new("/tmp/tokscale-test-home"); - let roots = headless_roots(home); - let config_root = home.join(".config/tokscale/headless"); - - assert!(roots.contains(&config_root)); - #[cfg(target_os = "macos")] - { - let mac_root = home.join("Library/Application Support/tokscale/headless"); - assert_eq!(roots.len(), 2); - assert!(roots.contains(&mac_root)); - } - #[cfg(not(target_os = "macos"))] - assert_eq!(roots, vec![config_root]); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); - } - - #[test] - #[serial] - fn test_headless_roots_blank_override_falls_back_to_default() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", " ") }; - - let home = Path::new("/tmp/tokscale-test-home"); - let roots = headless_roots(home); - let config_root = home.join(".config/tokscale/headless"); - - assert!(roots.contains(&config_root)); - assert!(!roots.contains(&PathBuf::from(" "))); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); - } - - #[test] - #[serial] - fn test_headless_roots_override() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", "/custom/headless") }; - - let roots = headless_roots(Path::new("/tmp/home")); - assert_eq!(roots, vec![PathBuf::from("/custom/headless")]); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); - } - - #[test] - #[serial] - fn test_headless_roots_trim_override() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", " /custom/headless ") }; - - let roots = headless_roots(Path::new("/tmp/home")); - assert_eq!(roots, vec![PathBuf::from("/custom/headless")]); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); - } - - #[test] - #[serial] - fn test_headless_roots_ignore_env_override_when_disabled() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", "/custom/headless") }; - - let roots = headless_roots_with_env_strategy(Path::new("/tmp/home"), false); - #[cfg(target_os = "macos")] - assert_eq!( - roots, - vec![ - PathBuf::from("/tmp/home/.config/tokscale/headless"), - PathBuf::from("/tmp/home/Library/Application Support/tokscale/headless") - ] - ); - #[cfg(not(target_os = "macos"))] - assert_eq!( - roots, - vec![PathBuf::from("/tmp/home/.config/tokscale/headless")] - ); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); - } - #[test] #[serial] fn test_scan_all_clients_opencode() { @@ -2892,36 +2767,6 @@ mod tests { ); } - #[test] - #[serial] - fn test_scan_all_clients_headless_paths() { - let previous_headless = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }; - - let dir = TempDir::new().unwrap(); - let home = dir.path(); - - let headless_root = home.join(".config").join("tokscale").join("headless"); - - fs::create_dir_all(headless_root.join("codex")).unwrap(); - File::create(headless_root.join("codex").join("codex.jsonl")).unwrap(); - - let result = scan_all_clients( - home.to_str().unwrap(), - &[ - "claude".to_string(), - "codex".to_string(), - "gemini".to_string(), - ], - ); - - assert!(result.get(ClientId::Claude).is_empty()); - assert_eq!(result.get(ClientId::Codex).len(), 1); - assert!(result.get(ClientId::Gemini).is_empty()); - - restore_env("TOKSCALE_HEADLESS_DIR", previous_headless); - } - #[test] #[serial] fn test_scan_all_clients_codex_with_env() { diff --git a/crates/tokscale-core/src/sessions/antigravity.rs b/crates/tokscale-core/src/sessions/antigravity.rs deleted file mode 100644 index c60458707..000000000 --- a/crates/tokscale-core/src/sessions/antigravity.rs +++ /dev/null @@ -1,517 +0,0 @@ -use super::error::{SessionParseError, SessionParseResult}; -use super::UnifiedMessage; -use crate::source_health::{RecordRejectionReason, ScannedSource, SourceFailure}; -use crate::{provider_identity, TokenBreakdown}; -use serde_json::Value; -use std::io::{BufRead, BufReader}; -use std::path::Path; - -pub(crate) fn response_dedup_key(response_id: &str) -> u64 { - crate::sessions::dedup_hash_str(&format!("antigravity:{response_id}")) -} - -pub fn parse_antigravity_file(path: &Path) -> SessionParseResult { - let file = std::fs::File::open(path) - .map_err(|error| SessionParseError::new("read Antigravity JSONL file", error))?; - - Ok(parse_antigravity_reader(BufReader::new(file), path)) -} - -fn parse_antigravity_reader(mut reader: R, path: &Path) -> ScannedSource { - let mut scanned = ScannedSource::default(); - let mut session_model: Option = None; - let mut line = String::with_capacity(4096); - let mut line_number = 0usize; - - loop { - line.clear(); - match reader.read_line(&mut line) { - Ok(0) => break, - Ok(_) => line_number += 1, - Err(error) => { - scanned.interrupted = Some(SourceFailure::new( - "read Antigravity JSONL line", - format!("{} line {}: {error}", path.display(), line_number + 1), - )); - break; - } - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let value = match serde_json::from_str::(trimmed) { - Ok(value) => value, - Err(_error) => { - session_model = None; - scanned - .rejections - .record(RecordRejectionReason::MalformedRecord); - continue; - } - }; - - let row_type = value.get("type").and_then(Value::as_str).unwrap_or(""); - match row_type { - "session_meta" => { - match value - .get("modelId") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - Some(model_id) => session_model = Some(model_id.to_string()), - None => { - session_model = None; - scanned - .rejections - .record(RecordRejectionReason::MalformedRecord); - } - } - } - "usage" => match parse_usage_row(&value, session_model.as_deref()) { - Ok(Some(message)) => scanned.messages.push(message), - Ok(None) => {} - Err(error) => { - let reason = antigravity_rejection_reason(&error); - scanned.rejections.record(reason); - } - }, - _ => {} - } - } - - scanned -} - -fn antigravity_rejection_reason(error: &SessionParseError) -> RecordRejectionReason { - let detail = error.to_string(); - if detail.contains("modelId") { - RecordRejectionReason::MissingModel - } else if detail.contains("providerId") { - RecordRejectionReason::MissingProvider - } else if detail.contains("timestamp") { - RecordRejectionReason::MissingTimestamp - } else { - RecordRejectionReason::MalformedRecord - } -} - -fn parse_usage_row( - value: &Value, - fallback_model: Option<&str>, -) -> SessionParseResult> { - let tokens = TokenBreakdown { - input: parse_nonnegative_i64(value.get("input"), "input")?, - output: parse_nonnegative_i64(value.get("output"), "output")?, - cache_read: parse_nonnegative_i64(value.get("cacheRead"), "cacheRead")?, - cache_write: parse_nonnegative_i64(value.get("cacheWrite"), "cacheWrite")?, - reasoning: parse_nonnegative_i64(value.get("reasoning"), "reasoning")?, - }; - let token_total = tokens.checked_total().ok_or_else(|| { - SessionParseError::invalid( - "validate Antigravity usage row", - "usage token total exceeds i64::MAX", - ) - })?; - if token_total == 0 { - return Ok(None); - } - - let session_id = value - .get("sessionId") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - SessionParseError::invalid( - "validate Antigravity usage row", - "usage row is missing a non-empty sessionId", - ) - })? - .to_string(); - let timestamp = parse_nonnegative_i64(value.get("timestamp"), "timestamp")?; - if timestamp <= 0 { - return Err(SessionParseError::invalid( - "validate Antigravity usage row", - "usage row is missing a positive timestamp", - )); - } - - let model_id = value - .get("modelId") - .and_then(Value::as_str) - .filter(|text| !text.trim().is_empty()) - .map(|text| text.trim().to_string()) - .or_else(|| fallback_model.map(|text| text.trim().to_string())) - .ok_or_else(|| { - SessionParseError::invalid( - "validate Antigravity usage row", - "usage row and session metadata are missing modelId", - ) - })?; - let model_id = if let Some(resolved) = resolve_antigravity_placeholder(&model_id) { - resolved.to_string() - } else { - model_id - }; - - let provider_id = value - .get("providerId") - .and_then(Value::as_str) - .filter(|text| !text.trim().is_empty()) - .map(|text| text.trim().to_string()) - .or_else(|| infer_provider(&model_id).map(str::to_string)) - .ok_or_else(|| { - SessionParseError::invalid( - "validate Antigravity usage row", - format!( - "usage row is missing providerId and model `{model_id}` has no known provider" - ), - ) - })?; - - let dedup_key = value - .get("responseId") - .and_then(Value::as_str) - .filter(|text| !text.trim().is_empty()) - .map(response_dedup_key); - - Ok(Some(UnifiedMessage::new_with_dedup( - "antigravity", - model_id, - provider_id, - session_id, - timestamp, - tokens, - 0.0, - dedup_key, - ))) -} - -fn infer_provider(model: &str) -> Option<&'static str> { - provider_identity::inferred_provider_from_model(model) -} - -fn resolve_antigravity_placeholder(model_id: &str) -> Option<&'static str> { - match model_id.to_lowercase().as_str() { - "model_placeholder_m26" => Some("claude-opus-4.6"), - "model_placeholder_m35" => Some("claude-sonnet-4.6"), - "model_placeholder_m36" | "model_placeholder_m37" => Some("gemini-3.1-pro"), - "model_placeholder_m47" => Some("gemini-3-flash-preview"), - "model_openai_gpt_oss_120b_medium" => Some("gpt-oss-120b-medium"), - _ => None, - } -} - -fn parse_nonnegative_i64(value: Option<&Value>, field: &str) -> SessionParseResult { - let Some(value) = value else { - return Ok(0); - }; - let parsed = if let Some(number) = value.as_i64() { - number - } else if let Some(number) = value.as_u64() { - i64::try_from(number).map_err(|_| { - SessionParseError::invalid( - "validate Antigravity usage row", - format!("{field} exceeds i64::MAX"), - ) - })? - } else if let Some(text) = value.as_str() { - text.parse::().map_err(|error| { - SessionParseError::new("decode Antigravity usage token field", error) - })? - } else { - return Err(SessionParseError::invalid( - "validate Antigravity usage row", - format!("{field} must be an integer or decimal integer string"), - )); - }; - if parsed < 0 { - return Err(SessionParseError::invalid( - "validate Antigravity usage row", - format!("{field} must be non-negative"), - )); - } - Ok(parsed) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{self, BufRead, Read}; - - fn parse_antigravity_file(path: &Path) -> Vec { - super::parse_antigravity_file(path).unwrap().messages - } - - #[test] - fn malformed_jsonl_is_reported() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), "{not-json}\n").unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - assert!(scanned.messages.is_empty()); - assert_eq!(scanned.rejections.total(), 1); - assert!(scanned.interrupted.is_none()); - } - - #[test] - fn directory_source_is_reported_as_interrupted_read() { - let directory = tempfile::TempDir::new().unwrap(); - - let scanned = super::parse_antigravity_file(directory.path()).unwrap(); - assert!(scanned.messages.is_empty()); - assert_eq!( - scanned.interrupted.as_ref().unwrap().operation, - "read Antigravity JSONL line" - ); - } - - #[test] - fn parse_usage_row_with_meta_fallback() { - let input = r#"{"type":"session_meta","sessionId":"abc","modelId":"claude-sonnet-4.6"} -{"type":"usage","sessionId":"abc","timestamp":1711200000000,"input":12,"output":4,"cacheRead":2,"cacheWrite":0,"reasoning":1,"responseId":"resp-1"} -"#; - - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), input).unwrap(); - - let messages = parse_antigravity_file(path.path()); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].client.as_ref(), "antigravity"); - assert_eq!(messages[0].model_id.as_ref(), "claude-sonnet-4.6"); - assert_eq!(messages[0].tokens.input, 12); - assert_eq!(messages[0].tokens.reasoning, 1); - assert_eq!(messages[0].dedup_key, Some(response_dedup_key("resp-1"))); - } - - #[test] - fn bad_usage_row_is_rejected_without_hiding_later_usage() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - path.path(), - concat!( - r#"{"type":"session_meta","modelId":"gemini-3.1-pro"}"#, - "\n", - r#"{"type":"usage","sessionId":"good-1","timestamp":1780000000000,"input":10,"output":2}"#, - "\n", - r#"{"type":"usage","sessionId":"bad","timestamp":1780000001000,"input":"not-a-number"}"#, - "\n", - r#"{"type":"usage","sessionId":"good-2","timestamp":1780000002000,"input":20,"output":3}"#, - ), - ) - .unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert_eq!(scanned.messages.len(), 2); - assert_eq!(scanned.rejections.total(), 1); - assert!(scanned.interrupted.is_none()); - } - - #[test] - fn malformed_state_line_clears_model_and_later_metadata_resyncs() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - path.path(), - concat!( - r#"{"type":"usage","sessionId":"good-1","modelId":"gemini-3.1-pro","timestamp":1780000000000,"input":10}"#, - "\n", - r#"{"type":"session_meta","modelId":"broken"#, - "\n", - r#"{"type":"usage","sessionId":"old-model-must-not-leak","timestamp":1780000001000,"input":20}"#, - "\n", - r#"{"type":"session_meta","modelId":"claude-sonnet-4.6"}"#, - "\n", - r#"{"type":"usage","sessionId":"good-2","timestamp":1780000002000,"input":30}"#, - ), - ) - .unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert_eq!(scanned.messages.len(), 2); - assert_eq!(scanned.messages[1].model_id.as_ref(), "claude-sonnet-4.6"); - assert_eq!(scanned.rejections.total(), 2); - let keys = scanned - .rejections - .entries() - .map(|entry| entry.key) - .collect::>(); - assert_eq!(keys, vec!["malformed-record", "missing-model"]); - assert!(scanned.interrupted.is_none()); - } - - #[test] - fn invalid_session_metadata_clears_old_model_and_records_rejection() { - for invalid_model in ["null", "42", r#"\"\""#] { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - path.path(), - format!( - concat!( - "{{\"type\":\"session_meta\",\"modelId\":\"gemini-3.1-pro\"}}\n", - "{{\"type\":\"session_meta\",\"modelId\":{}}}\n", - "{{\"type\":\"usage\",\"sessionId\":\"must-not-use-old-model\",\"timestamp\":1780000000000,\"input\":10}}\n", - "{{\"type\":\"session_meta\",\"modelId\":\"claude-sonnet-4.6\"}}\n", - "{{\"type\":\"usage\",\"sessionId\":\"recovered\",\"timestamp\":1780000001000,\"input\":20}}\n" - ), - invalid_model - ), - ) - .unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert_eq!(scanned.messages.len(), 1, "modelId={invalid_model}"); - assert_eq!(scanned.messages[0].model_id.as_ref(), "claude-sonnet-4.6"); - assert_eq!(scanned.rejections.total(), 2, "modelId={invalid_model}"); - } - } - - #[test] - fn unknown_model_without_explicit_provider_is_rejected() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - path.path(), - r#"{"type":"usage","sessionId":"unknown","modelId":"model_placeholder_m84","timestamp":1780000000000,"input":10}"#, - ) - .unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - assert_eq!(scanned.rejections.total(), 1); - assert_eq!( - scanned.rejections.entries().next().unwrap().key, - "missing-provider" - ); - } - - #[test] - fn zero_usage_is_ignored_before_identity_validation() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), r#"{"type":"usage","input":0,"output":0}"#).unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - assert_eq!(scanned.rejections.total(), 0); - } - - #[test] - fn overflowing_usage_total_is_rejected_without_panicking() { - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - path.path(), - format!( - concat!( - "{{\"type\":\"usage\",\"sessionId\":\"overflow\",\"modelId\":\"gemini-3.1-pro\",\"timestamp\":1780000000000,\"input\":{},\"output\":1}}\n", - "{{\"type\":\"usage\",\"sessionId\":\"good\",\"modelId\":\"gemini-3.1-pro\",\"timestamp\":1780000001000,\"input\":10}}\n" - ), - i64::MAX - ), - ) - .unwrap(); - - let scanned = super::parse_antigravity_file(path.path()).unwrap(); - - assert_eq!(scanned.messages.len(), 1); - assert_eq!(scanned.messages[0].session_id.as_ref(), "good"); - assert_eq!(scanned.rejections.total(), 1); - } - - struct InterruptAfterFirstLine { - first_line: Option, - } - - impl Read for InterruptAfterFirstLine { - fn read(&mut self, _buf: &mut [u8]) -> io::Result { - unreachable!("parse_antigravity_reader uses BufRead::read_line") - } - } - - impl BufRead for InterruptAfterFirstLine { - fn fill_buf(&mut self) -> io::Result<&[u8]> { - unreachable!("read_line is overridden") - } - - fn consume(&mut self, _amount: usize) { - unreachable!("read_line is overridden") - } - - fn read_line(&mut self, output: &mut String) -> io::Result { - match self.first_line.take() { - Some(line) => { - let len = line.len(); - output.push_str(&line); - Ok(len) - } - None => Err(io::Error::other("injected read interruption")), - } - } - } - - #[test] - fn read_interruption_keeps_confirmed_prefix_and_marks_partial() { - let reader = InterruptAfterFirstLine { - first_line: Some( - concat!( - r#"{"type":"usage","sessionId":"confirmed","modelId":"gemini-3.1-pro","timestamp":1780000000000,"input":10}"#, - "\n" - ) - .to_string(), - ), - }; - - let scanned = parse_antigravity_reader(reader, Path::new("injected.jsonl")); - - assert_eq!(scanned.messages.len(), 1); - assert!(scanned.interrupted.is_some()); - } - - #[test] - fn parse_usage_row_resolves_placeholder_model_alias() { - let input = r#"{"type":"usage","sessionId":"abc","modelId":"MODEL_PLACEHOLDER_M26","timestamp":1711200000000,"input":12,"output":4,"cacheRead":2,"cacheWrite":0,"reasoning":1} -"#; - - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), input).unwrap(); - - let messages = parse_antigravity_file(path.path()); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "claude-opus-4.6"); - assert_eq!(messages[0].provider_id.as_ref(), "anthropic"); - } - - #[test] - fn parse_usage_row_preserves_real_model_alias_candidates() { - let input = r#"{"type":"usage","sessionId":"abc","modelId":"gemini-3-flash-c","timestamp":1711200000000,"input":12,"output":4} -"#; - - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), input).unwrap(); - - let messages = parse_antigravity_file(path.path()); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "gemini-3-flash-c"); - } - - #[test] - fn parse_usage_row_preserves_unmapped_models_with_explicit_provider() { - let input = r#"{"type":"usage","sessionId":"abc","modelId":"model_placeholder_m84","providerId":"antigravity","timestamp":1711200000000,"input":12,"output":4,"cacheRead":2,"cacheWrite":0,"reasoning":1} -{"type":"usage","sessionId":"abc","modelId":"model_placeholder_m16","providerId":"antigravity","timestamp":1711200000001,"input":8,"output":3,"cacheRead":0,"cacheWrite":0,"reasoning":0} -"#; - - let path = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(path.path(), input).unwrap(); - - let messages = parse_antigravity_file(path.path()); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0].model_id.as_ref(), "model_placeholder_m84"); - assert_eq!(messages[0].provider_id.as_ref(), "antigravity"); - assert_eq!(messages[1].model_id.as_ref(), "model_placeholder_m16"); - assert_eq!(messages[1].provider_id.as_ref(), "antigravity"); - } -} diff --git a/crates/tokscale-core/src/sessions/antigravity_cli.rs b/crates/tokscale-core/src/sessions/antigravity_cli.rs index 332c0d9e7..a7c101d7d 100644 --- a/crates/tokscale-core/src/sessions/antigravity_cli.rs +++ b/crates/tokscale-core/src/sessions/antigravity_cli.rs @@ -5,6 +5,11 @@ //! blobs without a checked-in `.proto`; this parser reads only the fields needed //! for token accounting. //! +//! Tokscale intentionally supports this provider-owned database directly and +//! does not bridge Antigravity IDE or Antigravity 2.0 through a running language +//! server, transient CSRF credentials, private RPCs, or a Tokscale-owned shadow +//! cache. See ADR 0025. +//! //! The field numbers below were reverse-engineered upstream from real //! Antigravity CLI conversation databases and ported here as a narrow decoder. //! They were cross-checked against successful sessions where token buckets move @@ -47,6 +52,10 @@ use rusqlite::Connection; use std::collections::HashSet; use std::path::Path; +pub(crate) fn response_dedup_key(response_id: &str) -> u64 { + crate::sessions::dedup_hash_str(&format!("antigravity:{response_id}")) +} + pub fn parse_antigravity_cli_file(path: &Path) -> SessionParseResult { let conn = open_readonly_sqlite(path)?; let session_id = path @@ -213,9 +222,7 @@ fn parse_gen_metadata( seen_response_ids.insert(response_id.clone()); } - let dedup_key = response_id - .as_deref() - .map(super::antigravity::response_dedup_key); + let dedup_key = response_id.as_deref().map(response_dedup_key); Ok(Some(UnifiedMessage::new_with_dedup( "antigravity", @@ -907,12 +914,7 @@ mod tests { .unwrap(); assert_eq!(message.model_id.as_ref(), "gemini-3.1-pro"); - assert_eq!( - message.dedup_key, - Some(crate::sessions::antigravity::response_dedup_key( - "resp-display" - )) - ); + assert_eq!(message.dedup_key, Some(response_dedup_key("resp-display"))); } #[test] diff --git a/crates/tokscale-core/src/sessions/claudecode.rs b/crates/tokscale-core/src/sessions/claudecode.rs index 6f0b08de0..45aaeab9c 100644 --- a/crates/tokscale-core/src/sessions/claudecode.rs +++ b/crates/tokscale-core/src/sessions/claudecode.rs @@ -563,17 +563,6 @@ pub fn parse_claude_file_with_cache_and_home( })? .to_string(); - if path.extension().and_then(|s| s.to_str()) == Some("json") { - return parse_claude_headless_json( - path, - &session_id, - workspace_key.clone(), - workspace_label.clone(), - &client_id, - metadata_provider_hint, - ); - } - let file = std::fs::File::open(path) .map_err(|source| SessionParseError::at_path(path, "open Claude session", source))?; @@ -588,7 +577,6 @@ pub fn parse_claude_file_with_cache_and_home( // We merge duplicates using per-field max to always keep the highest value seen // for each token type, ensuring we capture the most complete record. let mut processed_hashes: HashMap = HashMap::new(); - let mut headless_state = ClaudeHeadlessState::default(); let mut buffer = Vec::with_capacity(4096); // Tracks whether the previous entry was a user message, // so the next assistant message can be marked as a turn start. @@ -624,7 +612,6 @@ pub fn parse_claude_file_with_cache_and_home( continue; } - let mut handled = false; buffer.clear(); buffer.extend_from_slice(trimmed.as_bytes()); let entry = match simd_json::from_slice::(&mut buffer) { @@ -1073,63 +1060,7 @@ pub fn parse_claude_file_with_cache_and_home( // above, so they merge via merge_claude_duplicate without needing // the global pending value again. pending_request_start_timestamp_ms = None; - handled = true; - } - } - - if handled { - continue; - } - - let headless_message = match process_claude_headless_line( - trimmed, - ClaudeHeadlessContext { - path, - line_number: Some(line_index + 1), - session_id: &session_id, - client_id: &client_id, - default_provider_hint: metadata_provider_hint, - }, - &mut headless_state, - ) { - Ok(message) => message, - Err(error) => { - record_claude_error_rejection(&mut rejections, &error); - if matches!(entry.entry_type.as_str(), "message_start" | "message_stop") { - interrupted = Some(SourceFailure::from(&error)); - break; - } - continue; - } - }; - if let Some(message) = headless_message { - let mut message = message; - message.set_workspace(workspace_key.clone(), workspace_label.clone()); - let provider_confidence = stored_claude_provider_confidence(&message.provider_id); - messages.push(message); - provider_confidences.push(provider_confidence); - } - } - - if interrupted.is_none() { - match finalize_headless_state( - &mut headless_state, - ClaudeHeadlessContext { - path, - line_number: None, - session_id: &session_id, - client_id: &client_id, - default_provider_hint: metadata_provider_hint, - }, - ) { - Ok(Some(mut message)) => { - message.set_workspace(workspace_key, workspace_label); - let provider_confidence = stored_claude_provider_confidence(&message.provider_id); - messages.push(message); - provider_confidences.push(provider_confidence); } - Ok(None) => {} - Err(error) => record_claude_error_rejection(&mut rejections, &error), } } @@ -1731,217 +1662,6 @@ fn canonicalize_claude_model(model: &str) -> String { model_aliases::canonicalize_source_model_id(model).unwrap_or_else(|| model.trim().to_string()) } -#[derive(Default)] -struct ClaudeHeadlessState { - model: Option, - provider_id: Option, - input: i64, - output: i64, - cache_read: i64, - cache_write: i64, - timestamp_ms: Option, - timestamp_error: Option, - source_line_number: Option, -} - -#[derive(Clone, Copy)] -struct ClaudeHeadlessContext<'a> { - path: &'a Path, - line_number: Option, - session_id: &'a str, - client_id: &'a str, - default_provider_hint: Option<&'a str>, -} - -fn parse_claude_headless_json( - path: &Path, - session_id: &str, - workspace_key: Option, - workspace_label: Option, - client_id: &str, - default_provider_hint: Option<&str>, -) -> SessionParseResult { - let mut bytes = std::fs::read(path) - .map_err(|source| SessionParseError::at_path(path, "read Claude JSON source", source))?; - let value: Value = simd_json::from_slice(&mut bytes) - .map_err(|source| SessionParseError::at_path(path, "decode Claude JSON source", source))?; - - let mut scanned = ScannedSource::complete(Vec::with_capacity(1)); - match extract_claude_headless_message( - &value, - ClaudeHeadlessContext { - path, - line_number: None, - session_id, - client_id, - default_provider_hint, - }, - ) { - Ok(Some(mut message)) => { - message.set_workspace(workspace_key, workspace_label); - scanned.messages.push(message); - } - Ok(None) => {} - Err(error) => record_claude_error_rejection(&mut scanned.rejections, &error), - } - - Ok(scanned) -} - -fn process_claude_headless_line( - line: &str, - context: ClaudeHeadlessContext<'_>, - state: &mut ClaudeHeadlessState, -) -> SessionParseResult> { - let mut bytes = line.as_bytes().to_vec(); - let value: Value = simd_json::from_slice(&mut bytes).map_err(|source| { - SessionParseError::at_path( - context.path, - "decode Claude headless event", - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "{}: {source}", - claude_headless_location(context.line_number) - ), - ), - ) - })?; - - let event_type = value.get("type").and_then(|val| val.as_str()); - let mut completed_message: Option = None; - - match event_type { - Some("message_start") => { - completed_message = finalize_headless_state(state, context)?; - - let model = extract_claude_model(&value); - if model - .as_deref() - .is_some_and(is_claude_synthetic_placeholder_model) - { - *state = ClaudeHeadlessState::default(); - return Ok(completed_message); - } - state.model = model; - state.provider_id = extract_claude_provider(&value); - match extract_claude_timestamp_checked( - &value, - context.path, - context.line_number, - "validate Claude headless timestamp", - ) { - Ok(timestamp) => state.timestamp_ms = timestamp, - Err(error) => state.timestamp_error = Some(error), - } - state.source_line_number = context.line_number; - if let Some(usage) = value - .get("message") - .and_then(|msg| msg.get("usage")) - .or_else(|| value.get("usage")) - { - update_claude_usage(state, usage); - } - } - Some("message_delta") => { - if let Some(usage) = value - .get("usage") - .or_else(|| value.get("delta").and_then(|delta| delta.get("usage"))) - { - update_claude_usage(state, usage); - } - } - Some("message_stop") => { - completed_message = finalize_headless_state(state, context)?; - } - _ => { - if let Some(message) = extract_claude_headless_message(&value, context)? { - completed_message = Some(message); - } - } - } - - Ok(completed_message) -} - -fn extract_claude_headless_message( - value: &Value, - context: ClaudeHeadlessContext<'_>, -) -> SessionParseResult> { - let Some(usage) = value - .get("usage") - .or_else(|| value.get("message").and_then(|msg| msg.get("usage"))) - else { - return Ok(None); - }; - let token_breakdown = TokenBreakdown { - input: extract_i64(usage.get("input_tokens")).unwrap_or(0).max(0), - output: extract_i64(usage.get("output_tokens")).unwrap_or(0).max(0), - cache_read: extract_i64(usage.get("cache_read_input_tokens")) - .unwrap_or(0) - .max(0), - cache_write: extract_i64(usage.get("cache_creation_input_tokens")) - .unwrap_or(0) - .max(0), - reasoning: 0, - }; - if !crate::has_positive_tokens(&token_breakdown) { - return Ok(None); - } - let raw_model = extract_claude_model(value).ok_or_else(|| { - SessionParseError::at_path( - context.path, - "validate Claude headless message", - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "{}: token-bearing headless message is missing model", - claude_headless_location(context.line_number) - ), - ), - ) - })?; - if is_claude_synthetic_placeholder_model(&raw_model) { - return Ok(None); - } - let provider_hint = extract_claude_provider(value); - let model = canonicalize_claude_model(&raw_model); - let provider_id = claude_provider_id_for_models( - &raw_model, - &model, - provider_hint.as_deref().or(context.default_provider_hint), - ); - let timestamp = extract_claude_timestamp_checked( - value, - context.path, - context.line_number, - "validate Claude headless timestamp", - )? - .ok_or_else(|| { - SessionParseError::at_path( - context.path, - "validate Claude headless timestamp", - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "{}: token-bearing headless message is missing timestamp", - claude_headless_location(context.line_number) - ), - ), - ) - })?; - - Ok(Some(UnifiedMessage::new( - context.client_id, - model, - provider_id, - context.session_id, - timestamp, - token_breakdown, - 0.0, - ))) -} - /// Internal Claude Code system/tool tags that should NOT be counted as human turns. /// User prompts containing arbitrary HTML/XML (e.g. `
hello
`) are still /// counted, only this narrow allowlist is excluded. @@ -2020,14 +1740,6 @@ const CLAUDE_PROVIDER_INFERRED_CONFIDENCE: u8 = 2; const CLAUDE_PROVIDER_EXPLICIT_CONFIDENCE: u8 = 3; const CLAUDE_PROVIDER_MODEL_OVERRIDE_CONFIDENCE: u8 = 4; -fn claude_provider_id_for_models( - raw_model: &str, - canonical_model: &str, - provider_hint: Option<&str>, -) -> String { - claude_provider_choice_for_models(raw_model, canonical_model, provider_hint).id -} - fn claude_provider_choice_for_models( raw_model: &str, canonical_model: &str, @@ -2169,109 +1881,19 @@ fn extract_claude_timestamp_checked( ErrorKind::InvalidData, format!( "{}: invalid timestamp value {raw_timestamp}", - claude_headless_location(line_number) + claude_source_location(line_number) ), ), ) }) } -fn claude_headless_location(line_number: Option) -> String { +fn claude_source_location(line_number: Option) -> String { line_number .map(|line_number| format!("line {line_number}")) .unwrap_or_else(|| "JSON source".to_string()) } -fn update_claude_usage(state: &mut ClaudeHeadlessState, usage: &Value) { - if let Some(input) = extract_i64(usage.get("input_tokens")) { - state.input = state.input.max(input); - } - if let Some(output) = extract_i64(usage.get("output_tokens")) { - state.output = state.output.max(output); - } - if let Some(cache_read) = extract_i64(usage.get("cache_read_input_tokens")) { - state.cache_read = state.cache_read.max(cache_read); - } - if let Some(cache_write) = extract_i64(usage.get("cache_creation_input_tokens")) { - state.cache_write = state.cache_write.max(cache_write); - } -} - -fn finalize_headless_state( - state: &mut ClaudeHeadlessState, - context: ClaudeHeadlessContext<'_>, -) -> SessionParseResult> { - if state.input == 0 && state.output == 0 && state.cache_read == 0 && state.cache_write == 0 { - *state = ClaudeHeadlessState::default(); - return Ok(None); - } - - if let Some(error) = state.timestamp_error.take() { - *state = ClaudeHeadlessState::default(); - return Err(error); - } - - let source_line_number = state.source_line_number.or(context.line_number); - let raw_model = state.model.clone().ok_or_else(|| { - SessionParseError::at_path( - context.path, - "validate Claude headless message", - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "{}: token-bearing headless message is missing model", - claude_headless_location(source_line_number) - ), - ), - ) - })?; - if is_claude_synthetic_placeholder_model(&raw_model) { - *state = ClaudeHeadlessState::default(); - return Ok(None); - } - let model = canonicalize_claude_model(&raw_model); - let provider_id = claude_provider_id_for_models( - &raw_model, - &model, - state - .provider_id - .as_deref() - .or(context.default_provider_hint), - ); - let timestamp = state.timestamp_ms.ok_or_else(|| { - SessionParseError::at_path( - context.path, - "validate Claude headless timestamp", - std::io::Error::new( - ErrorKind::InvalidData, - format!( - "{}: token-bearing headless message is missing timestamp", - claude_headless_location(source_line_number) - ), - ), - ) - })?; - - let message = UnifiedMessage::new( - context.client_id, - model, - provider_id, - context.session_id, - timestamp, - TokenBreakdown { - input: state.input.max(0), - output: state.output.max(0), - cache_read: state.cache_read.max(0), - cache_write: state.cache_write.max(0), - reasoning: 0, - }, - 0.0, - ); - - *state = ClaudeHeadlessState::default(); - Ok(Some(message)) -} - #[cfg(test)] mod tests { use super::*; @@ -2526,40 +2148,6 @@ mod tests { assert!(failure.message.contains("line 2")); } - #[test] - fn token_bearing_headless_json_without_model_is_rejected() { - let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - writeln!( - file, - r#"{{"usage":{{"input_tokens":1,"output_tokens":1}}}}"# - ) - .unwrap(); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - assert_eq!(scanned.rejections.total(), 1); - assert_eq!( - scanned.rejections.entries().next().unwrap().key, - "missing-model" - ); - assert!(scanned.interrupted.is_none()); - } - - #[test] - fn zero_usage_headless_json_without_metadata_is_an_intentional_filter() { - let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - writeln!( - file, - r#"{{"timestamp":"not-a-timestamp","usage":{{"input_tokens":0,"output_tokens":0}}}}"# - ) - .unwrap(); - - let messages = parse_claude_file(file.path()).unwrap(); - - assert!(messages.is_empty()); - } - #[test] fn token_bearing_assistant_without_timestamp_is_rejected() { let file = create_test_file( @@ -2574,26 +2162,6 @@ mod tests { assert!(scanned.interrupted.is_none()); } - #[test] - fn token_bearing_headless_json_without_timestamp_is_rejected() { - let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - writeln!( - file, - r#"{{"type":"message","message":{{"model":"claude-sonnet-4.6","usage":{{"input_tokens":1,"output_tokens":1}}}}}}"# - ) - .unwrap(); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - assert_eq!(scanned.rejections.total(), 1); - assert_eq!( - scanned.rejections.entries().next().unwrap().key, - "missing-timestamp" - ); - assert!(scanned.interrupted.is_none()); - } - #[test] fn token_bearing_tool_result_without_timestamp_is_rejected() { let file = create_test_file( @@ -2639,67 +2207,6 @@ mod tests { assert!(scanned.interrupted.is_none()); } - #[test] - fn token_bearing_headless_stream_without_timestamp_is_partial() { - let file = create_test_file( - r#"{"type":"message_start","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":1}}} -{"type":"message_stop"}"#, - ); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - let rejection = scanned.rejections.entries().next().unwrap(); - assert_eq!(rejection.key, "missing-timestamp"); - let failure = scanned.interrupted.unwrap(); - assert_eq!(failure.operation, "validate Claude headless timestamp"); - } - - #[test] - fn zero_usage_headless_stream_with_invalid_timestamp_is_an_intentional_filter() { - let file = create_test_file( - r#"{"type":"message_start","timestamp":"not-a-timestamp","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":0,"output_tokens":0}}} -{"type":"message_stop"}"#, - ); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - assert!(scanned.rejections.is_empty()); - assert!(scanned.interrupted.is_none()); - } - - #[test] - fn positive_headless_delta_surfaces_deferred_start_timestamp_error() { - let file = create_test_file( - r#"{"type":"message_start","timestamp":"not-a-timestamp","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":0,"output_tokens":0}}} -{"type":"message_delta","usage":{"output_tokens":1}} -{"type":"message_stop"}"#, - ); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - let rejection = scanned.rejections.entries().next().unwrap(); - assert_eq!(rejection.key, "missing-timestamp"); - let failure = scanned.interrupted.unwrap(); - assert_eq!(failure.operation, "validate Claude headless timestamp"); - } - - #[test] - fn token_bearing_headless_stream_without_model_is_rejected_at_eof() { - let file = create_test_file( - r#"{"type":"message_start","timestamp":"2026-07-14T00:00:00Z","message":{"usage":{"input_tokens":1}}}"#, - ); - - let scanned = super::parse_claude_file(file.path()).unwrap(); - - assert!(scanned.messages.is_empty()); - let rejection = scanned.rejections.entries().next().unwrap(); - assert_eq!(rejection.key, "missing-model"); - assert!(scanned.interrupted.is_none()); - } - #[test] fn malformed_sidechain_meta_keeps_usage_and_reports_rejection() { let temp_dir = tempfile::tempdir().unwrap(); @@ -3125,7 +2632,7 @@ mod tests { #[test] fn test_turn_start_without_user_message() { - // No user message → no turn starts (e.g. headless or partial log) + // No user message → no turn starts (e.g. a partial log) let content = r#"{"type":"assistant","timestamp":"2024-12-01T10:00:00.000Z","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":100,"output_tokens":50}}} {"type":"assistant","timestamp":"2024-12-01T10:00:01.000Z","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":200,"output_tokens":100}}}"#; @@ -3396,77 +2903,6 @@ mod tests { assert_eq!(messages[0].provider_id.as_ref(), "openrouter"); } - #[test] - fn test_headless_json_output() { - let content = r#"{"type":"message","timestamp":"2025-01-01T00:00:00Z","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":120,"output_tokens":60,"cache_read_input_tokens":10}}}"#; - let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - std::fs::write(file.path(), content).unwrap(); - - let messages = parse_claude_file(file.path()).unwrap(); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "claude-sonnet-4.6"); - assert_eq!(messages[0].tokens.input, 120); - assert_eq!(messages[0].tokens.output, 60); - assert_eq!(messages[0].tokens.cache_read, 10); - } - - #[test] - fn test_headless_json_output_infers_subprovider() { - let content = r#"{"type":"message","timestamp":"2025-01-01T00:00:00Z","message":{"model":"gpt-5.3-codex","usage":{"input_tokens":120,"output_tokens":60,"cache_read_input_tokens":10}}}"#; - let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); - std::fs::write(file.path(), content).unwrap(); - - let messages = parse_claude_file(file.path()).unwrap(); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "gpt-5.3-codex"); - assert_eq!(messages[0].provider_id.as_ref(), "openai"); - } - - #[test] - fn test_headless_json_output_keeps_workspace_metadata() { - let content = r#"{"type":"message","timestamp":"2025-01-01T00:00:00Z","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":120,"output_tokens":60,"cache_read_input_tokens":10}}}"#; - let (_dir, path) = create_project_file(content, "myproject", "session.json"); - - let messages = parse_claude_file(&path).unwrap(); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].workspace_key.as_deref(), Some("myproject")); - assert_eq!(messages[0].workspace_label.as_deref(), Some("myproject")); - } - - #[test] - fn test_headless_stream_output() { - let content = r#"{"type":"message_start","timestamp":"2025-01-01T00:00:00Z","message":{"id":"msg_1","model":"claude-sonnet-4.6","usage":{"input_tokens":200,"cache_read_input_tokens":20,"cache_creation_input_tokens":5}}} -{"type":"message_delta","usage":{"output_tokens":80}} -{"type":"message_stop"}"#; - let file = create_test_file(content); - let messages = parse_claude_file(file.path()).unwrap(); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "claude-sonnet-4.6"); - assert_eq!(messages[0].tokens.input, 200); - assert_eq!(messages[0].tokens.output, 80); - assert_eq!(messages[0].tokens.cache_read, 20); - assert_eq!(messages[0].tokens.cache_write, 5); - } - - #[test] - fn test_headless_stream_output_infers_subprovider() { - let content = r#"{"type":"message_start","timestamp":"2026-02-18T10:00:00Z","message":{"id":"msg_1","model":"gemini-3-pro-preview","usage":{"input_tokens":200,"cache_read_input_tokens":20}}} -{"type":"message_delta","usage":{"output_tokens":80}} -{"type":"message_stop"}"#; - let file = create_test_file(content); - let messages = parse_claude_file(file.path()).unwrap(); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "gemini-3-pro-preview"); - assert_eq!(messages[0].provider_id.as_ref(), "google"); - assert_eq!(messages[0].tokens.input, 200); - assert_eq!(messages[0].tokens.output, 80); - } - #[test] fn test_workspace_metadata_from_claude_project_path() { let content = r#"{"type":"assistant","timestamp":"2024-12-01T10:00:00.000Z","message":{"model":"claude-sonnet-4.6","usage":{"input_tokens":100,"output_tokens":50}}}"#; diff --git a/crates/tokscale-core/src/sessions/codex.rs b/crates/tokscale-core/src/sessions/codex.rs index 8bfc4c549..b7e57bfe1 100644 --- a/crates/tokscale-core/src/sessions/codex.rs +++ b/crates/tokscale-core/src/sessions/codex.rs @@ -7,7 +7,6 @@ //! Note: This parser has stateful logic to track model and delta calculations. use super::error::{SessionParseError, SessionParseResult}; -use super::utils::{extract_i64, extract_string, parse_timestamp_value}; use super::{normalize_workspace_key, workspace_label_from_key, UnifiedMessage}; use crate::source_health::{RecordRejectionReason, RejectionSummary, SourceFailure}; use crate::{checked_token_sum, TokenBreakdown}; @@ -165,7 +164,7 @@ pub(crate) struct CodexParseState { #[serde(default)] pub current_turn_start_ms: Option, pub previous_totals: Option, - pub session_is_headless: bool, + pub session_is_exec: bool, pub session_id_from_meta: Option, pub session_forked_from_id: Option, pub forked_child_session_id: Option, @@ -475,7 +474,7 @@ fn parse_codex_reader( if entry.entry_type == "session_meta" { if codex_source_is_exec(payload.source.as_ref()) { - state.session_is_headless = true; + state.session_is_exec = true; } if let Some(ref id) = payload.id { state.session_id_from_meta = Some(id.clone()); @@ -504,7 +503,7 @@ fn parse_codex_reader( payload.source.as_ref(), agent_role, payload.agent_nickname.as_deref(), - state.session_is_headless, + state.session_is_exec, ); state.session_agent_instance = payload .agent_nickname @@ -540,7 +539,7 @@ fn parse_codex_reader( // A human `user_message` event starts a new turn. The event // itself carries no tokens, so we defer the flag to the next // token_count-derived message (the assistant's reply). This - // counts `codex exec` one-shots too: they are headless but still + // counts `codex exec` one-shots too: they are non-interactive but still // carry a real human prompt, so each is one turn. Only // system-injected messages (leading `<`, e.g. // , ) are excluded as @@ -697,6 +696,16 @@ fn parse_codex_reader( continue; } + if !pending_model_messages.is_empty() { + interrupt_on_record!( + RecordRejectionReason::MissingModel, + SessionParseError::invalid( + "resolve Codex token-count model", + "token-count rows were not followed by a model-bearing event", + ) + ); + } + if state.forked_child_waiting_for_turn_context { let mut json_probe = trimmed.as_bytes().to_vec(); if simd_json::from_slice::(&mut json_probe).is_ok() { @@ -704,43 +713,6 @@ fn parse_codex_reader( } } - let headless_message = match parse_codex_headless_line( - trimmed, - CodexHeadlessContext { - session_id, - session_provider: state.session_provider.as_deref(), - session_agent: &state.session_agent, - session_agent_instance: &state.session_agent_instance, - session_is_headless: state.session_is_headless, - }, - &mut state.current_model, - ) { - Ok(message) => message, - Err(error) => interrupt_on_record!(error.reason, error.source), - }; - if !pending_model_messages.is_empty() { - if let Some(model) = state.current_model.clone() { - flush_pending_model_messages(&mut pending_model_messages, &mut messages, &model); - } else { - interrupt_on_record!( - RecordRejectionReason::MissingModel, - SessionParseError::invalid( - "resolve Codex token-count model", - "headless usage followed token-count rows without a model", - ) - ); - } - } - - if let Some(mut msg) = headless_message { - msg.set_workspace( - state.session_workspace_key.clone(), - state.session_workspace_label.clone(), - ); - messages.push(msg); - continue; - } - if let Some(source) = entry_decode_error { let error = SessionParseError::new("decode Codex JSONL entry", source); let mut json_probe = trimmed.as_bytes().to_vec(); @@ -806,10 +778,10 @@ fn codex_agent_label( source: Option<&Value>, agent_role: Option<&str>, agent_nickname: Option<&str>, - is_headless: bool, + is_exec: bool, ) -> Option { - if is_headless { - return Some("Codex Headless".to_string()); + if is_exec { + return Some("Codex Exec".to_string()); } if codex_source_is_subagent(source) { @@ -1118,99 +1090,6 @@ fn extract_model_from_info(info: &CodexInfo) -> Option { .or(info.model_name.clone().filter(|s| !s.is_empty())) } -struct CodexHeadlessUsage { - input: i64, - output: i64, - cached: i64, - model: Option, - timestamp_ms: Option, -} - -struct CodexRecordError { - reason: RecordRejectionReason, - source: SessionParseError, -} - -impl CodexRecordError { - fn new(reason: RecordRejectionReason, source: SessionParseError) -> Self { - Self { reason, source } - } -} - -struct CodexHeadlessContext<'a> { - session_id: &'a str, - session_provider: Option<&'a str>, - session_agent: &'a Option, - session_agent_instance: &'a Option, - session_is_headless: bool, -} - -fn parse_codex_headless_line( - line: &str, - context: CodexHeadlessContext<'_>, - current_model: &mut Option, -) -> Result, CodexRecordError> { - let mut bytes = line.as_bytes().to_vec(); - let value: Value = simd_json::from_slice(&mut bytes).map_err(|source| { - CodexRecordError::new( - RecordRejectionReason::MalformedRecord, - SessionParseError::new("decode Codex headless line", source), - ) - })?; - - if let Some(model) = extract_model_from_value(&value) { - *current_model = Some(model); - } - - let Some(usage) = extract_headless_usage(&value) else { - return Ok(None); - }; - let model = usage - .model - .or_else(|| current_model.clone()) - .ok_or_else(|| { - CodexRecordError::new( - RecordRejectionReason::MissingModel, - SessionParseError::invalid("validate Codex headless usage", "model is missing"), - ) - })?; - let timestamp = usage.timestamp_ms.ok_or_else(|| { - CodexRecordError::new( - RecordRejectionReason::MissingTimestamp, - SessionParseError::invalid("validate Codex headless usage", "timestamp is missing"), - ) - })?; - - if usage.input == 0 && usage.output == 0 && usage.cached == 0 { - return Ok(None); - } - - let provider = context.session_provider.unwrap_or("openai"); - let mut message = UnifiedMessage::new_with_agent( - "codex", - model, - provider, - context.session_id, - timestamp, - TokenBreakdown { - input: usage.input.max(0), - output: usage.output.max(0), - cache_read: usage.cached.max(0), - cache_write: 0, - reasoning: 0, - }, - 0.0, - if context.session_is_headless { - Some("Codex Headless".to_string()) - } else { - context.session_agent.clone() - }, - ); - message.set_agent_instance(context.session_agent_instance.clone()); - - Ok(Some(message)) -} - fn codex_schema_invalid_value_may_affect_state(value: &Value) -> bool { value .get("type") @@ -1218,70 +1097,6 @@ fn codex_schema_invalid_value_may_affect_state(value: &Value) -> bool { .is_some_and(|entry_type| { matches!(entry_type, "session_meta" | "turn_context" | "event_msg") }) - || extract_model_from_value(value).is_some() - || extract_headless_usage(value).is_some() -} - -fn extract_headless_usage(value: &Value) -> Option { - let usage = value - .get("usage") - .or_else(|| value.get("data").and_then(|data| data.get("usage"))) - .or_else(|| value.get("result").and_then(|data| data.get("usage"))) - .or_else(|| value.get("response").and_then(|data| data.get("usage")))?; - - let input_tokens = extract_i64(usage.get("input_tokens")) - .or_else(|| extract_i64(usage.get("prompt_tokens"))) - .or_else(|| extract_i64(usage.get("input"))) - .unwrap_or(0); - let output_tokens = extract_i64(usage.get("output_tokens")) - .or_else(|| extract_i64(usage.get("completion_tokens"))) - .or_else(|| extract_i64(usage.get("output"))) - .unwrap_or(0); - let cached_tokens = extract_i64(usage.get("cached_input_tokens")) - .or_else(|| extract_i64(usage.get("cache_read_input_tokens"))) - .or_else(|| extract_i64(usage.get("cached_tokens"))) - .unwrap_or(0); - - let model = extract_model_from_value(value) - .or_else(|| value.get("data").and_then(extract_model_from_value)); - let timestamp_ms = extract_timestamp_from_value(value); - - Some(CodexHeadlessUsage { - input: input_tokens.saturating_sub(cached_tokens), - output: output_tokens, - cached: cached_tokens, - model, - timestamp_ms, - }) -} - -fn extract_model_from_value(value: &Value) -> Option { - extract_string(value.get("model")) - .or_else(|| extract_string(value.get("model_name"))) - .or_else(|| { - value - .get("data") - .and_then(|data| extract_string(data.get("model"))) - }) - .or_else(|| { - value - .get("data") - .and_then(|data| extract_string(data.get("model_name"))) - }) - .or_else(|| { - value - .get("response") - .and_then(|data| extract_string(data.get("model"))) - }) -} - -fn extract_timestamp_from_value(value: &Value) -> Option { - value - .get("timestamp") - .or_else(|| value.get("time")) - .or_else(|| value.get("created_at")) - .or_else(|| value.get("data").and_then(|data| data.get("timestamp"))) - .and_then(parse_timestamp_value) } /// Prefixes Codex prepends to context it injects as `user_message` events. @@ -1450,31 +1265,18 @@ mod tests { ); #[test] - fn test_headless_usage_line() { - let content = r#"{"timestamp":"2026-01-01T00:00:00Z","type":"turn.completed","model":"gpt-4o-mini","usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30}}"#; - let file = create_test_file(content); - - let messages = parse_codex_file(file.path()); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "gpt-4o-mini"); - assert_eq!(messages[0].tokens.input, 100); - assert_eq!(messages[0].tokens.output, 30); - assert_eq!(messages[0].tokens.cache_read, 20); - } - - #[test] - fn test_headless_usage_nested_data() { - let content = r#"{"timestamp":"2026-01-01T00:00:00Z","type":"result","data":{"model_name":"gpt-4o","usage":{"input_tokens":50,"cached_input_tokens":5,"output_tokens":12}}}"#; - let file = create_test_file(content); + fn structured_stdout_is_not_a_provider_session_source() { + let file = create_test_file( + r#"{"timestamp":"2026-01-01T00:00:00Z","type":"turn.completed","model":"gpt-4o-mini","usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30}}"#, + ); - let messages = parse_codex_file(file.path()); + let parsed = + super::parse_codex_file_incremental(file.path(), 0, CodexParseState::default()) + .unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].model_id.as_ref(), "gpt-4o"); - assert_eq!(messages[0].tokens.input, 45); - assert_eq!(messages[0].tokens.output, 12); - assert_eq!(messages[0].tokens.cache_read, 5); + assert!(parsed.messages.is_empty()); + assert!(parsed.rejections.is_empty()); + assert!(parsed.interrupted.is_none()); } #[test] @@ -1612,23 +1414,6 @@ mod tests { .contains("model was never identified")); } - #[test] - fn test_model_only_headless_line_flushes_pending_token_counts() { - let file = create_test_file(concat!( - r#"{"type":"session_meta","payload":{"source":"interactive","model_provider":"openai"}}"#, - "\n", - r#"{"timestamp":"2026-04-27T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3,"reasoning_output_tokens":1},"last_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3,"reasoning_output_tokens":1}}}}"#, - "\n", - r#"{"model":"gpt-5.5","type":"metadata"}"#, - "\n" - )); - - let parsed = parse_codex_file_incremental(file.path(), 0, CodexParseState::default()); - - assert_eq!(parsed.messages.len(), 1); - assert_eq!(parsed.messages[0].model_id.as_ref(), "gpt-5.5"); - } - #[test] fn test_parse_reader_returns_interrupted_outcome_on_line_read_error() { let mut reader = FailAfterFirstLine::new(concat!( @@ -1701,7 +1486,7 @@ mod tests { } #[test] - fn test_session_meta_exec_marks_headless() { + fn test_session_meta_exec_marks_exec() { let line1 = r#"{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"originator":"codex_exec","source":"exec"}}"#; let line2 = r#"{"timestamp":"2026-01-01T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"model":"gpt-5.4","total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3},"last_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3}}}}"#; let content = format!("{}\n{}", line1, line2); @@ -1710,7 +1495,7 @@ mod tests { let messages = parse_codex_file(file.path()); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].agent.as_deref(), Some("Codex Headless")); + assert_eq!(messages[0].agent.as_deref(), Some("Codex Exec")); } #[test] @@ -2358,35 +2143,6 @@ mod tests { assert_eq!(messages[1].tokens.reasoning, 2); } - #[test] - fn test_headless_line_uses_session_provider_and_agent() { - // session_meta sets provider to "azure" and agent to "my-bot", - // then a line falls through to headless parsing (no structured entry_type) - let line1 = r#"{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"model_provider":"azure","agent_nickname":"my-bot"}}"#; - let line2 = r#"{"timestamp":"2026-01-01T00:00:01Z","type":"turn.completed","model":"gpt-4o","usage":{"input_tokens":100,"output_tokens":50}}"#; - let content = format!("{}\n{}", line1, line2); - let file = create_test_file(&content); - - let messages = parse_codex_file(file.path()); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].provider_id.as_ref(), "azure"); - assert_eq!(messages[0].agent.as_deref(), Some("Codex Agent")); - } - - #[test] - fn test_headless_line_uses_codex_default_provider_without_session_meta() { - // The Codex headless protocol uses OpenAI as its default provider. - let content = r#"{"timestamp":"2026-01-01T00:00:00Z","type":"turn.completed","model":"gpt-4o-mini","usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30}}"#; - let file = create_test_file(content); - - let messages = parse_codex_file(file.path()); - - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].provider_id.as_ref(), "openai"); - assert!(messages[0].agent.is_none()); - } - #[test] fn test_extract_model_skips_empty_slug_falls_through_to_model() { // model_info.slug is empty string, but payload.model has a valid value. @@ -2490,7 +2246,7 @@ mod tests { #[test] fn test_exec_user_message_still_marks_turn_start() { - // A `codex exec` one-shot is headless but still carries a real human + // A `codex exec` one-shot is non-interactive but still carries a real human // prompt, so it counts as exactly one turn (verified against a real // `codex exec` session: 1 user_message -> turn_count 1). let content = [ @@ -2512,7 +2268,7 @@ mod tests { messages[0].is_turn_start, "an exec one-shot with a human prompt counts as one turn" ); - assert_eq!(messages[0].agent.as_deref(), Some("Codex Headless")); + assert_eq!(messages[0].agent.as_deref(), Some("Codex Exec")); } #[test] diff --git a/crates/tokscale-core/src/sessions/gemini.rs b/crates/tokscale-core/src/sessions/gemini.rs index 59b7fe953..01a4d2fe8 100644 --- a/crates/tokscale-core/src/sessions/gemini.rs +++ b/crates/tokscale-core/src/sessions/gemini.rs @@ -152,7 +152,7 @@ pub fn parse_gemini_file(path: &Path) -> SessionParseResult { fn parse_gemini_file_inner(path: &Path) -> SessionParseResult { if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { - return parse_gemini_headless_jsonl(path); + return parse_gemini_jsonl(path); } // JSON session files are valid only in the current @@ -201,7 +201,7 @@ fn parse_gemini_file_inner(path: &Path) -> SessionParseResult { } let session_id = extract_string(value.get("session_id").or_else(|| value.get("sessionId"))); - Ok(parse_gemini_headless_value(&value, session_id.as_deref())) + Ok(parse_gemini_usage_value(&value, session_id.as_deref())) } fn parse_gemini_session(session: GeminiSessionEnvelope) -> SessionParseResult { @@ -388,7 +388,7 @@ fn parse_direct_gemini_token_message( ))) } -fn parse_gemini_headless_jsonl(path: &Path) -> SessionParseResult { +fn parse_gemini_jsonl(path: &Path) -> SessionParseResult { let file = std::fs::File::open(path) .map_err(|error| SessionParseError::at_path(path, "open file", error))?; @@ -585,7 +585,7 @@ fn trim_ascii_bytes(bytes: &[u8]) -> &[u8] { &bytes[start..end] } -fn parse_gemini_headless_value(value: &Value, session_id: Option<&str>) -> ScannedSource { +fn parse_gemini_usage_value(value: &Value, session_id: Option<&str>) -> ScannedSource { let mut scanned = ScannedSource::default(); if value.get("tokens").is_some() { match parse_direct_gemini_token_message(value, None, session_id) { @@ -638,7 +638,7 @@ fn parse_gemini_headless_value(value: &Value, session_id: Option<&str>) -> Scann } fn build_messages_from_usages( - usages: Vec, + usages: Vec, session_id: &str, timestamp: i64, ) -> Vec { @@ -646,7 +646,7 @@ fn build_messages_from_usages( .into_iter() .map(|usage| { let (input, cache_read) = if usage.input_includes_cache { - normalize_gemini_headless_input_and_cache(usage.input, usage.cached) + normalize_gemini_usage_input_and_cache(usage.input, usage.cached) } else { (usage.input.max(0), usage.cached.max(0)) }; @@ -676,7 +676,7 @@ fn subtract_cached_overlap(input: i64, cached: i64) -> (i64, i64) { (input - cached_portion, cached) } -fn normalize_gemini_headless_input_and_cache(input: i64, cached: i64) -> (i64, i64) { +fn normalize_gemini_usage_input_and_cache(input: i64, cached: i64) -> (i64, i64) { // Gemini usage_metadata promptTokenCount is cache-inclusive, while Tokscale // represents non-cached input and cache hits as separate buckets. subtract_cached_overlap(input, cached) @@ -707,7 +707,7 @@ fn normalize_gemini_session_input_and_cache( (input, cached) } -struct GeminiHeadlessUsage { +struct GeminiUsageStats { model: String, input: i64, output: i64, @@ -717,7 +717,7 @@ struct GeminiHeadlessUsage { } struct GeminiUsageScan { - usages: Vec, + usages: Vec, rejections: Vec, } @@ -772,7 +772,7 @@ fn extract_gemini_usages( ) })?; Ok(GeminiUsageScan { - usages: vec![GeminiHeadlessUsage { model, ..usage }], + usages: vec![GeminiUsageStats { model, ..usage }], rejections: Vec::new(), }) } @@ -780,7 +780,7 @@ fn extract_gemini_usages( fn extract_gemini_usage_from_value( model: String, value: &Value, -) -> SessionParseResult> { +) -> SessionParseResult> { if !value.is_object() { return Err(SessionParseError::invalid( "validate model stats", @@ -876,7 +876,7 @@ fn extract_gemini_usage_from_value( return Ok(None); } - Ok(Some(GeminiHeadlessUsage { + Ok(Some(GeminiUsageStats { model, input, output, @@ -1130,8 +1130,8 @@ mod tests { } #[test] - fn test_parse_headless_json() { - let json = r#"{"session_id":"headless-1","timestamp":"2026-05-01T00:01:00Z","response":"Hi","stats":{"models":{"gemini-2.5-pro":{"tokens":{"prompt":12,"candidates":34,"cached":5,"thoughts":2}}}}}"#; + fn test_parse_gemini_usage_json() { + let json = r#"{"session_id":"usage-1","timestamp":"2026-05-01T00:01:00Z","response":"Hi","stats":{"models":{"gemini-2.5-pro":{"tokens":{"prompt":12,"candidates":34,"cached":5,"thoughts":2}}}}}"#; let (_directory, file) = write_current_json(json); let messages = parse_gemini_file(&file); @@ -1146,10 +1146,10 @@ mod tests { } #[test] - fn headless_models_isolates_bad_model_and_keeps_siblings() { + fn usage_models_isolates_bad_model_and_keeps_siblings() { let (_directory, file) = write_current_json( r#"{ - "session_id":"headless-mixed", + "session_id":"usage-mixed", "timestamp":"2026-05-01T00:01:00Z", "stats":{"models":{ "gemini-2.5-flash":{"tokens":{"input":10}}, @@ -1167,10 +1167,10 @@ mod tests { } #[test] - fn overflowing_headless_model_is_malformed_and_sibling_model_survives() { + fn overflowing_usage_model_is_malformed_and_sibling_model_survives() { let (_directory, file) = write_current_json( r#"{ - "session_id":"headless-overflow", + "session_id":"usage-overflow", "timestamp":"2026-05-01T00:01:00Z", "stats":{"models":{ "gemini-bad":{"tokens":{"input":9223372036854775807,"output":1}}, @@ -1192,7 +1192,7 @@ mod tests { } #[test] - fn test_parse_headless_stream_jsonl() { + fn test_parse_gemini_stream_jsonl() { let content = r#"{"type":"init","model":"gemini-2.5-pro","session_id":"session-1"} {"type":"result","timestamp":"2026-05-01T00:01:00Z","stats":{"input_tokens":10,"output_tokens":20}}"#; let mut file = tempfile::Builder::new() @@ -1231,7 +1231,7 @@ mod tests { } #[test] - fn test_parse_headless_stream_jsonl_normalizes_cached_input() { + fn test_parse_gemini_stream_jsonl_normalizes_cached_input() { let content = r#"{"type":"init","model":"gemini-2.5-pro","session_id":"session-1"} {"type":"result","timestamp":"2026-05-01T00:01:00Z","stats":{"input_tokens":12,"output_tokens":20,"cached_tokens":5,"thoughts_tokens":3}}"#; let mut file = tempfile::Builder::new() @@ -1295,8 +1295,8 @@ mod tests { } #[test] - fn test_parse_headless_stats_tokens_wrapper_preserves_cache_inclusive_input() { - let json = r#"{"session_id":"headless-1","timestamp":"2026-05-01T00:01:00Z","stats":{"models":{"gemini-2.5-pro":{"tokens":{"input":12,"output":20,"cached":5}}}}}"#; + fn test_parse_gemini_stats_tokens_wrapper_preserves_cache_inclusive_input() { + let json = r#"{"session_id":"usage-1","timestamp":"2026-05-01T00:01:00Z","stats":{"models":{"gemini-2.5-pro":{"tokens":{"input":12,"output":20,"cached":5}}}}}"#; let (_directory, file) = write_current_json(json); let messages = parse_gemini_file(&file); @@ -1442,7 +1442,7 @@ not-json\n\ #[test] fn test_parse_gemini_json_direct_tokens() { - let json = r#"{"type":"gemini","session_id":"headless-1","timestamp":"2026-05-01T00:01:00Z","model":"gemini-3.1-pro-preview","tokens":{"input":20,"output":2,"cached":5,"thoughts":3,"tool":4,"total":29}}"#; + let json = r#"{"type":"gemini","session_id":"usage-1","timestamp":"2026-05-01T00:01:00Z","model":"gemini-3.1-pro-preview","tokens":{"input":20,"output":2,"cached":5,"thoughts":3,"tool":4,"total":29}}"#; let (_directory, file) = write_current_json(json); let messages = parse_gemini_file(&file); @@ -1457,9 +1457,9 @@ not-json\n\ } #[test] - fn headless_negative_tokens_are_malformed_instead_of_clamped() { + fn usage_negative_tokens_are_malformed_instead_of_clamped() { let (_directory, file) = write_current_json( - r#"{"type":"gemini","session_id":"headless-negative","timestamp":"2026-05-01T00:01:00Z","model":"gemini-2.5-pro","tokens":{"input":-10,"output":20}}"#, + r#"{"type":"gemini","session_id":"usage-negative","timestamp":"2026-05-01T00:01:00Z","model":"gemini-2.5-pro","tokens":{"input":-10,"output":20}}"#, ); let scanned = super::parse_gemini_file(&file).unwrap(); @@ -1473,8 +1473,8 @@ not-json\n\ } #[test] - fn test_parse_headless_json_clamps_cached_input_overlap() { - let json = r#"{"session_id":"headless-1","timestamp":"2026-05-01T00:01:00Z","response":"Hi","stats":{"models":{"gemini-2.5-pro":{"tokens":{"prompt":5,"candidates":2,"cached":10}}}}}"#; + fn test_parse_gemini_usage_json_clamps_cached_input_overlap() { + let json = r#"{"session_id":"usage-1","timestamp":"2026-05-01T00:01:00Z","response":"Hi","stats":{"models":{"gemini-2.5-pro":{"tokens":{"prompt":5,"candidates":2,"cached":10}}}}}"#; let (_directory, file) = write_current_json(json); let messages = parse_gemini_file(&file); @@ -1719,7 +1719,7 @@ not-json\n\ } #[test] - fn test_parse_headless_jsonl_non_gemini_type_with_direct_tokens() { + fn test_parse_gemini_usage_jsonl_non_gemini_type_with_direct_tokens() { let content = r#"{"type":"init","model":"gemini-3-flash-preview","session_id":"session-tokens"} {"type":"result","id":"msg-1","timestamp":"2026-05-01T00:01:00Z","tokens":{"input":100,"output":25,"cached":10,"total":125}}"#; let dir = TempDir::new().unwrap(); diff --git a/crates/tokscale-core/src/sessions/mod.rs b/crates/tokscale-core/src/sessions/mod.rs index b8075c467..3e0e74327 100644 --- a/crates/tokscale-core/src/sessions/mod.rs +++ b/crates/tokscale-core/src/sessions/mod.rs @@ -3,7 +3,6 @@ //! Each client has its own parser that converts to a unified message format. pub mod amp; -pub mod antigravity; pub mod antigravity_cli; pub mod claudecode; pub mod cline; diff --git a/docs/adr/0005-local-client-boundaries.md b/docs/adr/0005-local-client-boundaries.md index f1dbf33d8..faeb16b5e 100644 --- a/docs/adr/0005-local-client-boundaries.md +++ b/docs/adr/0005-local-client-boundaries.md @@ -10,6 +10,13 @@ Client identity, local parsing policy, usage aggregation, and TUI interaction rules are currently spread across multiple modules and packages. This makes small client changes expensive and makes upstream merges harder to reason about. +An additional boundary is needed between provider-owned usage artifacts and +Tokscale-owned state. Launching a provider CLI only to copy its structured +stdout into a second Tokscale session tree creates two competing sources for +the same run. It also makes Tokscale responsible for child-process flags, +timeouts, exit codes, and cross-format deduplication even when the provider +already persists an authoritative session record. + ## Decision Use these boundaries for future implementation work: @@ -20,8 +27,37 @@ Use these boundaries for future implementation work: - TUI scroll, hitbox, and selection behavior should move behind a local interaction seam where repeated views already drift. +Provider-owned local artifacts are authoritative: + +- Adapters read the provider's current files or databases directly. A + provider-owned non-interactive session remains ordinary local usage; for + example, `codex exec` is included when Codex writes it under + `$CODEX_HOME/sessions`. +- Tokscale does not launch a provider CLI solely to capture structured stdout + as a parallel usage log, and does not create or scan a shadow session tree + for that purpose. +- A Tokscale-owned sync cache is acceptable only for an explicit integration + whose supported source has no stable directly readable artifact. Such a + workflow must have one documented authority and must not duplicate provider + credentials or an already available usage record. +- Regenerable parser and report caches may mirror derived data for performance, + but they are never an additional semantic source. + ## Consequences These are direction-setting boundaries, not permission for a large speculative rewrite. Each implementation PR should migrate one proven slice and delete the duplicated behavior it replaces. + +The former `headless` command, `TOKSCALE_HEADLESS_DIR`, and +`~/.config/tokscale/headless` scan root violate the provider-source boundary and +are removed. Captured structured-stdout streams are not local session sources. +Existing files under that old root are ignored and may be deleted. Normal +provider-owned Codex and Gemini non-interactive session records continue to be +discovered. Removing the shadow capture path also removes the possibility of +counting one execution once from provider storage and again from captured +stdout. + +ADR 0025 applies the same boundary to Antigravity: the private IDE/2.0 RPC +bridge and Tokscale-owned JSONL session tree are removed in favor of direct, +read-only AGY CLI SQLite ingestion. diff --git a/docs/adr/0006-agent-identity-for-agents-tab.md b/docs/adr/0006-agent-identity-for-agents-tab.md index 050416a4f..593f6a4e7 100644 --- a/docs/adr/0006-agent-identity-for-agents-tab.md +++ b/docs/adr/0006-agent-identity-for-agents-tab.md @@ -22,7 +22,7 @@ Group `Agents` rows by stable agent identity only. as the primary aggregation key. - Instance identifiers belong in `agent_instance` and may contribute to the `Instances` count. -- Codex uses stable role, subagent, or headless labels; `agent_nickname` is not +- Codex uses stable role, subagent, or exec-session labels; `agent_nickname` is not a grouping identity. - Claude preserves known stable subagent types and collapses unknown temporary sidechain names to `Claude Subagent`. diff --git a/docs/adr/0018-bounded-source-fold-pipeline.md b/docs/adr/0018-bounded-source-fold-pipeline.md index 3ae6a6685..5d6c5ab94 100644 --- a/docs/adr/0018-bounded-source-fold-pipeline.md +++ b/docs/adr/0018-bounded-source-fold-pipeline.md @@ -65,7 +65,8 @@ Codex cold parses, append merges, and cache-race reparses keep one owned raw message vector. When a shard is cacheable, the fold serializes a borrowed slice of that raw vector before applying fallback timestamps or derived fields. It then applies timestamp fallback, token normalization/filtering, model and provider -canonicalization, pricing, and headless attribution to the same vector in place. +canonicalization, pricing, and exec-session attribution to the same vector in +place. The persisted shard format and its raw-message semantics do not change. ## Consequences diff --git a/docs/adr/0020-strict-source-identity-and-error-contract.md b/docs/adr/0020-strict-source-identity-and-error-contract.md index ab56d1871..568d7dd3c 100644 --- a/docs/adr/0020-strict-source-identity-and-error-contract.md +++ b/docs/adr/0020-strict-source-identity-and-error-contract.md @@ -56,6 +56,8 @@ The current Antigravity adapter still reads the accepted `~/.gemini/antigravity-cli/conversations/*.db` source under the canonical `antigravity` identity. ADR 0007 owns the exact persisted `defaultClients` identity migration from the former `antigravity-cli` client. +ADR 0025 later makes this current AGY CLI database the sole Antigravity source +and retires the IDE/2.0 private-RPC cache bridge. This decision supersedes ADR 0008's metadata-only persisted stamp, transient- identity-only race check, same-size/same-mtime limitation, legacy public-key diff --git a/docs/adr/0022-deterministic-cli-command-semantics.md b/docs/adr/0022-deterministic-cli-command-semantics.md index 9d82f4b66..3e217c6b8 100644 --- a/docs/adr/0022-deterministic-cli-command-semantics.md +++ b/docs/adr/0022-deterministic-cli-command-semantics.md @@ -96,8 +96,9 @@ Pricing is `pricing lookup ` or `pricing overrides`; the lookup's catalog selector is named `--source`. Cache maintenance is `cache warm` or `cache prune`. Reports never write the TUI aggregate cache, and the removed `--write-cache`, `--no-write-cache`, and `light.writeCache` controls have no -replacement inside a report command. `headless` requires `--` between -Tokscale options and the child command. +replacement inside a report command. Tokscale does not expose a subprocess +capture command; provider-owned non-interactive sessions are discovered as +ordinary local usage under ADR 0005. The old spellings are not aliases and are never rewritten into a successful command. Known v4 invocations may receive one migration hint only after Clap diff --git a/docs/adr/0025-antigravity-cli-only-local-source.md b/docs/adr/0025-antigravity-cli-only-local-source.md new file mode 100644 index 000000000..0aa88b910 --- /dev/null +++ b/docs/adr/0025-antigravity-cli-only-local-source.md @@ -0,0 +1,63 @@ +# ADR 0025: Antigravity CLI is the only supported local source + +Status: Accepted + +## Context + +Tokscale previously supported Antigravity IDE and Antigravity 2.0 through an +indirect bridge. It searched historical Antigravity data roots for trajectory +identifiers, inspected a running language-server process for a port and +transient CSRF credential, called undocumented local RPC methods, and converted +the responses into a Tokscale-owned JSONL session tree and manifest under +`~/.config/tokscale/antigravity-cache/`. + +That bridge was not a local-file parser. It depended on a running provider +process, private runtime arguments and RPC schemas, process visibility across +the Tokscale/provider OS boundary, and a second semantic copy of provider usage +inside Tokscale state. A provider update or a Windows/WSL split could therefore +make intact provider data unavailable. The parallel discovery, transport, +normalization, manifest, locking, and cache path also imposed substantial +maintenance and security surface for an optional historical product path. + +Current AGY CLI releases persist conversation usage in provider-owned SQLite +databases under +`$GEMINI_CLI_HOME/antigravity-cli/conversations/*.db`, falling back to +`~/.gemini/antigravity-cli/conversations/*.db`. Read-only SQLite/WAL ingestion +has been verified against normal incremental AGY CLI activity. This satisfies +the provider-owned artifact boundary in ADR 0005 without a sync bridge. + +## Decision + +The canonical `antigravity` client reads only current AGY CLI conversation +databases. + +- Discover `*.db` under the current AGY CLI conversations directory and any + explicitly configured `scanner.extraScanPaths.antigravity` roots. +- Read SQLite and its WAL directly and parse only the token-accounting protobuf + fields required by the current AGY CLI format. +- Do not discover or ingest Antigravity IDE, retained IDE, backup, or + Antigravity 2.0 Agent Manager data roots. +- Do not inspect Antigravity processes, extract transient credentials, connect + to private language-server RPCs, or create a shadow JSONL usage source. +- Remove the `tokscale antigravity sync`, `status`, and `purge-cache` command + surface. Local-source visibility belongs to + `tokscale clients --client antigravity`; reports and the TUI scan AGY CLI + databases directly. +- Keep the canonical `antigravity` client identity and the persisted + `antigravity-cli` identity migration owned by ADR 0007. + +Retired parser-id tags may remain as non-constructible cache-format tombstones +when required to preserve serialized discriminants. They are not accepted +sources and do not constitute compatibility support. + +## Consequences + +Antigravity IDE/2.0-only history is intentionally not reported. Supporting a +future Antigravity product requires a directly readable provider-owned current +artifact and an explicit revision of this decision; reviving the private RPC +bridge is not an implicit fallback. + +AGY CLI usage appears on the next ordinary scan or TUI refresh without a sync +command. Existing `~/.config/tokscale/antigravity-cache/` files are ignored. +Tokscale does not silently delete user files during startup; users may remove +that obsolete directory after upgrading. diff --git a/docs/cli.md b/docs/cli.md index 466905e6f..db48e8e23 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -204,9 +204,7 @@ provider-owned auth state without copying, switching, refreshing, or modifying its credentials. ```bash -# Local integrations with explicit sync workflows -tokscale antigravity status --json -tokscale antigravity sync +# Local integration with an explicit sync workflow tokscale warp status --json tokscale warp sync --json @@ -215,19 +213,23 @@ tokscale usage tokscale usage --json ``` -Flags belong to the leaf command that executes them. They cannot be placed on -the root or before the owning subcommand. - -## Headless capture - -Headless capture requires `--` between Tokscale options and the child command: +Antigravity is an ordinary local report source, not a command namespace: ```bash -tokscale headless codex --format jsonl -- codex exec -m gpt-5 "review this change" +tokscale clients --client antigravity +tokscale models --client antigravity --no-spinner ``` -This boundary prevents child flags such as `--json` or `--output` from being -claimed by Tokscale. Set `TOKSCALE_HEADLESS_DIR` to change the capture root. +Tokscale reads current AGY CLI SQLite/WAL data directly. The retired +Antigravity IDE/2.0 private-RPC bridge and `tokscale antigravity ...` commands +are not supported; see ADR 0025. + +Flags belong to the leaf command that executes them. They cannot be placed on +the root or before the owning subcommand. + +Provider-owned non-interactive sessions require no Tokscale wrapper. For +example, Codex writes ordinary `codex exec` rollouts under its own session +directory, and Tokscale discovers them through the `codex` adapter. ## Exit codes diff --git a/docs/clients.md b/docs/clients.md index 43676c3ff..f20367a2a 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -20,7 +20,7 @@ When using an installed binary, use `tokscale clients` instead. | --- | --- | --- | --- | | `opencode` | OpenCode | `~/.local/share/opencode/opencode*.db` | Reads only current-format SQLite databases and combines multiple release channels when present. | | `claude` | Claude Code | `~/.claude/projects/**/*.jsonl`, `~/.claude/transcripts/**/*.jsonl` | Claude Desktop chat history is not treated as Claude Code token accounting. | -| `codex` | Codex CLI | `$CODEX_HOME/sessions/**/*.jsonl`, fallback `~/.codex/sessions/` | Also supports `tokscale headless codex ...` capture. | +| `codex` | Codex CLI | `$CODEX_HOME/sessions/**/*.jsonl`, fallback `~/.codex/sessions/` | Includes provider-owned interactive and `codex exec` sessions. | | `gemini` | Gemini CLI | `$GEMINI_CLI_HOME/tmp/**/chats/*`, fallback `~/.gemini/tmp/` | Reads local chat files. | | `amp` | Amp | `~/.local/share/amp/threads/T-*.json` | Reads local thread files. | | `droid` | Droid | `~/.factory/sessions/**/*.settings.json`, related session JSONL and Mission `features.json` | Reads Factory Droid sessions and attributes subagent usage to `Droid Explorer`, `Droid Worker`, `Droid Orchestrator`, or `Droid Validator`. | @@ -38,7 +38,7 @@ When using an installed binary, use `tokscale clients` instead. | `goose` | Goose | `~/.local/share/goose/sessions/sessions.db` and platform legacy roots | `GOOSE_PATH_ROOT` can point at an alternate root. | | `codebuff` | Codebuff | `$CODEBUFF_DATA_DIR/projects/**/chat-messages.json`, fallback `~/.config/manicode/projects/` | Also scans dev/staging Manicode roots. | | `codebuddy` | CodeBuddy | `~/.codebuddy/projects/**/*.jsonl` and local CodeBuddy/VS Code extension logs | Reads assistant/function-call usage and final agent usage from local CodeBuddy records. | -| `antigravity` | Antigravity | `~/.config/tokscale/antigravity-cache/sessions/*.jsonl` and Antigravity CLI conversation databases | IDE data requires `tokscale antigravity sync`; CLI databases are read directly. | +| `antigravity` | Antigravity | `$GEMINI_CLI_HOME/antigravity-cli/conversations/*.db`, fallback `~/.gemini/antigravity-cli/conversations/*.db` | Reads current AGY CLI SQLite/WAL data directly. Antigravity IDE and Antigravity 2.0 Agent Manager are intentionally unsupported; see ADR 0025. | | `zed` | Zed Agent | `~/.local/share/zed/threads/threads.db` | Hosted Zed model usage only; external ACP agents are not included. | | `zcode` | ZCode | `~/.zcode/projects/**/*.jsonl` | Reads Z.ai ADE JSONL sessions. | | `kiro` | Kiro | `~/.kiro/sessions/cli/`, `~/.local/share/kiro-cli/data.sqlite3`, and Kiro IDE globalStorage snapshots | Combines CLI and IDE local sources when present. | @@ -109,15 +109,12 @@ TOKSCALE_EXTRA_DIRS='codex:/abs/path/.codex/sessions,gemini:/abs/path/gemini/tmp tokscale models --no-spinner ``` -## Cache-backed integrations +## Integration data boundaries -Antigravity does not refresh from the root report or TUI command. Run its sync -command before reports when you need fresh data: - -```bash -tokscale antigravity status -tokscale antigravity sync -``` +Antigravity is not a cache-backed integration. Reports and the TUI read current +AGY CLI databases directly; there is no sync command. Historical +`~/.config/tokscale/antigravity-cache/` artifacts are ignored and may be +deleted. `warp` has two separate surfaces. Local reports read `warp.sqlite` when it is available. Those local rows are per-conversation/per-model aggregates, not diff --git a/docs/configuration.md b/docs/configuration.md index 648b94b69..45ace9abe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,6 @@ Tokscale stores most local settings under the platform config directory: | `includeUnusedModels` | boolean | Show zero-token models in reports. | | `autoRefreshEnabled` | boolean | Enable TUI auto-refresh for local reports. | | `autoRefreshMs` | number | TUI auto-refresh interval in milliseconds. | -| `nativeTimeoutMs` | number | Maximum processing time for native subprocess work. | | `defaultClients` | string[] | Client filter used when no `--client/-c` flag is passed. | | `usageTabEnabled` | boolean | Show the subscription quota Usage tab in the TUI. | | `usageProviders` | string[] | Explicit allowlist of subscription providers the TUI may fetch. Empty means cache-display mode. | @@ -65,9 +64,7 @@ reported explicitly. | Variable | Meaning | | --- | --- | | `TOKSCALE_CONFIG_DIR` | Overrides the general config/cache root used by Tokscale. Non-empty values are used verbatim. Empty values are treated as unset. | -| `TOKSCALE_NATIVE_TIMEOUT_MS` | Overrides `nativeTimeoutMs`. | | `TOKSCALE_EXTRA_DIRS` | One-off extra scan roots as `client:/abs/path,client:/abs/path`. | -| `TOKSCALE_HEADLESS_DIR` | Overrides the headless capture root. Surrounding whitespace is trimmed; blank values fall back to the default root. | | `TOKSCALE_USAGE_ZAI_CODING_PLAN_API_KEY` | Z.ai/Zhipu GLM Coding Plan quota key. | | `TOKSCALE_USAGE_KIMI_CODING_PLAN_API_KEY` | Kimi Code Console quota key. | | `TOKSCALE_USAGE_MINIMAX_TOKEN_PLAN_CN_KEY` | MiniMax CN Token Plan subscription key. | @@ -81,7 +78,7 @@ path when set to a blank value. Path-like environment variables intentionally use two different policies: -- Client/headless scan roots trim surrounding whitespace and treat blank values +- Client scan roots trim surrounding whitespace and treat blank values as a request to use the default root. - Config and XDG roots (`TOKSCALE_CONFIG_DIR`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME`) are system/configuration boundaries. Tokscale keeps @@ -116,13 +113,16 @@ write it; use `tokscale cache warm` when you intentionally want to prebuild it. Integration roots are mixed state, not all disposable caches: -- `antigravity-cache/` contains synced Antigravity artifacts. Use - `tokscale antigravity purge-cache` when you want to clear them. - `warp-cache/` contains both synced Warp aggregate usage and `credentials.json`. Deleting the directory can log you out; use `tokscale warp logout --purge-cache` when you intentionally want to remove credentials and cached usage together. +The retired `antigravity-cache/` integration root is not a current input. +Tokscale does not delete it automatically; it may be removed manually after +upgrading. Current AGY CLI usage remains provider-owned under +`$GEMINI_CLI_HOME/antigravity-cli/conversations/`. + ## Subscription providers Canonical `usageProviders` ids: diff --git a/docs/performance/2026-07-10-scan-rss-optimization.md b/docs/performance/2026-07-10-scan-rss-optimization.md index 3068ecb48..b581e3583 100644 --- a/docs/performance/2026-07-10-scan-rss-optimization.md +++ b/docs/performance/2026-07-10-scan-rss-optimization.md @@ -515,7 +515,8 @@ prepared snapshots on indeterminate misses, no repeated header lookup after a definitive miss, direct-parser adapters ignoring seeded shards, Rayon-width message ownership, batch release before the next parse, cross-batch dedup and merge state, OpenCode precedence, OMP parent attribution, and Codex raw cache, -append, fallback-coordinate, pricing, headless, and cache-race semantics. +append, fallback-coordinate, pricing, exec-session attribution, and cache-race +semantics. ```text cargo test -p tokscale-core