aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 18:36:47 +0300
committerGitHub <noreply@github.com>2026-09-13 17:36:47 +0200
commitfc1cb77f0572948552377b23f7754a3016ac7dfa (patch)
tree5412b796aaaa00894fe628b2ffed8d747e6f452d /src/detect
parent3be35231497f33f38aeffd84df02642479995bc6 (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.rs1
-rw-r--r--src/detect/manifests/letta.toml95
-rw-r--r--src/detect/mod.rs309
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,