aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent3be35231497f33f38aeffd84df02642479995bc6 (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.rs47
-rw-r--r--src/cli/integration.rs128
-rw-r--r--src/cli/spec.rs19
-rw-r--r--src/config/model.rs2
-rw-r--r--src/config/sound.rs14
-rw-r--r--src/detect/manifest.rs1
-rw-r--r--src/detect/manifests/letta.toml95
-rw-r--r--src/detect/mod.rs309
-rw-r--r--src/integration/actions.rs61
-rw-r--r--src/integration/assets/letta/herdr-agent-session.ps151
-rw-r--r--src/integration/assets/letta/herdr-agent-session.sh56
-rw-r--r--src/integration/env.rs4
-rw-r--r--src/integration/mod.rs30
-rw-r--r--src/integration/registry.rs50
-rw-r--r--src/integration/targets.rs256
-rw-r--r--src/integration/tests.rs188
-rw-r--r--src/integration/types.rs25
-rw-r--r--src/main.rs2
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.