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 | |
| parent | 3be35231497f33f38aeffd84df02642479995bc6 (diff) | |
feat: add Letta Code detection and restore (#3107)
refs #3106
Co-authored-by: Cameron <cameron@pfiffer.org>
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 47 | ||||
| -rw-r--r-- | src/cli/integration.rs | 128 | ||||
| -rw-r--r-- | src/cli/spec.rs | 19 | ||||
| -rw-r--r-- | src/config/model.rs | 2 | ||||
| -rw-r--r-- | src/config/sound.rs | 14 | ||||
| -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 | ||||
| -rw-r--r-- | src/integration/actions.rs | 61 | ||||
| -rw-r--r-- | src/integration/assets/letta/herdr-agent-session.ps1 | 51 | ||||
| -rw-r--r-- | src/integration/assets/letta/herdr-agent-session.sh | 56 | ||||
| -rw-r--r-- | src/integration/env.rs | 4 | ||||
| -rw-r--r-- | src/integration/mod.rs | 30 | ||||
| -rw-r--r-- | src/integration/registry.rs | 50 | ||||
| -rw-r--r-- | src/integration/targets.rs | 256 | ||||
| -rw-r--r-- | src/integration/tests.rs | 188 | ||||
| -rw-r--r-- | src/integration/types.rs | 25 | ||||
| -rw-r--r-- | src/main.rs | 2 |
18 files changed, 1255 insertions, 83 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 075c396f..04e6cf63 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -225,6 +225,26 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:letta", "letta", AgentSessionRefKind::Id) => { + if let Some(agent_id) = session_ref.value.strip_prefix("default:") { + if agent_id.is_empty() { + return None; + } + vec![ + "letta".into(), + "--conversation".into(), + "default".into(), + "--agent".into(), + agent_id.into(), + ] + } else { + vec![ + "letta".into(), + "--conversation".into(), + session_ref.value.clone(), + ] + } + } _ => return None, }; @@ -262,6 +282,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:cursor", "cursor") | ("herdr:antigravity_cli", "agy") | ("herdr:grok", "grok") + | ("herdr:letta", "letta") ) } @@ -516,6 +537,32 @@ mod tests { .argv, vec!["grok", "--resume", "grok-session"] ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("conversation-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "conversation-123"] + ); + assert_eq!( + plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:agent-123").unwrap() + ) + .unwrap() + .argv, + vec!["letta", "--conversation", "default", "--agent", "agent-123"] + ); + assert!(plan( + "herdr:letta", + "letta", + &AgentSessionRef::id("default:").unwrap() + ) + .is_none()); } #[test] diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d0a53f7b..a8d5a5bf 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -38,38 +38,63 @@ fn integration_status(args: &[String]) -> std::io::Result<i32> { for status in crate::integration::installed_integration_statuses() { let target = crate::integration::integration_target_label(status.target); - let version = match status.installed_version { - Some(version) => format!("v{version}"), - None => "legacy".to_string(), - }; - let state = match status.state { - crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), - crate::integration::IntegrationStatusKind::Current => { - format!("current ({version})") - } - crate::integration::IntegrationStatusKind::Outdated - if status - .installed_version - .is_some_and(|installed| installed >= status.expected_version) => - { - format!("needs repair ({version})") - } - crate::integration::IntegrationStatusKind::Outdated => { - format!("outdated ({version} < v{})", status.expected_version) - } - }; + let state = describe_integration_state( + status.state, + status.installed_version, + status.expected_version, + ); println!("{target}: {state} ({})", status.path.display()); } + if let Some(status) = crate::integration::experimental_letta_integration_status() { + let state = describe_integration_state( + status.state, + status.installed_version, + status.expected_version, + ); + println!( + "{} (experimental): {state} ({})", + status.label, + status.path.display() + ); + } + Ok(0) } +fn describe_integration_state( + state: crate::integration::IntegrationStatusKind, + installed_version: Option<u32>, + expected_version: u32, +) -> String { + let version = match installed_version { + Some(version) => format!("v{version}"), + None => "legacy".to_string(), + }; + match state { + crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), + crate::integration::IntegrationStatusKind::Current => format!("current ({version})"), + crate::integration::IntegrationStatusKind::Outdated + if installed_version.is_some_and(|installed| installed >= expected_version) => + { + format!("needs repair ({version})") + } + crate::integration::IntegrationStatusKind::Outdated => { + format!("outdated ({version} < v{expected_version})") + } + } +} + fn integration_install(args: &[String]) -> std::io::Result<i32> { let Some(target) = parse_integration_target(args, "install")? else { return Ok(2); }; - match crate::integration::install_target(target) { + let installed = match target { + IntegrationCommandTarget::Builtin(target) => crate::integration::install_target(target), + IntegrationCommandTarget::Letta => crate::integration::install_experimental_letta(), + }; + match installed { Ok(messages) => { print_integration_messages(messages); Ok(0) @@ -86,7 +111,11 @@ fn integration_uninstall(args: &[String]) -> std::io::Result<i32> { return Ok(2); }; - match crate::integration::uninstall_target(target) { + let removed = match target { + IntegrationCommandTarget::Builtin(target) => crate::integration::uninstall_target(target), + IntegrationCommandTarget::Letta => crate::integration::uninstall_experimental_letta(), + }; + match removed { Ok(messages) => { print_integration_messages(messages); Ok(0) @@ -104,45 +133,56 @@ fn print_integration_messages(messages: Vec<String>) { } } +/// Integration target accepted by the CLI. Letta is deliberately kept out of +/// the frozen client endpoint `IntegrationTarget` enum and is handled as an +/// experimental CLI-only target until the agent registry replaces it. +enum IntegrationCommandTarget { + Builtin(IntegrationTarget), + Letta, +} + fn parse_integration_target( args: &[String], action: &str, -) -> std::io::Result<Option<IntegrationTarget>> { +) -> std::io::Result<Option<IntegrationCommandTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|cursor|mastracode|grok>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|letta|cursor|mastracode|grok>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|cursor|mastracode|grok>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|letta|cursor|mastracode|grok>" ); return Ok(None); } let parsed = match target { - "pi" => IntegrationTarget::Pi, - "omp" => IntegrationTarget::Omp, - "claude" => IntegrationTarget::Claude, - "codex" => IntegrationTarget::Codex, - "copilot" => IntegrationTarget::Copilot, - "devin" => IntegrationTarget::Devin, - "droid" => IntegrationTarget::Droid, - "kimi" => IntegrationTarget::Kimi, - "opencode" => IntegrationTarget::Opencode, - "kilo" => IntegrationTarget::Kilo, - "hermes" => IntegrationTarget::Hermes, - "qodercli" => IntegrationTarget::Qodercli, - "qwen" => IntegrationTarget::Qwen, - "cursor" => IntegrationTarget::Cursor, - "mastracode" => IntegrationTarget::Mastracode, - "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, - "grok" => IntegrationTarget::Grok, + "pi" => IntegrationCommandTarget::Builtin(IntegrationTarget::Pi), + "omp" => IntegrationCommandTarget::Builtin(IntegrationTarget::Omp), + "claude" => IntegrationCommandTarget::Builtin(IntegrationTarget::Claude), + "codex" => IntegrationCommandTarget::Builtin(IntegrationTarget::Codex), + "copilot" => IntegrationCommandTarget::Builtin(IntegrationTarget::Copilot), + "devin" => IntegrationCommandTarget::Builtin(IntegrationTarget::Devin), + "droid" => IntegrationCommandTarget::Builtin(IntegrationTarget::Droid), + "kimi" => IntegrationCommandTarget::Builtin(IntegrationTarget::Kimi), + "opencode" => IntegrationCommandTarget::Builtin(IntegrationTarget::Opencode), + "kilo" => IntegrationCommandTarget::Builtin(IntegrationTarget::Kilo), + "hermes" => IntegrationCommandTarget::Builtin(IntegrationTarget::Hermes), + "qodercli" => IntegrationCommandTarget::Builtin(IntegrationTarget::Qodercli), + "qwen" => IntegrationCommandTarget::Builtin(IntegrationTarget::Qwen), + "letta" => IntegrationCommandTarget::Letta, + "cursor" => IntegrationCommandTarget::Builtin(IntegrationTarget::Cursor), + "mastracode" => IntegrationCommandTarget::Builtin(IntegrationTarget::Mastracode), + "antigravity-cli" | "antigravity_cli" => { + IntegrationCommandTarget::Builtin(IntegrationTarget::AntigravityCli) + } + "grok" => IntegrationCommandTarget::Builtin(IntegrationTarget::Grok), _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, cursor, mastracode, antigravity-cli, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, letta, cursor, mastracode, antigravity-cli, grok" ); return Ok(None); } @@ -166,6 +206,7 @@ fn print_integration_help() { eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install qwen"); + eprintln!(" herdr integration install letta"); eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install antigravity-cli"); @@ -183,6 +224,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall qwen"); + eprintln!(" herdr integration uninstall letta"); eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall antigravity-cli"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 0d8a58ea..9465dcc3 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -895,10 +895,12 @@ fn integration_target_arg() -> Arg { } fn integration_target_values() -> Vec<&'static str> { - crate::api::schema::IntegrationTarget::ALL + let mut values: Vec<&'static str> = crate::api::schema::IntegrationTarget::ALL .into_iter() .map(crate::integration::integration_target_label) - .collect() + .collect(); + values.extend_from_slice(crate::integration::EXPERIMENTAL_INTEGRATION_TARGET_LABELS); + values } fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command { @@ -1121,6 +1123,15 @@ mod tests { fn spec_matches_all_integration_targets() { let cmd = super::command(); let install = command_path(&cmd, &["integration", "install"]); + let mut expected: Vec<String> = crate::api::schema::IntegrationTarget::ALL + .map(crate::integration::integration_target_label) + .map(str::to_string) + .to_vec(); + expected.extend( + crate::integration::EXPERIMENTAL_INTEGRATION_TARGET_LABELS + .iter() + .map(|label| (*label).to_string()), + ); assert_eq!( argument(install, "target") .get_value_parser() @@ -1128,9 +1139,7 @@ mod tests { .unwrap() .map(|value| value.get_name().to_string()) .collect::<Vec<_>>(), - crate::api::schema::IntegrationTarget::ALL - .map(crate::integration::integration_target_label) - .map(str::to_string) + expected ); } diff --git a/src/config/model.rs b/src/config/model.rs index 604fe9ad..6ef6274d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1060,7 +1060,7 @@ pub struct ExperimentalConfig { /// if the list contains no valid names, the reveal does not apply. /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, - /// qodercli, qoder, qwen, qwen-code, maki. + /// qodercli, qoder, qwen, qwen-code, letta, letta-code, maki. /// Default: empty. pub cjk_ime_agents: Vec<String>, /// Cursor shape rendered for the IME anchor when diff --git a/src/config/sound.rs b/src/config/sound.rs index 2215c832..8eb2561e 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -44,6 +44,7 @@ pub struct AgentSoundOverrides { pub kilo: AgentSoundSetting, pub qodercli: AgentSoundSetting, pub qwen: AgentSoundSetting, + pub letta: AgentSoundSetting, pub maki: AgentSoundSetting, pub muse: AgentSoundSetting, } @@ -142,6 +143,7 @@ impl AgentSoundOverrides { Some(Agent::Kilo) => self.kilo, Some(Agent::Qodercli) => self.qodercli, Some(Agent::Qwen) => self.qwen, + Some(Agent::Letta) => self.letta, Some(Agent::Maki) => self.maki, Some(Agent::Muse) => self.muse, None => AgentSoundSetting::Default, @@ -183,6 +185,7 @@ impl Default for AgentSoundOverrides { kilo: AgentSoundSetting::Default, qodercli: AgentSoundSetting::Default, qwen: AgentSoundSetting::Default, + letta: AgentSoundSetting::Default, maki: AgentSoundSetting::Default, muse: AgentSoundSetting::Default, } @@ -223,6 +226,17 @@ claude = "on" assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + assert_eq!(config.ui.sound.agents.letta, AgentSoundSetting::Default); + } + + #[test] + fn letta_sound_override_is_used() { + let overrides: AgentSoundOverrides = toml::from_str("letta = \"on\"").unwrap(); + assert_eq!(overrides.letta, AgentSoundSetting::On); + assert_eq!( + overrides.for_agent(Some(Agent::Letta)), + AgentSoundSetting::On + ); } #[test] 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, diff --git a/src/integration/actions.rs b/src/integration/actions.rs index e8b3ab63..d51682f8 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -4,11 +4,11 @@ use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, - install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, install_qwen, - uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + install_letta, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + install_qwen, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, - uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, - uninstall_pi, uninstall_qodercli, uninstall_qwen, + uninstall_kilo, uninstall_kimi, uninstall_letta, uninstall_mastracode, uninstall_omp, + uninstall_opencode, uninstall_pi, uninstall_qodercli, uninstall_qwen, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -22,6 +22,59 @@ pub(crate) fn install_target( result } +/// Experimental Letta install that bypasses the frozen client endpoint +/// `IntegrationTarget` enum. Fold into the agent registry when it lands. +pub(crate) fn install_experimental_letta() -> io::Result<Vec<String>> { + let result = install_letta().map(|installed| { + vec![ + format!( + "installed letta integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured letta settings at {}", + installed.settings_path.display() + ), + ] + }); + let outcome = if result.is_ok() { "ok" } else { "error" }; + crate::logging::integration_action("install", "letta", outcome); + result +} + +/// Experimental Letta uninstall counterpart. +pub(crate) fn uninstall_experimental_letta() -> io::Result<Vec<String>> { + let result = uninstall_letta().map(|result| { + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed letta hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no letta hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr letta hook entry from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr letta hook entry found in {}", + result.settings_path.display() + )); + } + messages + }); + let outcome = if result.is_ok() { "ok" } else { "error" }; + crate::logging::integration_action("uninstall", "letta", outcome); + result +} + fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result<Vec<String>> { if !integration_target_supported(target) { return Err(io::Error::other(format!( diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1 new file mode 100644 index 00000000..27f40e9b --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.ps1 @@ -0,0 +1,51 @@ +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.conversation_id)) { exit 0 } + +$sessionId = [string]$payload.conversation_id +if ($sessionId -eq "default") { + if ([string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 } + $sessionId = "default:" + [string]$payload.agent_id +} + +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$source = if ($payload.is_new_session -eq $true) { "new" } else { "resume" } +$commandArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", $sessionId, + "--seq", [string][DateTime]::UtcNow.Ticks, + "--session-start-source", $source +) +$job = $null +try { + $job = Start-Job -ScriptBlock { + param($Executable, [object[]]$Arguments) + & $Executable @Arguments *> $null + } -ArgumentList $herdr, (,$commandArgs) + if ($null -eq (Wait-Job -Job $job -Timeout 1)) { + Stop-Job -Job $job + } +} catch { + # Hook failures must stay silent because Letta injects output into the next prompt. + $null = $_ +} finally { + if ($null -ne $job) { + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh new file mode 100644 index 00000000..d6e052d6 --- /dev/null +++ b/src/integration/assets/letta/herdr-agent-session.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +[ "${1:-}" = "session" ] || exit 0 +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +if [ -n "${HERDR_BIN_PATH:-}" ]; then + [ -x "$HERDR_BIN_PATH" ] || exit 0 +else + command -v herdr >/dev/null 2>&1 || exit 0 +fi +command -v python3 >/dev/null 2>&1 || exit 0 + +# SessionStart stdout is injected into Letta's next user message. Keep every +# path silent, including successful reports and malformed hook input. +python3 -c ' +import json +import os +import subprocess +import sys +import time + +try: + payload = json.load(sys.stdin) + conversation_id = payload.get("conversation_id") + agent_id = payload.get("agent_id") + if not isinstance(conversation_id, str) or not conversation_id: + raise ValueError + if conversation_id == "default": + if not isinstance(agent_id, str) or not agent_id: + raise ValueError + session_id = "default:" + agent_id + else: + session_id = conversation_id + + command = os.environ.get("HERDR_BIN_PATH") or "herdr" + args = [ + command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"], + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", session_id, "--seq", str(time.time_ns()), + "--session-start-source", "new" if payload.get("is_new_session") else "resume", + ] + subprocess.run( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1, + check=False, + ) +except Exception: + pass +' 2>/dev/null || true diff --git a/src/integration/env.rs b/src/integration/env.rs index 48320c72..548c1386 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -175,6 +175,10 @@ pub(crate) fn qwen_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(QWEN_HOME_ENV_VAR, &[".qwen"]) } +pub(crate) fn letta_dir() -> io::Result<PathBuf> { + Ok(home_dir()?.join(".letta")) +} + pub(crate) fn cursor_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"]) } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index a40ad970..97163da5 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -10,17 +10,27 @@ mod targets; mod types; mod version; -pub(crate) use actions::{install_target, uninstall_target}; +pub(crate) use actions::{ + install_experimental_letta, install_target, uninstall_experimental_letta, uninstall_target, +}; #[cfg(test)] pub(crate) use env::integration_env_lock; pub(crate) use env::{ apply_pane_base_env, HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR, }; pub(crate) use registry::{ - installed_integration_statuses, integration_recommendations, integration_target_label, - print_outdated_update_notice, + experimental_letta_integration_status, installed_integration_statuses, + integration_recommendations, integration_target_label, print_outdated_update_notice, +}; +pub(crate) use types::{ + ExperimentalIntegrationStatus, IntegrationRecommendation, IntegrationStatus, + IntegrationStatusKind, }; -pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, IntegrationStatusKind}; + +/// CLI labels for experimental integrations that are intentionally not part of +/// the frozen client endpoint `IntegrationTarget` enum. Empty this list once the +/// agent registry provides first-class target registration. +pub(crate) const EXPERIMENTAL_INTEGRATION_TARGET_LABELS: &[&str] = &["letta"]; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); @@ -207,6 +217,18 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) { }; const QWEN_INTEGRATION_VERSION: u32 = 1; const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-session.ps1" +} else { + "herdr-agent-session.sh" +}; +const LETTA_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/letta/herdr-agent-session.ps1") +} else { + include_str!("assets/letta/herdr-agent-session.sh") +}; +const LETTA_INTEGRATION_VERSION: u32 = 1; +const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000; const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("SessionStart", "idle"), ("UserPromptSubmit", "working"), diff --git a/src/integration/registry.rs b/src/integration/registry.rs index f3bb8c04..9185c96f 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -441,30 +441,33 @@ fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) .is_some_and(|version| version >= expected_version))) } -pub(crate) fn integration_status_at( - target: crate::api::schema::IntegrationTarget, - path: PathBuf, +fn integration_state_for_path( + path: &Path, expected_version: u32, -) -> super::IntegrationStatus { +) -> (super::IntegrationStatusKind, Option<u32>) { if !path.is_file() { - return super::IntegrationStatus { - target, - path, - state: super::IntegrationStatusKind::NotInstalled, - installed_version: None, - expected_version, - }; + return (super::IntegrationStatusKind::NotInstalled, None); } - let installed_version = fs::read_to_string(&path) + let installed_version = fs::read_to_string(path) .ok() .and_then(|content| parse_integration_version(&content)); - let mut state = if installed_version.is_some_and(|version| version >= expected_version) { + let state = if installed_version.is_some_and(|version| version >= expected_version) { super::IntegrationStatusKind::Current } else { super::IntegrationStatusKind::Outdated }; + (state, installed_version) +} + +pub(crate) fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> super::IntegrationStatus { + let (mut state, installed_version) = integration_state_for_path(&path, expected_version); + // Grok only invokes the hook when the herdr-owned `hooks/herdr.json` // registers it, so a current hook script with a missing or broken config // is a nonfunctional install: report it as outdated so `herdr integration @@ -491,6 +494,27 @@ pub(crate) fn integration_status_at( } } +/// Letta is intentionally kept out of the frozen client endpoint +/// `IntegrationTarget` enum so published generation-1 clients never receive an +/// unknown variant. It is installable and reportable as an experimental +/// CLI-only target until the agent registry replaces the enum-keyed registry. +pub(crate) fn experimental_letta_integration_status() -> Option<super::ExperimentalIntegrationStatus> +{ + let path = letta_dir() + .ok()? + .join("hooks") + .join(super::LETTA_HOOK_INSTALL_NAME); + let (state, installed_version) = + integration_state_for_path(&path, super::LETTA_INTEGRATION_VERSION); + Some(super::ExperimentalIntegrationStatus { + label: "letta", + path, + state, + installed_version, + expected_version: super::LETTA_INTEGRATION_VERSION, + }) +} + pub(crate) fn parse_integration_version(content: &str) -> Option<u32> { content.lines().find_map(|line| { let marker_line = line diff --git a/src/integration/targets.rs b/src/integration/targets.rs index d204c552..cab8f7ee 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -21,8 +21,8 @@ use super::config_edit::{ }; use super::env::{ antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, - grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, - opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir, + grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, letta_dir, mastracode_dir, + omp_extension_dir, opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, @@ -37,10 +37,10 @@ use super::types::{ CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, - KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, - MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, - OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, - QwenInstallPaths, QwenUninstallResult, + KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, LettaInstallPaths, + LettaUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, + OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, + QodercliInstallPaths, QodercliUninstallResult, QwenInstallPaths, QwenUninstallResult, }; use super::{ ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, @@ -53,7 +53,8 @@ use super::{ GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, + KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, LETTA_HOOK_ASSET, LETTA_HOOK_INSTALL_NAME, + LETTA_HOOK_TIMEOUT_MS, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, @@ -1021,6 +1022,207 @@ pub(crate) fn install_qwen() -> io::Result<QwenInstallPaths> { }) } +fn letta_install_artifact_path(path: &Path, role: &str) -> io::Result<PathBuf> { + let file_name = path + .file_name() + .ok_or_else(|| io::Error::other(format!("invalid install path: {}", path.display())))?; + let mut artifact_name = file_name.to_os_string(); + artifact_name.push(format!(".herdr-install-{}-{role}", std::process::id())); + Ok(path.with_file_name(artifact_name)) +} + +pub(super) fn prepare_letta_install_file( + target: &Path, + contents: &[u8], + executable: bool, + preserve_permissions: bool, +) -> io::Result<(PathBuf, PathBuf)> { + if target.try_exists()? && !target.is_file() { + return Err(io::Error::other(format!( + "install target is not a file: {}", + target.display() + ))); + } + + let staged = letta_install_artifact_path(target, "staged")?; + let backup = letta_install_artifact_path(target, "backup")?; + if staged.try_exists()? || backup.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("stale install artifact exists for {}", target.display()), + )); + } + + let prepare_result = (|| { + fs::write(&staged, contents)?; + if preserve_permissions && target.is_file() { + fs::set_permissions(&staged, fs::metadata(target)?.permissions())?; + } + if executable { + make_executable(&staged)?; + } + Ok(()) + })(); + if let Err(err) = prepare_result { + let _ = remove_file_if_exists(&staged); + return Err(err); + } + + Ok((staged, backup)) +} + +fn combine_letta_install_errors(primary: io::Error, rollback: io::Result<()>) -> io::Error { + match rollback { + Ok(()) => primary, + Err(rollback_err) => io::Error::new( + primary.kind(), + format!("{primary}; rollback failed: {rollback_err}"), + ), + } +} + +pub(super) fn publish_letta_install_file( + target: &Path, + staged: &Path, + backup: &Path, +) -> io::Result<bool> { + let had_original = target.try_exists()?; + if had_original { + fs::rename(target, backup)?; + } + + if let Err(err) = fs::rename(staged, target) { + let rollback = if had_original { + fs::rename(backup, target) + } else { + Ok(()) + }; + return Err(combine_letta_install_errors(err, rollback)); + } + + Ok(had_original) +} + +pub(super) fn rollback_letta_install_file( + target: &Path, + backup: &Path, + had_original: bool, +) -> io::Result<()> { + remove_file_if_exists(target)?; + if had_original { + fs::rename(backup, target)?; + } + Ok(()) +} + +fn cleanup_letta_install_artifact(path: &Path) { + if let Err(err) = remove_file_if_exists(path) { + tracing::warn!(path = %path.display(), %err, "failed to remove Letta install artifact"); + } +} + +fn ensure_letta_session_hook(hooks: &mut Map<String, Value>, command: String) -> io::Result<()> { + let entries = hooks + .entry("SessionStart".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| io::Error::other("hook entries for SessionStart must be an array"))?; + + entries.push(json!({ + "hooks": [{ + "type": "command", + "command": command, + "timeout": LETTA_HOOK_TIMEOUT_MS, + "quiet": true, + }], + })); + Ok(()) +} + +pub(crate) fn install_letta() -> io::Result<LettaInstallPaths> { + let dir = letta_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "letta code config directory not found at {}. install letta code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(LETTA_HOOK_INSTALL_NAME); + + let settings_path = dir.join("settings.json"); + let mut settings = if settings_path.is_file() { + serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )?; + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + ensure_letta_session_hook(hooks, hook_command(&hook_path, Some("session")))?; + + let settings_contents = serde_json::to_string_pretty(&settings)?; + let (hook_staged, hook_backup) = + prepare_letta_install_file(&hook_path, LETTA_HOOK_ASSET.as_bytes(), true, false)?; + let (settings_staged, settings_backup) = + match prepare_letta_install_file(&settings_path, settings_contents.as_bytes(), false, true) + { + Ok(paths) => paths, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + return Err(err); + } + }; + + let hook_had_original = match publish_letta_install_file(&hook_path, &hook_staged, &hook_backup) + { + Ok(had_original) => had_original, + Err(err) => { + cleanup_letta_install_artifact(&hook_staged); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + let settings_had_original = + match publish_letta_install_file(&settings_path, &settings_staged, &settings_backup) { + Ok(had_original) => had_original, + Err(err) => { + let err = combine_letta_install_errors( + err, + rollback_letta_install_file(&hook_path, &hook_backup, hook_had_original), + ); + cleanup_letta_install_artifact(&settings_staged); + return Err(err); + } + }; + + if hook_had_original { + cleanup_letta_install_artifact(&hook_backup); + } + if settings_had_original { + cleanup_letta_install_artifact(&settings_backup); + } + + Ok(LettaInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> { let dir = cursor_dir()?; if !dir.is_dir() { @@ -1163,6 +1365,46 @@ pub(crate) fn uninstall_qwen() -> io::Result<QwenUninstallResult> { }) } +pub(crate) fn uninstall_letta() -> io::Result<LettaUninstallResult> { + let dir = letta_dir()?; + let hook_path = dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME); + let settings_path = dir.join("settings.json"); + let mut updated_settings = false; + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "letta settings", + "letta settings hooks", + )? { + updated_settings |= + remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(LettaUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> { let cursor_home = cursor_dir()?; let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME); diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 4c2178cc..41a4e027 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -3343,6 +3343,194 @@ fn install_qwen_errors_when_config_dir_missing() { } #[test] +fn install_and_uninstall_letta_preserve_unrelated_settings_and_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + let settings_path = letta_dir.join("settings.json"); + fs::write( + &settings_path, + r#"{"theme":"dark","hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ) + .unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let installed = install_letta().unwrap(); + assert_eq!( + installed.hook_path, + letta_dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME) + ); + let first_install = fs::read_to_string(&settings_path).unwrap(); + let settings: Value = serde_json::from_str(&first_install).unwrap(); + let entries = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["hooks"][0]["command"], "echo user"); + assert!(entries[1].get("matcher").is_none()); + assert_eq!(entries[1]["hooks"][0]["timeout"], LETTA_HOOK_TIMEOUT_MS); + assert_eq!(entries[1]["hooks"][0]["quiet"], true); + assert!(entries[1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("session")); + assert_eq!(settings["theme"], "dark"); + + install_letta().unwrap(); + assert_eq!(fs::read_to_string(&settings_path).unwrap(), first_install); + + let result = uninstall_letta().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_settings); + assert!(!installed.hook_path.exists()); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert_eq!(settings["theme"], "dark"); + let remaining = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["hooks"][0]["command"], "echo user"); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn letta_session_hook_is_silent_and_encodes_default_conversation() { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".letta")).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + let installed = install_letta().unwrap(); + + let capture = base.join("args.txt"); + let fake_herdr = base.join("herdr"); + fs::write( + &fake_herdr, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&fake_herdr).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_herdr, permissions).unwrap(); + + let mut child = Command::new("sh") + .arg(&installed.hook_path) + .arg("session") + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", "w1:p2") + .env("HERDR_SOCKET_PATH", "/tmp/herdr.sock") + .env("HERDR_BIN_PATH", &fake_herdr) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all( + br#"{"event_type":"SessionStart","conversation_id":"default","agent_id":"agent-123","is_new_session":false}"#, + ) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + let args = fs::read_to_string(capture).unwrap(); + assert!(args.contains("report-agent-session w1:p2")); + assert!(args.contains("--source herdr:letta --agent letta")); + assert!(args.contains("--agent-session-id default:agent-123")); + assert!(args.contains("--session-start-source resume")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + let err = install_letta().unwrap_err().to_string(); + assert!(err.contains("letta code config directory not found")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_letta_does_not_publish_hook_when_settings_are_invalid() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let letta_dir = home.join(".letta"); + fs::create_dir_all(&letta_dir).unwrap(); + fs::write(letta_dir.join("settings.json"), "not json").unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + + assert!(install_letta().is_err()); + assert!(!letta_dir + .join("hooks") + .join(LETTA_HOOK_INSTALL_NAME) + .exists()); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + +#[test] +fn letta_staged_install_can_restore_the_prior_file() { + let base = unique_base(); + fs::create_dir_all(&base).unwrap(); + let target = base.join("settings.json"); + fs::write(&target, "old").unwrap(); + + let (staged, backup) = prepare_letta_install_file(&target, b"new", false, true).unwrap(); + let had_original = publish_letta_install_file(&target, &staged, &backup).unwrap(); + assert!(had_original); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + + rollback_letta_install_file(&target, &backup, had_original).unwrap(); + assert_eq!(fs::read_to_string(&target).unwrap(), "old"); + assert!(!backup.exists()); + + let _ = fs::remove_dir_all(base); +} + +#[test] fn install_cursor_writes_hook_and_updates_hooks_json() { let _lock = integration_env_lock(); let base = unique_base(); diff --git a/src/integration/types.rs b/src/integration/types.rs index 8169ab98..928675f6 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -77,6 +77,12 @@ pub(crate) struct QwenInstallPaths { } #[derive(Debug)] +pub(crate) struct LettaInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct CursorInstallPaths { pub hook_path: PathBuf, pub hooks_path: PathBuf, @@ -134,6 +140,14 @@ pub(crate) struct QwenUninstallResult { pub updated_settings: bool, } +#[derive(Debug)] +pub(crate) struct LettaUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationStatus { pub target: crate::api::schema::IntegrationTarget, @@ -150,6 +164,17 @@ pub(crate) enum IntegrationStatusKind { Outdated, } +/// Status for an experimental target that is deliberately not part of the +/// frozen client endpoint `IntegrationTarget` enum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExperimentalIntegrationStatus { + pub label: &'static str, + pub path: PathBuf, + pub state: IntegrationStatusKind, + pub installed_version: Option<u32>, + pub expected_version: u32, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationRecommendation { pub target: crate::api::schema::IntegrationTarget, diff --git a/src/main.rs b/src/main.rs index 12711f3a..c75b2e7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -425,7 +425,7 @@ pane_history = false # If the list contains no valid names, the reveal does not apply. # Accepted: pi, claude, codex, gemini, cursor, devin, cline, opencode, # copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, -# qwen-code, maki. +# qwen-code, letta, letta-code, maki. # cjk_ime_agents = [] # Cursor shape rendered when reveal_hidden_cursor_for_cjk_ime is true. # Values: block, steady_block (default), underline, steady_underline, bar, steady_bar. |
