Skip to main content

ao_plugin_agent_cursor/
lib.rs

1//! Cursor Agent CLI plugin.
2//!
3//! Launches the Cursor background agent (`agent`) in permissionless mode
4//! inside a tmux session. Mirrors `packages/plugins/agent-cursor/src/index.ts`
5//! in the TypeScript agent-orchestrator.
6//!
7//! ## Launch strategy
8//!
9//! The TS reference embeds the full prompt in the launch command's positional
10//! argument (`agent -- <prompt>`). This Rust port uses **post-launch delivery**
11//! instead — same as the Claude Code plugin — where the orchestrator runs
12//! `agent` interactively, then sends the task via `Runtime::send_message`.
13//! This keeps the architecture consistent across agent plugins and avoids
14//! shell-escaping multi-kilobyte prompts in tmux commands.
15//!
16//! ## Activity detection
17//!
18//! Cursor doesn't write JSONL session logs like Claude Code. Detection uses:
19//! 1. `.cursor/chat.md` file mtime — if recently modified, agent is active.
20//! 2. Cursor log artifacts (if present) — `.cursor/logs/*` mtime.
21//! 3. Workspace git activity — `.git/index` mtime (captures edits even without commits).
22//! 4. Recent git commits in the workspace — if any within 60s, agent is active.
23//! 5. Fallback: `ActivityState::Ready` (runtime liveness covers process exit).
24//!
25//! ## System prompt
26//!
27//! Cursor has no `--append-system-prompt` equivalent, so agent rules are
28//! delivered by prepending them to the user prompt (see
29//! [`CursorAgent::system_prompt`] and the CLI spawn flow).
30//!
31//! ## Cost tracking
32//!
33//! Cursor stores chat history in per-project SQLite databases under
34//! `~/.cursor/chats/<hash>/<uuid>/store.db` and does not expose token /
35//! cost metadata via its CLI. `cost_estimate` is intentionally left at
36//! the trait default (`None`) — matching the TS reference, which also
37//! reports cost as unsupported.
38
39use ao_core::{ActivityState, Agent, AgentConfig, Result, Session};
40use async_trait::async_trait;
41use std::path::Path;
42
43/// Idle threshold: if `.cursor/chat.md` hasn't been modified in this many
44/// seconds, consider the agent idle. Matches the Claude Code plugin's 5 min.
45const IDLE_THRESHOLD_SECS: u64 = 300;
46
47/// Active window: if `.cursor/chat.md` was modified within this many seconds,
48/// the agent is actively working (not just "ready").
49const ACTIVE_WINDOW_SECS: u64 = 30;
50
51pub struct CursorAgent {
52    /// Rules prepended to the prompt. Cursor doesn't have a system prompt
53    /// flag, so rules are delivered as part of the initial prompt.
54    rules: Option<String>,
55    /// Model override passed via `--model`.
56    model: Option<String>,
57}
58
59impl CursorAgent {
60    pub fn new() -> Self {
61        Self {
62            rules: None,
63            model: None,
64        }
65    }
66
67    /// Create from project agent config.
68    pub fn from_config(config: &AgentConfig) -> Self {
69        let rules = if let Some(ref path) = config.rules_file {
70            match std::fs::read_to_string(path) {
71                Ok(content) => Some(content),
72                Err(e) => {
73                    tracing::warn!("could not read rules file {path}: {e}, using inline rules");
74                    config.rules.clone()
75                }
76            }
77        } else {
78            config.rules.clone()
79        };
80        Self {
81            rules,
82            model: config.model.clone(),
83        }
84    }
85}
86
87impl Default for CursorAgent {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93#[async_trait]
94impl Agent for CursorAgent {
95    fn launch_command(&self, _session: &Session) -> String {
96        // Cursor agent in permissionless mode:
97        //   --force: auto-approve all changes (alias: --yolo)
98        //   --sandbox disabled: skip workspace trust prompts
99        //   --approve-mcps: auto-approve MCP servers
100        let mut cmd = "agent --force --sandbox disabled --approve-mcps".to_string();
101
102        if let Some(ref model) = self.model {
103            // Shell-escape model value for safety (Cursor TS plugin does the same).
104            let escaped = model.replace('\'', "'\\''");
105            cmd.push_str(&format!(" --model '{escaped}'"));
106        }
107
108        cmd
109    }
110
111    fn environment(&self, session: &Session) -> Vec<(String, String)> {
112        vec![
113            ("AO_SESSION_ID".to_string(), session.id.to_string()),
114            // Issue ID for workspace hooks / prompt enrichment.
115            (
116                "AO_ISSUE_ID".to_string(),
117                session.issue_id.clone().unwrap_or_default(),
118            ),
119        ]
120    }
121
122    fn system_prompt(&self) -> Option<String> {
123        // Cursor has no `--append-system-prompt` flag, so rules are
124        // delivered by prepending them to the user prompt. Callers that
125        // build a prompt externally (e.g. `ao-rs spawn`) should prepend
126        // this before sending. Matches the TS plugin's behavior where
127        // `getLaunchCommand` concatenates `systemPrompt + "\n\n" + prompt`.
128        self.rules
129            .as_ref()
130            .map(|r| r.trim())
131            .filter(|r| !r.is_empty())
132            .map(|r| r.to_string())
133    }
134
135    fn initial_prompt(&self, session: &Session) -> String {
136        // NOTE: The CLI spawn flow uses `prompt_builder::build_prompt()` for
137        // richer 3-layer prompts. This is a backward-compat fallback for
138        // callers (dashboard, restore) that send a single composed message.
139        //
140        // Cursor doesn't have --append-system-prompt, so if rules are
141        // configured, prepend them to the task.
142        let task_part = if let Some(ref id) = session.issue_id {
143            let url_line = session
144                .issue_url
145                .as_deref()
146                .map(|u| format!("\nIssue URL: {u}"))
147                .unwrap_or_default();
148            format!(
149                "You are working on issue #{id} on branch `{branch}`.{url_line}\n\n\
150                 Task:\n{task}\n\n\
151                 When complete, push your branch and open a pull request.",
152                branch = session.branch,
153                task = session.task,
154            )
155        } else {
156            session.task.clone()
157        };
158
159        match &self.rules {
160            Some(rules) => format!("{rules}\n\n---\n\n{task_part}"),
161            None => task_part,
162        }
163    }
164
165    async fn detect_activity(&self, session: &Session) -> Result<ActivityState> {
166        let Some(ref ws) = session.workspace_path else {
167            return Ok(ActivityState::Ready);
168        };
169        // File I/O is blocking — run off the executor thread.
170        let ws = ws.clone();
171        tokio::task::spawn_blocking(move || detect_cursor_activity(&ws))
172            .await
173            .map_err(|e| ao_core::AoError::Other(format!("detect_activity panicked: {e}")))?
174    }
175
176    // Cursor doesn't expose token/cost data — use the default (None).
177}
178
179// ---------------------------------------------------------------------------
180// Activity detection
181// ---------------------------------------------------------------------------
182
183/// Determine agent activity by checking Cursor workspace artifacts.
184///
185/// Strategy (multi-fallback, mirrors TS):
186///   1. `.cursor/chat.md` mtime — direct evidence of Cursor writes.
187///   2. Recent git commits (within 60s) — indirect evidence of agent work.
188///   3. Fallback: `Ready` — runtime liveness covers process exit.
189fn detect_cursor_activity(workspace_path: &Path) -> Result<ActivityState> {
190    // 1. Check .cursor/chat.md mtime.
191    if let Some(state) = state_from_mtime(workspace_path.join(".cursor").join("chat.md"))? {
192        return Ok(state);
193    }
194
195    // 2. Check Cursor log artifacts (if any).
196    if let Some(state) = detect_cursor_log_activity(workspace_path)? {
197        return Ok(state);
198    }
199
200    // 3. Check git activity via `.git/index` mtime (captures "work happened" even without commits).
201    if let Some(state) = detect_git_index_activity(workspace_path)? {
202        return Ok(state);
203    }
204
205    // 4. Check for recent git commits.
206    if has_recent_commits(workspace_path) {
207        return Ok(ActivityState::Active);
208    }
209
210    // 5. Fallback — no cursor artifacts, agent may have just started.
211    Ok(ActivityState::Ready)
212}
213
214fn age_to_state(age_secs: u64) -> ActivityState {
215    if age_secs <= ACTIVE_WINDOW_SECS {
216        ActivityState::Active
217    } else if age_secs <= IDLE_THRESHOLD_SECS {
218        ActivityState::Ready
219    } else {
220        ActivityState::Idle
221    }
222}
223
224fn state_from_mtime(path: impl AsRef<Path>) -> Result<Option<ActivityState>> {
225    let path = path.as_ref();
226    let Ok(metadata) = std::fs::metadata(path) else {
227        return Ok(None);
228    };
229    let Ok(modified) = metadata.modified() else {
230        // Platform doesn't support mtime — fall back to Ready rather
231        // than silently reporting Active with a faked timestamp.
232        return Ok(Some(ActivityState::Ready));
233    };
234    let age = std::time::SystemTime::now()
235        .duration_since(modified)
236        .unwrap_or_default()
237        .as_secs();
238    Ok(Some(age_to_state(age)))
239}
240
241fn detect_cursor_log_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
242    let cursor_dir = workspace_path.join(".cursor");
243    let logs_dir = cursor_dir.join("logs");
244    let Ok(entries) = std::fs::read_dir(&logs_dir) else {
245        return Ok(None);
246    };
247
248    let mut newest: Option<std::time::SystemTime> = None;
249    // Bound cost even if logs dir is large.
250    for (i, entry) in entries.flatten().enumerate() {
251        if i >= 200 {
252            break;
253        }
254        let Ok(meta) = entry.metadata() else {
255            continue;
256        };
257        if !meta.is_file() {
258            continue;
259        }
260        let Ok(modified) = meta.modified() else {
261            continue;
262        };
263        newest = Some(match newest {
264            Some(prev) if prev > modified => prev,
265            _ => modified,
266        });
267    }
268
269    let Some(newest) = newest else {
270        return Ok(None);
271    };
272    let age = std::time::SystemTime::now()
273        .duration_since(newest)
274        .unwrap_or_default()
275        .as_secs();
276    Ok(Some(age_to_state(age)))
277}
278
279fn detect_git_index_activity(workspace_path: &Path) -> Result<Option<ActivityState>> {
280    // Fast path: `.git/index` exists directly under worktree.
281    let direct = workspace_path.join(".git").join("index");
282    if let Some(state) = state_from_mtime(&direct)? {
283        return Ok(Some(state));
284    }
285
286    // Worktrees sometimes have `.git` as a file pointing at the real git dir.
287    let output = std::process::Command::new("git")
288        .args(["rev-parse", "--git-dir"])
289        .current_dir(workspace_path)
290        .stdout(std::process::Stdio::piped())
291        .stderr(std::process::Stdio::null())
292        .output();
293    let Ok(o) = output else {
294        return Ok(None);
295    };
296    if !o.status.success() {
297        return Ok(None);
298    }
299    let git_dir = String::from_utf8_lossy(&o.stdout).trim().to_string();
300    if git_dir.is_empty() {
301        return Ok(None);
302    }
303    let git_dir = if Path::new(&git_dir).is_absolute() {
304        std::path::PathBuf::from(git_dir)
305    } else {
306        workspace_path.join(git_dir)
307    };
308    let idx = git_dir.join("index");
309    state_from_mtime(&idx)
310}
311
312/// Check if any git commits were made in the workspace within the last 60s.
313fn has_recent_commits(workspace_path: &Path) -> bool {
314    let output = std::process::Command::new("git")
315        .args(["log", "--since=60 seconds ago", "--format=%H"])
316        .current_dir(workspace_path)
317        .stdout(std::process::Stdio::piped())
318        .stderr(std::process::Stdio::null())
319        .output();
320
321    match output {
322        Ok(o) if o.status.success() => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
323        _ => false,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use ao_core::{now_ms, PermissionsMode, SessionId, SessionStatus};
331    use std::path::PathBuf;
332
333    fn fake_session() -> Session {
334        Session {
335            id: SessionId("cursor-test".into()),
336            project_id: "demo".into(),
337            status: SessionStatus::Working,
338            agent: "cursor".into(),
339            agent_config: None,
340            branch: "ao-abc123-feat-test".into(),
341            task: "fix the bug".into(),
342            workspace_path: Some(PathBuf::from("/tmp/cursor-demo")),
343            runtime_handle: None,
344            runtime: "tmux".into(),
345            activity: None,
346            created_at: now_ms(),
347            cost: None,
348            issue_id: None,
349            issue_url: None,
350            claimed_pr_number: None,
351            claimed_pr_url: None,
352            initial_prompt_override: None,
353            spawned_by: None,
354            last_merge_conflict_dispatched: None,
355            last_review_backlog_fingerprint: None,
356        }
357    }
358
359    #[test]
360    fn launch_command_is_permissionless() {
361        let agent = CursorAgent::new();
362        let cmd = agent.launch_command(&fake_session());
363        assert!(cmd.contains("agent"));
364        assert!(cmd.contains("--force"));
365        assert!(cmd.contains("--sandbox disabled"));
366        assert!(cmd.contains("--approve-mcps"));
367    }
368
369    #[test]
370    fn environment_includes_session_id() {
371        let agent = CursorAgent::new();
372        let env = agent.environment(&fake_session());
373        assert!(env
374            .iter()
375            .any(|(k, v)| k == "AO_SESSION_ID" && v == "cursor-test"));
376    }
377
378    #[test]
379    fn environment_includes_empty_issue_id_when_none() {
380        let agent = CursorAgent::new();
381        let env = agent.environment(&fake_session());
382        assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v.is_empty()));
383    }
384
385    #[test]
386    fn environment_includes_issue_id_when_set() {
387        let agent = CursorAgent::new();
388        let mut session = fake_session();
389        session.issue_id = Some("42".into());
390        let env = agent.environment(&session);
391        assert!(env.iter().any(|(k, v)| k == "AO_ISSUE_ID" && v == "42"));
392    }
393
394    #[test]
395    fn initial_prompt_task_first() {
396        let agent = CursorAgent::new();
397        assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
398    }
399
400    #[test]
401    fn initial_prompt_issue_first() {
402        let agent = CursorAgent::new();
403        let mut session = fake_session();
404        session.issue_id = Some("7".into());
405        session.issue_url = Some("https://github.com/acme/repo/issues/7".into());
406        session.task = "Add dark mode".into();
407
408        let prompt = agent.initial_prompt(&session);
409        assert!(prompt.contains("issue #7"));
410        assert!(prompt.contains("https://github.com/acme/repo/issues/7"));
411        assert!(prompt.contains("Add dark mode"));
412        assert!(prompt.contains("open a pull request"));
413    }
414
415    #[test]
416    fn initial_prompt_with_rules_prepends_rules() {
417        let agent = CursorAgent {
418            rules: Some("Always run tests before committing.".into()),
419            model: None,
420        };
421        let prompt = agent.initial_prompt(&fake_session());
422        assert!(prompt.starts_with("Always run tests"));
423        assert!(prompt.contains("---"));
424        assert!(prompt.contains("fix the bug"));
425    }
426
427    // ---- system_prompt (parity with TS systemPrompt injection) ----
428
429    #[test]
430    fn system_prompt_none_when_no_rules() {
431        let agent = CursorAgent::new();
432        assert!(agent.system_prompt().is_none());
433    }
434
435    #[test]
436    fn system_prompt_returns_rules_when_configured() {
437        let config = AgentConfig {
438            permissions: PermissionsMode::Permissionless,
439            rules: Some("Always run tests before committing.".into()),
440            rules_file: None,
441            model: None,
442            orchestrator_model: None,
443            opencode_session_id: None,
444        };
445        let agent = CursorAgent::from_config(&config);
446        assert_eq!(
447            agent.system_prompt().as_deref(),
448            Some("Always run tests before committing.")
449        );
450    }
451
452    #[test]
453    fn system_prompt_none_when_rules_blank() {
454        // Whitespace-only rules shouldn't round-trip as a system prompt —
455        // matches the TS plugin's `if (config.systemPrompt)` truthy check.
456        let config = AgentConfig {
457            permissions: PermissionsMode::Permissionless,
458            rules: Some("   \n  \t".into()),
459            rules_file: None,
460            model: None,
461            orchestrator_model: None,
462            opencode_session_id: None,
463        };
464        let agent = CursorAgent::from_config(&config);
465        assert!(agent.system_prompt().is_none());
466    }
467
468    // ---- --model flag (parity with TS getLaunchCommand) ----
469
470    #[test]
471    fn launch_command_no_model_flag_by_default() {
472        let agent = CursorAgent::new();
473        let cmd = agent.launch_command(&fake_session());
474        assert!(!cmd.contains("--model"));
475    }
476
477    #[test]
478    fn launch_command_includes_model_when_set() {
479        let config = AgentConfig {
480            permissions: PermissionsMode::Permissionless,
481            rules: None,
482            rules_file: None,
483            model: Some("gpt-4o".into()),
484            orchestrator_model: None,
485            opencode_session_id: None,
486        };
487        let agent = CursorAgent::from_config(&config);
488        let cmd = agent.launch_command(&fake_session());
489        assert!(cmd.contains("--model 'gpt-4o'"));
490    }
491
492    #[test]
493    fn launch_command_model_is_shell_escaped() {
494        let config = AgentConfig {
495            permissions: PermissionsMode::Permissionless,
496            rules: None,
497            rules_file: None,
498            model: Some("it's-a-model".into()),
499            orchestrator_model: None,
500            opencode_session_id: None,
501        };
502        let agent = CursorAgent::from_config(&config);
503        let cmd = agent.launch_command(&fake_session());
504        // Single quotes escape via close-escape-reopen.
505        assert!(cmd.contains(r"--model 'it'\''s-a-model'"));
506    }
507
508    #[test]
509    fn from_config_reads_inline_rules() {
510        let config = AgentConfig {
511            permissions: PermissionsMode::Permissionless,
512            rules: Some("custom cursor rules".into()),
513            rules_file: None,
514            model: None,
515            orchestrator_model: None,
516            opencode_session_id: None,
517        };
518        let agent = CursorAgent::from_config(&config);
519        let prompt = agent.initial_prompt(&fake_session());
520        assert!(prompt.contains("custom cursor rules"));
521    }
522
523    #[test]
524    fn from_config_no_rules() {
525        let config = AgentConfig {
526            permissions: PermissionsMode::Permissionless,
527            rules: None,
528            rules_file: None,
529            model: None,
530            orchestrator_model: None,
531            opencode_session_id: None,
532        };
533        let agent = CursorAgent::from_config(&config);
534        assert_eq!(agent.initial_prompt(&fake_session()), "fix the bug");
535    }
536
537    // ---- activity detection ----
538
539    #[test]
540    fn detect_activity_no_workspace_returns_ready() {
541        let ws = std::env::temp_dir().join("ao-cursor-no-ws");
542        std::fs::create_dir_all(&ws).unwrap();
543
544        // No .cursor dir, no git commits → fallback Ready.
545        let result = detect_cursor_activity(&ws).unwrap();
546        assert_eq!(result, ActivityState::Ready);
547
548        std::fs::remove_dir_all(&ws).ok();
549    }
550
551    #[test]
552    fn detect_activity_fresh_chat_file_returns_active() {
553        let ws = std::env::temp_dir().join("ao-cursor-active-chat");
554        let cursor_dir = ws.join(".cursor");
555        std::fs::create_dir_all(&cursor_dir).unwrap();
556        std::fs::write(cursor_dir.join("chat.md"), "# Session\nHello").unwrap();
557
558        let result = detect_cursor_activity(&ws).unwrap();
559        assert_eq!(result, ActivityState::Active);
560
561        std::fs::remove_dir_all(&ws).ok();
562    }
563
564    #[test]
565    fn detect_activity_falls_back_to_cursor_logs_when_chat_missing() {
566        let ws = std::env::temp_dir().join("ao-cursor-active-logs");
567        let logs_dir = ws.join(".cursor").join("logs");
568        std::fs::create_dir_all(&logs_dir).unwrap();
569        std::fs::write(logs_dir.join("cursor-agent.log"), "hello").unwrap();
570
571        let result = detect_cursor_activity(&ws).unwrap();
572        assert_eq!(result, ActivityState::Active);
573
574        std::fs::remove_dir_all(&ws).ok();
575    }
576
577    #[test]
578    fn detect_activity_falls_back_to_git_index_mtime_when_no_cursor_artifacts() {
579        let ws = std::env::temp_dir().join("ao-cursor-active-git-index");
580        let git_dir = ws.join(".git");
581        std::fs::create_dir_all(&git_dir).unwrap();
582        std::fs::write(git_dir.join("index"), "fake index").unwrap();
583
584        let result = detect_cursor_activity(&ws).unwrap();
585        assert_eq!(result, ActivityState::Active);
586
587        std::fs::remove_dir_all(&ws).ok();
588    }
589}