lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Claude Code lifecycle telemetry reader.
//!
//! Parses `~/.claude/projects/<slug>/<session>.jsonl` session logs and
//! extracts the lifecycle kernel: latest prompt-token count, model
//! name, and a compaction signal. No prompt or message bodies are
//! retained — only the per-line `usage` aggregates.

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",
}];

/// Probe the Claude Code session log for the given repo and return a
/// neutral [`PressureObservation`] when one is recent enough to drive a
/// `context.pressure_observed` event.
///
/// Returns `Ok(None)` when there is no fresh session log or when the log
/// has no usage-bearing entries — this is the *expected* "telemetry
/// unavailable" outcome and is not an error.
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,
        },
    }))
}

/// Parse a Claude Code session log byte slice directly. Useful for
/// testing and for callers that already have the log in memory.
///
/// `observed_at_epoch_s` is the mtime the caller wants stamped on the
/// resulting observation (typically the file mtime).
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>,
}