use ao_core::{ActivityState, Agent, AgentConfig, Result, Session};
use async_trait::async_trait;
use std::path::Path;
const IDLE_THRESHOLD_SECS: u64 = 300;
const ACTIVE_WINDOW_SECS: u64 = 30;
pub struct CursorAgent {
rules: Option<String>,
model: Option<String>,
}
impl CursorAgent {
pub fn new() -> Self {
Self {
rules: None,
model: None,
}
}
pub fn from_config(config: &AgentConfig) -> Self {
let rules = if let Some(ref path) = config.rules_file {
match std::fs::read_to_string(path) {
Ok(content) => Some(content),
Err(e) => {
tracing::warn!("could not read rules file {path}: {e}, using inline rules");
config.rules.clone()
}
}
} else {
config.rules.clone()
};
Self {
rules,
model: config.model.clone(),
}
}
}
impl Default for CursorAgent {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Agent for CursorAgent {
fn launch_command(&self, _session: &Session) -> String {
let mut cmd = "agent --force --sandbox disabled --approve-mcps".to_string();
if let Some(ref model) = self.model {
let escaped = model.replace('\'', "'\\''");
cmd.push_str(&format!(" --model '{escaped}'"));
}
cmd
}
fn environment(&self, session: &Session) -> Vec<(String, String)> {
vec![
("AO_SESSION_ID".to_string(), session.id.to_string()),
(
"AO_ISSUE_ID".to_string(),
session.issue_id.clone().unwrap_or_default(),
),
]
}
fn system_prompt(&self) -> Option<String> {
self.rules
.as_ref()
.map(|r| r.trim())
.filter(|r| !r.is_empty())
.map(|r| r.to_string())
}
fn initial_prompt(&self, session: &Session) -> String {
let task_part = if let Some(ref id) = session.issue_id {
let url_line = session
.issue_url
.as_deref()
.map(|u| format!("\nIssue URL: {u}"))
.unwrap_or_default();
format!(
"You are working on issue #{id} on branch `{branch}`.{url_line}\n\n\
Task:\n{task}\n\n\
When complete, push your branch and open a pull request.",
branch = session.branch,
task = session.task,
)
} else {
session.task.clone()
};
match &self.rules {
Some(rules) => format!("{rules}\n\n---\n\n{task_part}"),
None => task_part,
}
}
async fn detect_activity(&self, session: &Session) -> Result<ActivityState> {
let Some(ref ws) = session.workspace_path else {
return Ok(ActivityState::Ready);
};
let ws = ws.clone();
tokio::task::spawn_blocking(move || detect_cursor_activity(&ws))
.await
.map_err(|e| ao_core::AoError::Other(format!("detect_activity panicked: {e}")))?
}
}
fn detect_cursor_activity(workspace_path: &Path) -> Result<ActivityState> {
if let Some(state) = state_from_mtime(workspace_path.join(".cursor").join("chat.md"))? {
return Ok(state);
}
if let Some(state) = detect_cursor_log_activity(workspace_path)? {
return Ok(state);
}
if let Some(state) = detect_git_index_activity(workspace_path)? {
return Ok(state);
}
if has_recent_commits(workspace_path) {
return Ok(ActivityState::Active);
}
Ok(ActivityState::Ready)
}
fn age_to_state(age_secs: u64) -> ActivityState {
if age_secs <= ACTIVE_WINDOW_SECS {
ActivityState::Active
} else if age_secs <= IDLE_THRESHOLD_SECS {
ActivityState::Ready
} else {
ActivityState::Idle
}
}
fn state_from_mtime(path: impl AsRef<Path>) -> Result<Option<ActivityState>> {
let path = path.as_ref();
let Ok(metadata) = std::fs::metadata(path) else {
return Ok(None);
};
let Ok(modified) = metadata.modified() else {
return Ok(Some(ActivityState::Ready));
};
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or_default()
.as_secs();
Ok(Some(age_to_state(age)))
}
fn detect_cursor_log_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
let cursor_dir = workspace_path.join(".cursor");
let logs_dir = cursor_dir.join("logs");
let Ok(entries) = std::fs::read_dir(&logs_dir) else {
return Ok(None);
};
let mut newest: Option<std::time::SystemTime> = None;
for (i, entry) in entries.flatten().enumerate() {
if i >= 200 {
break;
}
let Ok(meta) = entry.metadata() else {
continue;
};
if !meta.is_file() {
continue;
}
let Ok(modified) = meta.modified() else {
continue;
};
newest = Some(match newest {
Some(prev) if prev > modified => prev,
_ => modified,
});
}
let Some(newest) = newest else {
return Ok(None);
};
let age = std::time::SystemTime::now()
.duration_since(newest)
.unwrap_or_default()
.as_secs();
Ok(Some(age_to_state(age)))
}
fn detect_git_index_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
let direct = workspace_path.join(".git").join("index");
if let Some(state) = state_from_mtime(&direct)? {
return Ok(Some(state));
}
let output = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(workspace_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
let Ok(o) = output else {
return Ok(None);
};
if !o.status.success() {
return Ok(None);
}
let git_dir = String::from_utf8_lossy(&o.stdout).trim().to_string();
if git_dir.is_empty() {
return Ok(None);
}
let git_dir = if Path::new(&git_dir).is_absolute() {
std::path::PathBuf::from(git_dir)
} else {
workspace_path.join(git_dir)
};
let idx = git_dir.join("index");
state_from_mtime(&idx)
}
fn has_recent_commits(workspace_path: &Path) -> bool {
let output = std::process::Command::new("git")
.args(["log", "--since=60 seconds ago", "--format=%H"])
.current_dir(workspace_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match output {
Ok(o) if o.status.success() => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ao_core::{now_ms, PermissionsMode, SessionId, SessionStatus};
use std::path::PathBuf;
fn fake_session() -> Session {
Session {
id: SessionId("cursor-test".into()),
project_id: "demo".into(),
status: SessionStatus::Working,
agent: "cursor".into(),
agent_config: None,
branch: "ao-abc123-feat-test".into(),
task: "fix the bug".into(),
workspace_path: Some(PathBuf::from("/tmp/cursor-demo")),
runtime_handle: None,
runtime: "tmux".into(),
activity: None,
created_at: now_ms(),
cost: None,
issue_id: None,
issue_url: None,
claimed_pr_number: None,
claimed_pr_url: None,
initial_prompt_override: None,
spawned_by: None,
last_merge_conflict_dispatched: None,
last_review_backlog_fingerprint: None,
}
}
#[test]
fn launch_command_is_permissionless() {
let agent = CursorAgent::new();
let cmd = agent.launch_command(&fake_session());
assert!(cmd.contains("agent"));
assert!(cmd.contains("--force"));
assert!(cmd.contains("--sandbox disabled"));
assert!(cmd.contains("--approve-mcps"));
}
#[test]
fn environment_includes_session_id() {
let agent = CursorAgent::new();
let env = agent.environment(&fake_session());
assert!(env
.iter()
.any(|(k, v)| k == "AO_SESSION_ID" && v == "cursor-test"));
}
#[test]
fn environment_includes_empty_issue_id_when_none() {
let agent = CursorAgent::new();
let env = agent.environment(&fake_session());
assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v.is_empty()));
}
#[test]
fn environment_includes_issue_id_when_set() {
let agent = CursorAgent::new();
let mut session = fake_session();
session.issue_id = Some("42".into());
let env = agent.environment(&session);
assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v == "42"));
}
#[test]
fn initial_prompt_task_first() {
let agent = CursorAgent::new();
assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
}
#[test]
fn initial_prompt_issue_first() {
let agent = CursorAgent::new();
let mut session = fake_session();
session.issue_id = Some("7".into());
session.issue_url = Some("https://github.com/acme/repo/issues/7".into());
session.task = "Add dark mode".into();
let prompt = agent.initial_prompt(&session);
assert!(prompt.contains("issue #7"));
assert!(prompt.contains("https://github.com/acme/repo/issues/7"));
assert!(prompt.contains("Add dark mode"));
assert!(prompt.contains("open a pull request"));
}
#[test]
fn initial_prompt_with_rules_prepends_rules() {
let agent = CursorAgent {
rules: Some("Always run tests before committing.".into()),
model: None,
};
let prompt = agent.initial_prompt(&fake_session());
assert!(prompt.starts_with("Always run tests"));
assert!(prompt.contains("---"));
assert!(prompt.contains("fix the bug"));
}
#[test]
fn system_prompt_none_when_no_rules() {
let agent = CursorAgent::new();
assert!(agent.system_prompt().is_none());
}
#[test]
fn system_prompt_returns_rules_when_configured() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: Some("Always run tests before committing.".into()),
rules_file: None,
model: None,
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
assert_eq!(
agent.system_prompt().as_deref(),
Some("Always run tests before committing.")
);
}
#[test]
fn system_prompt_none_when_rules_blank() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: Some(" \n \t".into()),
rules_file: None,
model: None,
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
assert!(agent.system_prompt().is_none());
}
#[test]
fn launch_command_no_model_flag_by_default() {
let agent = CursorAgent::new();
let cmd = agent.launch_command(&fake_session());
assert!(!cmd.contains("--model"));
}
#[test]
fn launch_command_includes_model_when_set() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: None,
rules_file: None,
model: Some("gpt-4o".into()),
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
let cmd = agent.launch_command(&fake_session());
assert!(cmd.contains("--model 'gpt-4o'"));
}
#[test]
fn launch_command_model_is_shell_escaped() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: None,
rules_file: None,
model: Some("it's-a-model".into()),
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
let cmd = agent.launch_command(&fake_session());
assert!(cmd.contains(r"--model 'it'\''s-a-model'"));
}
#[test]
fn from_config_reads_inline_rules() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: Some("custom cursor rules".into()),
rules_file: None,
model: None,
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
let prompt = agent.initial_prompt(&fake_session());
assert!(prompt.contains("custom cursor rules"));
}
#[test]
fn from_config_no_rules() {
let config = AgentConfig {
permissions: PermissionsMode::Permissionless,
rules: None,
rules_file: None,
model: None,
orchestrator_model: None,
opencode_session_id: None,
};
let agent = CursorAgent::from_config(&config);
assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
}
#[test]
fn detect_activity_no_workspace_returns_ready() {
let ws = std::env::temp_dir().join("ao-cursor-no-ws");
std::fs::create_dir_all(&ws).unwrap();
let result = detect_cursor_activity(&ws).unwrap();
assert_eq!(result, ActivityState::Ready);
std::fs::remove_dir_all(&ws).ok();
}
#[test]
fn detect_activity_fresh_chat_file_returns_active() {
let ws = std::env::temp_dir().join("ao-cursor-active-chat");
let cursor_dir = ws.join(".cursor");
std::fs::create_dir_all(&cursor_dir).unwrap();
std::fs::write(cursor_dir.join("chat.md"), "# Session\nHello").unwrap();
let result = detect_cursor_activity(&ws).unwrap();
assert_eq!(result, ActivityState::Active);
std::fs::remove_dir_all(&ws).ok();
}
#[test]
fn detect_activity_falls_back_to_cursor_logs_when_chat_missing() {
let ws = std::env::temp_dir().join("ao-cursor-active-logs");
let logs_dir = ws.join(".cursor").join("logs");
std::fs::create_dir_all(&logs_dir).unwrap();
std::fs::write(logs_dir.join("cursor-agent.log"), "hello").unwrap();
let result = detect_cursor_activity(&ws).unwrap();
assert_eq!(result, ActivityState::Active);
std::fs::remove_dir_all(&ws).ok();
}
#[test]
fn detect_activity_falls_back_to_git_index_mtime_when_no_cursor_artifacts() {
let ws = std::env::temp_dir().join("ao-cursor-active-git-index");
let git_dir = ws.join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("index"), "fake index").unwrap();
let result = detect_cursor_activity(&ws).unwrap();
assert_eq!(result, ActivityState::Active);
std::fs::remove_dir_all(&ws).ok();
}
}