clawgs 0.1.0

Extract structured JSON snapshots from Claude Code and Codex JSONL transcripts
Documentation
use std::process::Command;

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};

use crate::emit::protocol::{
    RestState, SessionSnapshot, SessionState, ThoughtSource, ThoughtState,
};

const FIELD_SEP: char = '\u{1f}';

pub fn tmux_bin() -> String {
    std::env::var("CLAWGS_TMUX_BIN").unwrap_or_else(|_| "tmux".to_string())
}

pub fn scan_sessions(now: DateTime<Utc>, max_capture_lines: usize) -> Result<Vec<SessionSnapshot>> {
    scan_sessions_with_bin(now, max_capture_lines, &tmux_bin())
}

pub fn scan_sessions_with_bin(
    now: DateTime<Utc>,
    max_capture_lines: usize,
    tmux_bin: &str,
) -> Result<Vec<SessionSnapshot>> {
    let stdout = list_tmux_panes(tmux_bin)?;
    Ok(stdout
        .lines()
        .filter_map(parse_pane_meta_line)
        .filter_map(|meta| pane_meta_to_session(now, max_capture_lines, tmux_bin, meta))
        .collect())
}

fn list_tmux_panes(tmux_bin: &str) -> Result<String> {
    let format = format!(
        "#{{session_name}}{sep}#{{window_index}}{sep}#{{pane_index}}{sep}#{{pane_id}}{sep}#{{pane_current_path}}{sep}#{{pane_current_command}}{sep}#{{?pane_active,1,0}}{sep}#{{?pane_dead,1,0}}",
        sep = FIELD_SEP
    );

    let output = Command::new(tmux_bin)
        .args(["list-panes", "-a", "-F", &format])
        .output()
        .with_context(|| format!("failed to run {tmux_bin} list-panes"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        if tmux_server_missing(&stderr) {
            return Ok(String::new());
        }

        anyhow::bail!(
            "{tmux_bin} list-panes failed: {}",
            stderr.trim().replace('\n', " ")
        );
    }

    String::from_utf8(output.stdout).context("tmux list-panes output was not UTF-8")
}

#[derive(Debug, PartialEq, Eq)]
struct PaneMeta {
    session_name: String,
    window_index: String,
    pane_index: String,
    pane_id: String,
    current_path: String,
    current_command: String,
    active: bool,
    dead: bool,
}

fn parse_pane_line(line: &str) -> Option<PaneMeta> {
    let mut parts = line.split(FIELD_SEP);

    Some(PaneMeta {
        session_name: parts.next()?.to_string(),
        window_index: parts.next()?.to_string(),
        pane_index: parts.next()?.to_string(),
        pane_id: parts.next()?.to_string(),
        current_path: parts.next()?.to_string(),
        current_command: parts.next()?.to_string(),
        active: parts.next()? == "1",
        dead: parts.next()? == "1",
    })
}

fn parse_pane_meta_line(line: &str) -> Option<PaneMeta> {
    let trimmed = line.trim_end();
    (!trimmed.is_empty())
        .then(|| parse_pane_line(trimmed))
        .flatten()
}

fn pane_meta_to_session(
    now: DateTime<Utc>,
    max_capture_lines: usize,
    tmux_bin: &str,
    meta: PaneMeta,
) -> Option<SessionSnapshot> {
    (!meta.dead).then(|| build_session_snapshot(now, max_capture_lines, tmux_bin, meta))
}

fn build_session_snapshot(
    now: DateTime<Utc>,
    max_capture_lines: usize,
    tmux_bin: &str,
    meta: PaneMeta,
) -> SessionSnapshot {
    let replay_text =
        capture_pane_text(tmux_bin, &meta.pane_id, max_capture_lines).unwrap_or_default();
    let state = if meta.active {
        SessionState::Busy
    } else {
        SessionState::Idle
    };

    SessionSnapshot {
        session_id: format!(
            "tmux:{}:{}.{}:{}",
            meta.session_name, meta.window_index, meta.pane_index, meta.pane_id
        ),
        state,
        exited: false,
        tool: infer_tool(&meta.current_command),
        cwd: meta.current_path,
        replay_text,
        thought: None,
        thought_state: ThoughtState::Holding,
        thought_source: ThoughtSource::CarryForward,
        objective_fingerprint: None,
        thought_updated_at: None,
        token_count: 0,
        context_limit: 0,
        last_activity_at: now,
        rest_state: RestState::Active,
        commit_candidate: false,
    }
}

fn capture_pane_text(tmux_bin: &str, pane_id: &str, max_capture_lines: usize) -> Result<String> {
    let start = capture_start(max_capture_lines);
    let output = Command::new(tmux_bin)
        .args(["capture-pane", "-p", "-t", pane_id, "-S", &start])
        .output()
        .with_context(|| format!("failed to run {tmux_bin} capture-pane for {pane_id}"))?;

    if !output.status.success() {
        return Ok(String::new());
    }

    let stdout =
        String::from_utf8(output.stdout).context("tmux capture-pane output was not UTF-8")?;
    Ok(stdout.trim().to_string())
}

fn capture_start(max_capture_lines: usize) -> String {
    let lines = max_capture_lines.max(1);
    format!("-{}", lines.saturating_sub(1))
}

fn infer_tool(current_command: &str) -> Option<String> {
    let normalized = current_command.trim().to_lowercase();
    ["claude", "codex"]
        .into_iter()
        .find(|tool| normalized.contains(tool))
        .map(|tool| tool.to_string())
}

fn tmux_server_missing(stderr: &str) -> bool {
    let lower = stderr.to_lowercase();
    [
        "no server running",
        "failed to connect to server",
        "no sessions",
    ]
    .iter()
    .any(|fragment| lower.contains(fragment))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_pane_line_decodes_tmux_fields() {
        let line = "work\u{1f}1\u{1f}0\u{1f}%3\u{1f}/tmp/project\u{1f}codex\u{1f}1\u{1f}0";
        let parsed = parse_pane_line(line).expect("pane meta");

        assert_eq!(
            parsed,
            PaneMeta {
                session_name: "work".to_string(),
                window_index: "1".to_string(),
                pane_index: "0".to_string(),
                pane_id: "%3".to_string(),
                current_path: "/tmp/project".to_string(),
                current_command: "codex".to_string(),
                active: true,
                dead: false,
            }
        );
    }

    #[test]
    fn capture_start_keeps_one_line_minimum() {
        assert_eq!(capture_start(0), "-0");
        assert_eq!(capture_start(1), "-0");
        assert_eq!(capture_start(200), "-199");
    }

    #[test]
    fn tmux_server_missing_recognizes_expected_errors() {
        assert!(tmux_server_missing("No server running on /tmp/tmux"));
        assert!(tmux_server_missing("failed to connect to server"));
        assert!(tmux_server_missing("no sessions"));
        assert!(!tmux_server_missing("permission denied"));
    }

    #[test]
    fn infer_tool_matches_supported_agents() {
        assert_eq!(infer_tool("  Claude  ").as_deref(), Some("claude"));
        assert_eq!(
            infer_tool("/usr/bin/codex --json").as_deref(),
            Some("codex")
        );
        assert_eq!(infer_tool("vim"), None);
    }
}