diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-13 18:36:47 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 17:36:47 +0200 |
| commit | fc1cb77f0572948552377b23f7754a3016ac7dfa (patch) | |
| tree | 5412b796aaaa00894fe628b2ffed8d747e6f452d /src/detect | |
| parent | 3be35231497f33f38aeffd84df02642479995bc6 (diff) | |
feat: add Letta Code detection and restore (#3107)
refs #3106
Co-authored-by: Cameron <cameron@pfiffer.org>
Diffstat (limited to 'src/detect')
| -rw-r--r-- | src/detect/manifest.rs | 1 | ||||
| -rw-r--r-- | src/detect/manifests/letta.toml | 95 | ||||
| -rw-r--r-- | src/detect/mod.rs | 309 |
3 files changed, 400 insertions, 5 deletions
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 4ef77d2a..1ca53228 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -251,6 +251,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("kilo", include_str!("manifests/kilo.toml")), ("kimi", include_str!("manifests/kimi.toml")), ("kiro", include_str!("manifests/kiro.toml")), + ("letta", include_str!("manifests/letta.toml")), ("maki", include_str!("manifests/maki.toml")), ("muse", include_str!("manifests/muse.toml")), ("opencode", include_str!("manifests/opencode.toml")), diff --git a/src/detect/manifests/letta.toml b/src/detect/manifests/letta.toml new file mode 100644 index 00000000..e27e2aa7 --- /dev/null +++ b/src/detect/manifests/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 7326ec32..0c773f0e 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -62,12 +62,13 @@ pub enum Agent { Kilo, Qodercli, Qwen, + Letta, Maki, Muse, } impl Agent { - pub const ALL: [Self; 23] = [ + pub const ALL: [Self; 24] = [ Self::Pi, Self::Claude, Self::Codex, @@ -89,11 +90,12 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, Self::Muse, ]; - pub const SCREEN_MANIFEST_AGENTS: [Self; 21] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 22] = [ Self::Pi, Self::Claude, Self::Codex, @@ -113,6 +115,7 @@ impl Agent { Self::Kilo, Self::Qodercli, Self::Qwen, + Self::Letta, Self::Maki, Self::Muse, ]; @@ -141,6 +144,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", Agent::Muse => "muse", } @@ -175,6 +179,7 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", Agent::Qwen => "qwen", + Agent::Letta => "letta", Agent::Maki => "maki", Agent::Muse => "muse", } @@ -214,6 +219,7 @@ fn lookup_agent(name: &str) -> Option<Agent> { "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), + "letta" | "letta-code" | "letta code" => Some(Agent::Letta), "maki" => Some(Agent::Maki), "muse" | "muse-code" | "muse-cli" => Some(Agent::Muse), _ if is_muse_versioned_binary(name) => Some(Agent::Muse), @@ -248,7 +254,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag { let candidate = normalized_process_name(process); if let Some(agent) = identify_agent(&candidate) { - return Some((agent, candidate)); + if agent != Agent::Letta || is_interactive_letta_process(process) { + return Some((agent, candidate)); + } } } @@ -259,6 +267,9 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let Some(agent) = identify_agent(&candidate) else { continue; }; + if agent == Agent::Letta && !is_interactive_letta_process(process) { + continue; + } let score = process_priority(process, &candidate); match &best { @@ -328,7 +339,10 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { matches!( (source, agent_label), - ("herdr:hermes", "hermes") | ("herdr:qwen", "qwen") | ("herdr:antigravity_cli", "agy") + ("herdr:hermes", "hermes") + | ("herdr:qwen", "qwen") + | ("herdr:letta", "letta") + | ("herdr:antigravity_cli", "agy") ) } @@ -380,7 +394,7 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri { if matches!( identify_agent(&wrapped_agent), - Some(Agent::Qwen | Agent::Cline) + Some(Agent::Qwen | Agent::Cline | Agent::Letta) ) { return wrapped_agent; } @@ -657,10 +671,121 @@ fn agent_name_from_known_package_path(path: &str) -> Option<String> { if window == ["node_modules", "mastracode", "dist", "cli"] { return Some(agent_label(Agent::Mastracode).to_string()); } + if window == ["node_modules", "@letta-ai", "letta-code", "letta"] { + return Some(agent_label(Agent::Letta).to_string()); + } + } + None +} + +fn letta_entrypoint_index(argv: &[String]) -> Option<usize> { + let is_letta = + |arg: &str| agent_name_from_path_token(arg).as_deref() == Some(agent_label(Agent::Letta)); + if argv.first().is_some_and(|arg| is_letta(arg)) { + return Some(0); + } + + let runtime = argv + .first() + .map(|arg| normalized_agent_lookup_name(path_basename(arg)))?; + if !matches!(runtime.as_str(), "node" | "bun") { + return None; + } + + let mut index = 1; + while let Some(arg) = argv.get(index) { + if arg == "--" { + return argv + .get(index + 1) + .is_some_and(|arg| is_letta(arg)) + .then_some(index + 1); + } + if flag_matches(arg, &["-e", "--eval", "-p", "--print"]) { + return None; + } + if arg.starts_with('-') { + index += if option_takes_value(arg) { 2 } else { 1 }; + continue; + } + return is_letta(arg).then_some(index); } None } +fn letta_first_arg_after_backend_selection(args: &[String]) -> Option<&str> { + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--backend" { + let _ = args.next(); + continue; + } + if arg.starts_with("--backend=") { + continue; + } + return Some(arg); + } + None +} + +fn is_interactive_letta_process(process: &crate::platform::ForegroundProcess) -> bool { + let parsed_cmdline; + let argv = if let Some(argv) = process.argv.as_deref() { + argv + } else { + parsed_cmdline = process + .cmdline + .as_deref() + .unwrap_or_default() + .split_whitespace() + .map(|arg| arg.trim_matches(|ch| matches!(ch, '\'' | '"')).to_string()) + .collect::<Vec<_>>(); + if parsed_cmdline.is_empty() { + return true; + } + &parsed_cmdline + }; + + let cli_args = letta_entrypoint_index(argv) + .map(|index| &argv[index + 1..]) + .unwrap_or(argv); + + if cli_args.iter().any(|arg| { + let option = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); + matches!( + option, + "-p" | "--print" + | "--prompt" + | "--json" + | "--stream-json" + | "--run" + | "--disable-memory-guard" + | "--output-format" + | "--input-format" + | "--include-partial-messages" + | "--from-agent" + | "--environment" + | "--env" + | "--pre-load-skills" + | "--tags" + | "--ephemeral" + | "--stateless" + | "--max-turns" + | "--memfs-startup" + | "-h" + | "--help" + | "-v" + | "--version" + | "--info" + | "--update" + | "--upgrade" + ) + }) { + return false; + } + + letta_first_arg_after_backend_selection(cli_args).is_none_or(|arg| arg.starts_with('-')) +} + fn resolved_agent_name_from_path_token(token: &str) -> Option<String> { let path = std::path::Path::new(token); if path.components().count() < 2 { @@ -813,6 +938,8 @@ mod tests { assert_eq!(identify_agent("kilo-code"), Some(Agent::Kilo)); assert_eq!(identify_agent("qwen"), Some(Agent::Qwen)); assert_eq!(identify_agent("Qwen Code"), Some(Agent::Qwen)); + assert_eq!(identify_agent("letta"), Some(Agent::Letta)); + assert_eq!(identify_agent("Letta Code"), Some(Agent::Letta)); assert_eq!(identify_agent("maki"), Some(Agent::Maki)); assert_eq!(identify_agent("muse"), Some(Agent::Muse)); assert_eq!(identify_agent("muse-code"), Some(Agent::Muse)); @@ -852,6 +979,7 @@ mod tests { assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes)); assert_eq!(parse_agent_label("qwen-code"), Some(Agent::Qwen)); + assert_eq!(parse_agent_label("letta-code"), Some(Agent::Letta)); assert_eq!(parse_agent_label("maki"), Some(Agent::Maki)); assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo)); } @@ -896,6 +1024,7 @@ mod tests { (Agent::Kilo, "kilo"), (Agent::Qodercli, "qodercli"), (Agent::Qwen, "qwen"), + (Agent::Letta, "letta"), (Agent::Maki, "maki"), (Agent::Muse, "muse"), ]; @@ -927,6 +1056,7 @@ mod tests { for (source, label, agent) in [ ("herdr:hermes", "hermes", Agent::Hermes), ("herdr:qwen", "qwen", Agent::Qwen), + ("herdr:letta", "letta", Agent::Letta), ("herdr:antigravity_cli", "agy", Agent::Antigravity), ] { assert!(!full_lifecycle_hook_authority(source, label)); @@ -1075,6 +1205,175 @@ mod tests { } #[test] + fn identify_agent_in_job_detects_interactive_letta_entrypoints() { + for argv in [ + vec!["letta", "--backend", "local"], + vec![ + "node", + "/home/user/project/node_modules/.bin/letta", + "--conversation", + "conversation-id", + ], + vec![ + "node.exe", + r"C:\Users\user\AppData\Roaming\npm\node_modules\@letta-ai\letta-code\letta.js", + "--agent", + "agent-id", + ], + ] { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Letta, "letta".to_string())) + ); + } + } + + #[test] + fn identify_agent_in_job_ignores_noninteractive_letta_processes() { + for args in [ + vec!["--prompt", "hello"], + vec!["--output-format", "json"], + vec!["--input-format=stream-json"], + vec!["--ephemeral"], + vec!["--max-turns=1"], + vec!["server"], + vec!["--backend", "local", "server"], + vec!["fix this bug"], + vec!["agents", "list"], + vec!["version"], + ] { + let mut argv = vec!["node", "/home/user/project/node_modules/.bin/letta"]; + argv.extend(args); + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, "MainThread", &argv)], + }; + + assert_eq!(identify_agent_in_job(&job), None, "argv: {argv:?}"); + } + + let unrelated = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/tmp/server.js", "letta"], + )], + }; + assert_eq!(identify_agent_in_job(&unrelated), None); + + let source_checkout = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node", + &["node", "/home/user/src/letta-code/letta/build.js"], + )], + }; + assert_eq!(identify_agent_in_job(&source_checkout), None); + } + + #[test] + fn letta_manifest_detects_observed_working_and_idle_chrome() { + let empty = manifest::explain(Agent::Letta, ""); + assert_eq!(empty.state, AgentState::Unknown); + assert_eq!( + empty.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("no_live_state_evidence") + ); + for screen in [ + "✻ Thinking…\nTutor is reflecting… (esc to interrupt · 2m 3s)", + "My Tutor is thinking about thinking… (esc to interrupt · 3s)", + "Tutor is calibrating… (interrupting)", + "• Run Compile the integration\n└ Running... (1s)", + ] { + assert_eq!( + detect_state(Some(Agent::Letta), screen), + AgentState::Working + ); + } + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · No model selected" + ), + AgentState::Idle + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "────────────────\n› explain this code\n────────────────\nTutor · No model selected" + ), + AgentState::Unknown + ); + let selector = manifest::explain( + Agent::Letta, + "8 pinned agents available.\n\n> Resume Bob (pinned)\n View all 8 profiles\n Create a new agent (--new)\n\n ↑↓ navigate · Enter select · Esc exit", + ); + assert_eq!(selector.state, AgentState::Unknown); + assert_eq!( + selector.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("profile_selector") + ); + } + + #[test] + fn letta_manifest_uses_osc_activity_and_approval_signals() { + let idle_screen = + "────────────────\n› Try \"debug this error\"\n────────────────\nTutor · GPT-5.5"; + + for title in ["⠋ Tutor", "project | ⠏ Tutor"] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); + } + + for title in [ + "[ ! ] Action Required | Tutor", + "[ . ] Action Required | Tutor", + ] { + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, title, ""); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + let detection = detect_agent_with_osc(Some(Agent::Letta), idle_screen, "Tutor", "4;3;0"); + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] + fn letta_manifest_detects_observed_command_approval() { + let approval = r#"✻ Thinking… + +──────────────────────────────────────────────────────────────── +Run this command? + + $ rm -f /var/tmp/herdr-blocked-capture-never-created + +❯ 1. Yes + 2. No, and tell Letta Code what to do differently + +Enter to select · Esc to cancel"#; + assert_eq!( + detect_state(Some(Agent::Letta), approval), + AgentState::Blocked + ); + assert_eq!( + detect_state( + Some(Agent::Letta), + "The user asked: Run this command?\n›\n────\nTutor · No model selected" + ), + AgentState::Idle + ); + } + + #[test] fn identify_agent_in_job_detects_windows_cursor_install() { let job = crate::platform::ForegroundJob { process_group_id: 123, |
