Skip to main content

winx_code_agent/state/
agent_resume.rs

1//! Durable interactive-agent session records, so a coding-agent TUI piloted
2//! through winx (claude, codex, ...) is not silently lost when the process
3//! that owned its PTY dies (host reboot, guardian kill, embedded-adapter
4//! restart).
5//!
6//! Modeled on herdr's `agent_resume` (<https://github.com/herdrdev/herdr>,
7//! Apache-2.0): persist *what was launched where*, and rebuild a resume
8//! command after the loss. winx keeps it deliberately simpler than herdr — it
9//! records the launch command it executed itself instead of inspecting
10//! process trees, and resumes by conversation-continuation flags (`claude
11//! --continue`, `codex resume --last`) instead of persisted session ids.
12//!
13//! Lifecycle: every foreground `Command` action either overwrites the record
14//! (the command launches a known agent) or clears it (it doesn't) — the main
15//! PTY runs one foreground program at a time, so the latest record is the
16//! only one that can still matter. Records survive until superseded; a stale
17//! record after a clean agent exit only makes the recovery hint say the agent
18//! "was running", which continuation-style resume commands tolerate.
19
20use serde::{Deserialize, Serialize};
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result};
24
25use super::persistence::get_state_dir;
26
27/// What was running in the foreground PTY when the record was written.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct AgentSessionRecord {
30    /// Canonical agent id — matches the turn-manifest ids (`claude`, `codex`,
31    /// `gemini`, ...), so the same string works as a `wait_for_turn`
32    /// recognizer hint after resuming.
33    pub agent: String,
34    /// The exact command line that launched it.
35    pub command: String,
36    /// Working directory at launch (resume must happen in the same project).
37    pub cwd: String,
38    pub launched_at_unix_ms: u64,
39}
40
41impl AgentSessionRecord {
42    /// The command that resumes (or best-effort relaunches) this agent's
43    /// conversation. Agents with a documented continuation flag get it;
44    /// everything else is relaunched with its original command.
45    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
54/// Canonical agent id when `command` launches a known interactive coding
55/// agent in the foreground, `None` otherwise.
56///
57/// Heuristic on the command string winx itself executes (there is no process
58/// tree to inspect at recovery time): take the last `&&`/`;` segment (so
59/// `cd x && claude` counts), refuse pipelines (a piped agent is not an
60/// interactive TUI), skip leading `VAR=value` assignments, and match the
61/// basename of the first real token against the known launcher set.
62pub 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
83/// Launcher basenames for the interactive agents winx knows how to recognize
84/// (the same set the turn manifests cover), normalized to manifest ids.
85fn 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        // The last segment is what owns the PTY foreground.
188        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        // Clearing an absent record is idempotent.
220        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}