use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
use super::{
EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
file_mtime_epoch_s, general_context_window, general_host_model, home_dir, is_recent,
read_file_bounded, read_file_to_string_bounded, resolve_env_string, resolve_env_u64,
string_key,
};
const ADAPTER_ID: &str = "claude";
const CLAUDE_HOME_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_CLAUDE_HOME",
ccd_compat: "CCD_CLAUDE_HOME",
}];
const CLAUDE_SESSION_ID_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_CLAUDE_SESSION_ID",
ccd_compat: "CCD_CLAUDE_SESSION_ID",
}];
const CLAUDE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_CLAUDE_MODEL",
ccd_compat: "CCD_CLAUDE_MODEL",
}];
const CLAUDE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_CLAUDE_CONTEXT_WINDOW_TOKENS",
ccd_compat: "CCD_CLAUDE_CONTEXT_WINDOW_TOKENS",
}];
pub fn current(repo_root: &Path) -> TelemetryResult<Option<PressureObservation>> {
let project_dir = claude_home()?
.join("projects")
.join(project_slug(repo_root));
if !project_dir.is_dir() {
return Ok(None);
}
let session_id_override = resolve_env_string(CLAUDE_SESSION_ID_ALIASES);
let session_log = match session_id_override.as_deref() {
Some(session_id) => {
let path = project_dir.join(format!("{session_id}.jsonl"));
path.is_file().then_some(path)
}
None => latest_session_log(&project_dir)?,
};
let Some(session_log) = session_log else {
return Ok(None);
};
let observed_at_epoch_s = match file_mtime_epoch_s(&session_log)? {
Some(epoch_s) => epoch_s,
None => return Ok(None),
};
if session_id_override.is_none() && !is_recent(observed_at_epoch_s)? {
return Ok(None);
}
let Some(metrics) = latest_session_metrics(&session_log)? else {
return Ok(None);
};
let context_window =
resolve_env_u64(CLAUDE_CONTEXT_WINDOW_ALIASES).or_else(general_context_window);
let compacted = session_was_compacted(&project_dir, &session_log, &metrics.session_id)?;
Ok(Some(PressureObservation {
adapter_id: ADAPTER_ID.into(),
adapter_version: None,
observed_at_epoch_s,
model_name: metrics
.model_name
.or_else(|| resolve_env_string(CLAUDE_MODEL_ALIASES))
.or_else(general_host_model),
total_tokens: Some(metrics.latest_prompt_tokens),
context_window_tokens: context_window,
context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window),
compaction_signal: compacted,
usage: TokenUsage {
input_tokens: metrics.input_tokens,
output_tokens: metrics.output_tokens,
cache_creation_input_tokens: metrics.cache_creation_input_tokens,
cache_read_input_tokens: metrics.cache_read_input_tokens,
blended_total_tokens: None,
},
}))
}
pub fn parse_session_log(
bytes: &[u8],
observed_at_epoch_s: u64,
context_window_tokens: Option<u64>,
) -> TelemetryResult<Option<PressureObservation>> {
let metrics = aggregate_session_metrics(bytes)?;
let Some(metrics) = metrics else {
return Ok(None);
};
Ok(Some(PressureObservation {
adapter_id: ADAPTER_ID.into(),
adapter_version: None,
observed_at_epoch_s,
model_name: metrics.model_name,
total_tokens: Some(metrics.latest_prompt_tokens),
context_window_tokens,
context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window_tokens),
compaction_signal: None,
usage: TokenUsage {
input_tokens: metrics.input_tokens,
output_tokens: metrics.output_tokens,
cache_creation_input_tokens: metrics.cache_creation_input_tokens,
cache_read_input_tokens: metrics.cache_read_input_tokens,
blended_total_tokens: None,
},
}))
}
fn claude_home() -> TelemetryResult<PathBuf> {
if let Some(path) = resolve_env_string(CLAUDE_HOME_ALIASES) {
return Ok(PathBuf::from(path));
}
Ok(home_dir()?.join(".claude"))
}
fn latest_session_log(project_dir: &Path) -> TelemetryResult<Option<PathBuf>> {
let mut newest: Option<(u64, PathBuf)> = None;
for entry in fs::read_dir(project_dir).map_err(TelemetryError::from)? {
let entry = entry.map_err(TelemetryError::from)?;
let path = entry.path();
let is_session_log = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".jsonl"))
.unwrap_or(false);
if !is_session_log {
continue;
}
let mtime = match file_mtime_epoch_s(&path)? {
Some(m) => m,
None => continue,
};
match &newest {
Some((best, _)) if *best >= mtime => {}
_ => newest = Some((mtime, path)),
}
}
Ok(newest.map(|(_, p)| p))
}
fn latest_session_metrics(path: &Path) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
let bytes = read_file_bounded(path, "Claude session log")?;
aggregate_session_metrics(&bytes)
}
fn aggregate_session_metrics(bytes: &[u8]) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
let reader = BufReader::new(bytes);
let mut latest: Option<ClaudeSessionMetrics> = None;
for line in reader.lines() {
let line = line.map_err(TelemetryError::from)?;
let value: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let model_name = string_key(
&value,
&["model", "model_name", "modelName", "display_name"],
);
let entry: ClaudeSessionEntry = match serde_json::from_value(value) {
Ok(e) => e,
Err(_) => continue,
};
let Some(message) = entry.message else {
continue;
};
let Some(usage) = message.usage else { continue };
let prompt_tokens = usage
.input_tokens
.saturating_add(usage.cache_creation_input_tokens)
.saturating_add(usage.cache_read_input_tokens);
if prompt_tokens == 0 && usage.output_tokens == 0 {
continue;
}
let Some(session_id) = entry.session_id else {
continue;
};
match &mut latest {
Some(m) => {
m.session_id = session_id;
m.latest_prompt_tokens = prompt_tokens;
m.input_tokens = m.input_tokens.saturating_add(usage.input_tokens);
m.output_tokens = m.output_tokens.saturating_add(usage.output_tokens);
m.cache_creation_input_tokens = m
.cache_creation_input_tokens
.saturating_add(usage.cache_creation_input_tokens);
m.cache_read_input_tokens = m
.cache_read_input_tokens
.saturating_add(usage.cache_read_input_tokens);
if model_name.is_some() {
m.model_name = model_name;
}
}
None => {
latest = Some(ClaudeSessionMetrics {
session_id,
latest_prompt_tokens: prompt_tokens,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
cache_read_input_tokens: usage.cache_read_input_tokens,
model_name,
});
}
}
}
Ok(latest)
}
fn session_was_compacted(
project_dir: &Path,
session_log: &Path,
session_id: &str,
) -> TelemetryResult<Option<bool>> {
let subagents_dir = project_dir.join(session_id).join("subagents");
if subagents_dir.is_dir() {
for entry in fs::read_dir(&subagents_dir).map_err(TelemetryError::from)? {
let entry = entry.map_err(TelemetryError::from)?;
let path = entry.path();
let is_acompact = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("agent-acompact-") && n.ends_with(".jsonl"))
.unwrap_or(false);
if is_acompact {
return Ok(Some(true));
}
}
}
let contents = read_file_to_string_bounded(session_log, "Claude session log")?;
if contents.contains("<command-name>/compact</command-name>") {
return Ok(Some(true));
}
Ok(None)
}
fn project_slug(repo_root: &Path) -> String {
repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf())
.to_string_lossy()
.replace('/', "-")
}
#[derive(Deserialize)]
struct ClaudeSessionEntry {
#[serde(rename = "sessionId")]
session_id: Option<String>,
message: Option<ClaudeMessage>,
}
#[derive(Deserialize)]
struct ClaudeMessage {
usage: Option<ClaudeUsage>,
}
#[derive(Deserialize)]
struct ClaudeUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
cache_creation_input_tokens: u64,
#[serde(default)]
cache_read_input_tokens: u64,
}
struct ClaudeSessionMetrics {
session_id: String,
latest_prompt_tokens: u64,
input_tokens: u64,
output_tokens: u64,
cache_creation_input_tokens: u64,
cache_read_input_tokens: u64,
model_name: Option<String>,
}