use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::persistence::get_state_dir;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionRecord {
pub agent: String,
pub command: String,
pub cwd: String,
pub launched_at_unix_ms: u64,
}
impl AgentSessionRecord {
pub fn resume_command(&self) -> String {
match self.agent.as_str() {
"claude" => "claude --continue".to_string(),
"codex" => "codex resume --last".to_string(),
_ => self.command.clone(),
}
}
}
pub fn detect_agent_launch(command: &str) -> Option<&'static str> {
let segment = command.split("&&").last()?.split(';').next_back()?.trim();
if segment.contains('|') {
return None;
}
let token = segment.split_whitespace().find(|token| !is_env_assignment(token))?;
agent_for_basename(basename(token))
}
fn is_env_assignment(token: &str) -> bool {
token.split_once('=').is_some_and(|(name, _)| {
!name.is_empty()
&& name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
&& !name.starts_with(|ch: char| ch.is_ascii_digit())
})
}
fn basename(token: &str) -> &str {
token.rsplit('/').next().unwrap_or(token)
}
fn agent_for_basename(name: &str) -> Option<&'static str> {
match name {
"claude" | "claude-code" => Some("claude"),
"codex" => Some("codex"),
"gemini" => Some("gemini"),
"agy" | "antigravity" => Some("agy"),
"cursor-agent" | "cursor" => Some("cursor"),
"opencode" | "opencode2" => Some("opencode"),
"copilot" | "ghcs" => Some("copilot"),
"kimi" => Some("kimi"),
"kiro" | "kiro-cli" => Some("kiro"),
"droid" => Some("droid"),
"amp" => Some("amp"),
"grok" => Some("grok"),
"hermes" => Some("hermes"),
"kilo" => Some("kilo"),
"qodercli" => Some("qodercli"),
"qwen" => Some("qwen"),
"pi" => Some("pi"),
"devin" | "devin-cli" => Some("devin"),
"cline" => Some("cline"),
"maki" => Some("maki"),
"muse" | "muse-code" | "muse-cli" => Some("muse"),
_ => None,
}
}
fn record_path(thread_id: &str) -> Result<PathBuf> {
Ok(get_state_dir()?.join(format!("{thread_id}_agent_session.json")))
}
pub fn save_agent_session(thread_id: &str, record: &AgentSessionRecord) -> Result<()> {
save_agent_session_to_path(&record_path(thread_id)?, record)
}
pub fn save_agent_session_to_path(path: &Path, record: &AgentSessionRecord) -> Result<()> {
let json = serde_json::to_string(record).context("failed to serialize agent session")?;
std::fs::write(path, json)
.with_context(|| format!("failed to write agent session to {}", path.display()))
}
pub fn load_agent_session(thread_id: &str) -> Result<Option<AgentSessionRecord>> {
load_agent_session_from_path(&record_path(thread_id)?)
}
pub fn load_agent_session_from_path(path: &Path) -> Result<Option<AgentSessionRecord>> {
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent session from {}", path.display()))?;
Ok(serde_json::from_str(&json).ok())
}
pub fn clear_agent_session(thread_id: &str) -> Result<()> {
clear_agent_session_at_path(&record_path(thread_id)?)
}
pub fn clear_agent_session_at_path(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => {
Err(error).with_context(|| format!("failed to clear agent session {}", path.display()))
}
}
}
pub fn now_unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn detects_direct_and_wrapped_agent_launches() {
assert_eq!(detect_agent_launch("claude"), Some("claude"));
assert_eq!(detect_agent_launch("claude --dangerously-skip-permissions"), Some("claude"));
assert_eq!(detect_agent_launch("cd ~/repo && claude"), Some("claude"));
assert_eq!(detect_agent_launch("FOO=bar codex --model gpt-5"), Some("codex"));
assert_eq!(detect_agent_launch("/usr/local/bin/gemini"), Some("gemini"));
assert_eq!(detect_agent_launch("antigravity"), Some("agy"));
assert_eq!(detect_agent_launch("cursor-agent"), Some("cursor"));
}
#[test]
fn refuses_non_agents_pipelines_and_backgrounded_noise() {
assert_eq!(detect_agent_launch("ls -la"), None);
assert_eq!(detect_agent_launch("claude | tee log"), None);
assert_eq!(detect_agent_launch("echo claude"), None);
assert_eq!(detect_agent_launch("claudette"), None);
assert_eq!(detect_agent_launch("FOO=claude env"), None);
assert_eq!(detect_agent_launch(""), None);
}
#[test]
fn compound_commands_use_the_last_segment() {
assert_eq!(detect_agent_launch("claude; ls"), None);
assert_eq!(detect_agent_launch("git pull && codex"), Some("codex"));
}
#[test]
fn resume_commands_prefer_continuation_flags() {
let record = |agent: &str, command: &str| AgentSessionRecord {
agent: agent.to_string(),
command: command.to_string(),
cwd: "/repo".to_string(),
launched_at_unix_ms: 0,
};
assert_eq!(record("claude", "claude --verbose").resume_command(), "claude --continue");
assert_eq!(record("codex", "codex").resume_command(), "codex resume --last");
assert_eq!(record("grok", "grok --fast").resume_command(), "grok --fast");
}
#[test]
fn record_roundtrip_and_clear() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("t1_agent_session.json");
let record = AgentSessionRecord {
agent: "claude".to_string(),
command: "claude".to_string(),
cwd: "/repo".to_string(),
launched_at_unix_ms: 42,
};
save_agent_session_to_path(&path, &record).expect("save");
assert_eq!(load_agent_session_from_path(&path).expect("load"), Some(record));
clear_agent_session_at_path(&path).expect("clear");
assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
clear_agent_session_at_path(&path).expect("clear absent");
}
#[test]
fn corrupt_record_reads_as_absent() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("t2_agent_session.json");
std::fs::write(&path, "not json").expect("write");
assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
}
}