winx_code_agent/state/
agent_resume.rs1use serde::{Deserialize, Serialize};
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result};
24
25use super::persistence::get_state_dir;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct AgentSessionRecord {
30 pub agent: String,
34 pub command: String,
36 pub cwd: String,
38 pub launched_at_unix_ms: u64,
39}
40
41impl AgentSessionRecord {
42 pub fn resume_command(&self) -> String {
46 match self.agent.as_str() {
47 "claude" => "claude --continue".to_string(),
48 "codex" => "codex resume --last".to_string(),
49 _ => self.command.clone(),
50 }
51 }
52}
53
54pub fn detect_agent_launch(command: &str) -> Option<&'static str> {
63 let segment = command.split("&&").last()?.split(';').next_back()?.trim();
64 if segment.contains('|') {
65 return None;
66 }
67 let token = segment.split_whitespace().find(|token| !is_env_assignment(token))?;
68 agent_for_basename(basename(token))
69}
70
71fn is_env_assignment(token: &str) -> bool {
72 token.split_once('=').is_some_and(|(name, _)| {
73 !name.is_empty()
74 && name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
75 && !name.starts_with(|ch: char| ch.is_ascii_digit())
76 })
77}
78
79fn basename(token: &str) -> &str {
80 token.rsplit('/').next().unwrap_or(token)
81}
82
83fn agent_for_basename(name: &str) -> Option<&'static str> {
86 match name {
87 "claude" | "claude-code" => Some("claude"),
88 "codex" => Some("codex"),
89 "gemini" => Some("gemini"),
90 "agy" | "antigravity" => Some("agy"),
91 "cursor-agent" | "cursor" => Some("cursor"),
92 "opencode" | "opencode2" => Some("opencode"),
93 "copilot" | "ghcs" => Some("copilot"),
94 "kimi" => Some("kimi"),
95 "kiro" | "kiro-cli" => Some("kiro"),
96 "droid" => Some("droid"),
97 "amp" => Some("amp"),
98 "grok" => Some("grok"),
99 "hermes" => Some("hermes"),
100 "kilo" => Some("kilo"),
101 "qodercli" => Some("qodercli"),
102 "qwen" => Some("qwen"),
103 "pi" => Some("pi"),
104 "devin" | "devin-cli" => Some("devin"),
105 "cline" => Some("cline"),
106 "maki" => Some("maki"),
107 "muse" | "muse-code" | "muse-cli" => Some("muse"),
108 _ => None,
109 }
110}
111
112fn record_path(thread_id: &str) -> Result<PathBuf> {
113 Ok(get_state_dir()?.join(format!("{thread_id}_agent_session.json")))
114}
115
116pub fn save_agent_session(thread_id: &str, record: &AgentSessionRecord) -> Result<()> {
117 save_agent_session_to_path(&record_path(thread_id)?, record)
118}
119
120pub fn save_agent_session_to_path(path: &Path, record: &AgentSessionRecord) -> Result<()> {
121 let json = serde_json::to_string(record).context("failed to serialize agent session")?;
122 std::fs::write(path, json)
123 .with_context(|| format!("failed to write agent session to {}", path.display()))
124}
125
126pub fn load_agent_session(thread_id: &str) -> Result<Option<AgentSessionRecord>> {
127 load_agent_session_from_path(&record_path(thread_id)?)
128}
129
130pub fn load_agent_session_from_path(path: &Path) -> Result<Option<AgentSessionRecord>> {
131 if !path.exists() {
132 return Ok(None);
133 }
134 let json = std::fs::read_to_string(path)
135 .with_context(|| format!("failed to read agent session from {}", path.display()))?;
136 Ok(serde_json::from_str(&json).ok())
137}
138
139pub fn clear_agent_session(thread_id: &str) -> Result<()> {
140 clear_agent_session_at_path(&record_path(thread_id)?)
141}
142
143pub fn clear_agent_session_at_path(path: &Path) -> Result<()> {
144 match std::fs::remove_file(path) {
145 Ok(()) => Ok(()),
146 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
147 Err(error) => {
148 Err(error).with_context(|| format!("failed to clear agent session {}", path.display()))
149 }
150 }
151}
152
153pub fn now_unix_ms() -> u64 {
154 std::time::SystemTime::now()
155 .duration_since(std::time::UNIX_EPOCH)
156 .map_or(0, |duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
157}
158
159#[cfg(test)]
160#[allow(clippy::expect_used)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn detects_direct_and_wrapped_agent_launches() {
166 assert_eq!(detect_agent_launch("claude"), Some("claude"));
167 assert_eq!(detect_agent_launch("claude --dangerously-skip-permissions"), Some("claude"));
168 assert_eq!(detect_agent_launch("cd ~/repo && claude"), Some("claude"));
169 assert_eq!(detect_agent_launch("FOO=bar codex --model gpt-5"), Some("codex"));
170 assert_eq!(detect_agent_launch("/usr/local/bin/gemini"), Some("gemini"));
171 assert_eq!(detect_agent_launch("antigravity"), Some("agy"));
172 assert_eq!(detect_agent_launch("cursor-agent"), Some("cursor"));
173 }
174
175 #[test]
176 fn refuses_non_agents_pipelines_and_backgrounded_noise() {
177 assert_eq!(detect_agent_launch("ls -la"), None);
178 assert_eq!(detect_agent_launch("claude | tee log"), None);
179 assert_eq!(detect_agent_launch("echo claude"), None);
180 assert_eq!(detect_agent_launch("claudette"), None);
181 assert_eq!(detect_agent_launch("FOO=claude env"), None);
182 assert_eq!(detect_agent_launch(""), None);
183 }
184
185 #[test]
186 fn compound_commands_use_the_last_segment() {
187 assert_eq!(detect_agent_launch("claude; ls"), None);
189 assert_eq!(detect_agent_launch("git pull && codex"), Some("codex"));
190 }
191
192 #[test]
193 fn resume_commands_prefer_continuation_flags() {
194 let record = |agent: &str, command: &str| AgentSessionRecord {
195 agent: agent.to_string(),
196 command: command.to_string(),
197 cwd: "/repo".to_string(),
198 launched_at_unix_ms: 0,
199 };
200 assert_eq!(record("claude", "claude --verbose").resume_command(), "claude --continue");
201 assert_eq!(record("codex", "codex").resume_command(), "codex resume --last");
202 assert_eq!(record("grok", "grok --fast").resume_command(), "grok --fast");
203 }
204
205 #[test]
206 fn record_roundtrip_and_clear() {
207 let dir = tempfile::tempdir().expect("temp dir");
208 let path = dir.path().join("t1_agent_session.json");
209 let record = AgentSessionRecord {
210 agent: "claude".to_string(),
211 command: "claude".to_string(),
212 cwd: "/repo".to_string(),
213 launched_at_unix_ms: 42,
214 };
215 save_agent_session_to_path(&path, &record).expect("save");
216 assert_eq!(load_agent_session_from_path(&path).expect("load"), Some(record));
217 clear_agent_session_at_path(&path).expect("clear");
218 assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
219 clear_agent_session_at_path(&path).expect("clear absent");
221 }
222
223 #[test]
224 fn corrupt_record_reads_as_absent() {
225 let dir = tempfile::tempdir().expect("temp dir");
226 let path = dir.path().join("t2_agent_session.json");
227 std::fs::write(&path, "not json").expect("write");
228 assert_eq!(load_agent_session_from_path(&path).expect("load"), None);
229 }
230}