diff options
| author | Eric Yue <yue1995pro@gmail.com> | 2026-06-15 21:50:24 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-15 16:50:24 +0300 |
| commit | 07261e09be60ac0c145685fd11f94351cc0e5ba6 (patch) | |
| tree | 771b0180c7a1560189547e6d23ef004ec1cf6d5c /src | |
| parent | 08bb0768ae224c8db04bbe016b050353ce13eee7 (diff) | |
feat: add devin cli detection and restore support (#622)
* feat: add devin cli detection and restore support
refs #606
* fix: keep devin integration session-only
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 38 | ||||
| -rw-r--r-- | src/api/schema/integrations.rs | 1 | ||||
| -rw-r--r-- | src/app/actions.rs | 39 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/config/model.rs | 5 | ||||
| -rw-r--r-- | src/config/sound.rs | 3 | ||||
| -rw-r--r-- | src/detect/manifest.rs | 1 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 60 | ||||
| -rw-r--r-- | src/detect/manifests/devin.toml | 86 | ||||
| -rw-r--r-- | src/detect/mod.rs | 12 | ||||
| -rw-r--r-- | src/integration/assets/devin/herdr-agent-state.sh | 173 | ||||
| -rw-r--r-- | src/integration/mod.rs | 436 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/terminal/state.rs | 48 |
14 files changed, 884 insertions, 31 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index cea4a641..4a97f207 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -82,6 +82,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { ("herdr:claude", "claude") | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") + | ("herdr:devin", "devin") | ("herdr:droid", "droid") | ("herdr:qodercli", "qodercli") | ("herdr:cursor", "cursor") @@ -128,6 +129,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:copilot", "copilot", AgentSessionRefKind::Id) => { vec!["copilot".into(), format!("--resume={}", session_ref.value)] } + ("herdr:devin", "devin", AgentSessionRefKind::Id) => { + vec!["devin".into(), "--resume".into(), session_ref.value.clone()] + } ("herdr:droid", "droid", AgentSessionRefKind::Id) => { vec!["droid".into(), "--resume".into(), session_ref.value.clone()] } @@ -191,6 +195,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { ("herdr:claude", "claude") | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") + | ("herdr:devin", "devin") | ("herdr:droid", "droid") | ("herdr:kimi", "kimi") | ("herdr:pi", "pi") @@ -229,6 +234,7 @@ mod tests { fn native_state_reservation_excludes_full_lifecycle_sources() { assert!(is_reserved_native_state_source("herdr:claude", "claude")); assert!(is_reserved_native_state_source("herdr:codex", "codex")); + assert!(is_reserved_native_state_source("herdr:devin", "devin")); assert!(!is_reserved_native_state_source("herdr:kimi", "kimi")); assert!(!is_reserved_native_state_source( "herdr:opencode", @@ -271,6 +277,16 @@ mod tests { ); assert_eq!( plan( + "herdr:devin", + "devin", + &AgentSessionRef::id("devin-session").unwrap() + ) + .unwrap() + .argv, + vec!["devin", "--resume", "devin-session"] + ); + assert_eq!( + plan( "herdr:droid", "droid", &AgentSessionRef::id("droid-session").unwrap() @@ -404,6 +420,11 @@ mod tests { ); let session_ref = + session_ref_from_report("herdr:devin", "devin", Some("devin-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "devin-id"); + + let session_ref = session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap(); assert_eq!(session_ref.kind, AgentSessionRefKind::Id); assert_eq!(session_ref.value, "droid-id"); @@ -474,6 +495,9 @@ mod tests { ) .unwrap(); assert_eq!(copilot_plan.argv, vec!["copilot", "--resume=abc; rm -rf /"]); + + let devin_plan = plan("herdr:devin", "devin", &AgentSessionRef::id(id).unwrap()).unwrap(); + assert_eq!(devin_plan.argv, vec!["devin", "--resume", id]); } #[test] @@ -482,6 +506,7 @@ mod tests { let opencode_session = absolute_test_path("opencode-session"); let kilo_session = absolute_test_path("kilo-session"); let copilot_session = absolute_test_path("copilot-session"); + let devin_session = absolute_test_path("devin-session"); assert!(plan( "herdr:hermes", "hermes", @@ -506,6 +531,12 @@ mod tests { &AgentSessionRef::path(&copilot_session).unwrap() ) .is_none()); + assert!(plan( + "herdr:devin", + "devin", + &AgentSessionRef::path(&devin_session).unwrap() + ) + .is_none()); assert!(session_ref_from_snapshot( "herdr:hermes", "hermes", @@ -534,5 +565,12 @@ mod tests { "copilot-session" ) .is_some()); + assert!(session_ref_from_snapshot( + "herdr:devin", + "devin", + AgentSessionRefKind::Id, + "devin-session" + ) + .is_some()); } } diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 49b27f96..bd4988f1 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -18,6 +18,7 @@ pub enum IntegrationTarget { Claude, Codex, Copilot, + Devin, Droid, Kimi, Opencode, diff --git a/src/app/actions.rs b/src/app/actions.rs index b033b372..9df07af5 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2767,7 +2767,7 @@ impl AppState { }), } }; - let toast = (!is_active_tab).then(&build_toast); + let toast = (!is_active_tab).then(build_toast); let client_notification = (!suppress_active_tab_notifications).then(build_toast); if toast.is_none() && client_notification.is_none() && sound.is_none() { @@ -4533,6 +4533,43 @@ mod tests { } #[test] + fn devin_state_report_refreshes_session_without_overriding_screen_state() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Devin), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:devin".into(), + agent_label: "devin".into(), + state: AgentState::Working, + message: None, + custom_status: None, + seq: Some(1), + session_ref: crate::agent_resume::AgentSessionRef::id("devin-session"), + }); + + let terminal = state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.state, AgentState::Idle); + assert!(terminal.hook_authority.is_none()); + assert!(terminal.persisted_agent_session.is_some()); + } + + #[test] fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); diff --git a/src/cli/integration.rs b/src/cli/integration.rs index e46e04b9..8dc053f1 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); } @@ -120,6 +120,7 @@ fn parse_integration_target( "claude" => IntegrationTarget::Claude, "codex" => IntegrationTarget::Codex, "copilot" => IntegrationTarget::Copilot, + "devin" => IntegrationTarget::Devin, "droid" => IntegrationTarget::Droid, "kimi" => IntegrationTarget::Kimi, "opencode" => IntegrationTarget::Opencode, @@ -130,7 +131,7 @@ fn parse_integration_target( _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, kilo, hermes, qodercli, cursor" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor" ); return Ok(None); } @@ -146,6 +147,7 @@ fn print_integration_help() { eprintln!(" herdr integration install claude"); eprintln!(" herdr integration install codex"); eprintln!(" herdr integration install copilot"); + eprintln!(" herdr integration install devin"); eprintln!(" herdr integration install droid"); eprintln!(" herdr integration install kimi"); eprintln!(" herdr integration install opencode"); @@ -158,6 +160,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall copilot"); + eprintln!(" herdr integration uninstall devin"); eprintln!(" herdr integration uninstall droid"); eprintln!(" herdr integration uninstall kimi"); eprintln!(" herdr integration uninstall opencode"); diff --git a/src/config/model.rs b/src/config/model.rs index 528a940c..95915590 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -527,8 +527,9 @@ pub struct ExperimentalConfig { /// detected agent matches one of these names (case-insensitive). Empty /// list means apply to any focused pane. Unknown agent names are ignored; /// if the list contains no valid names, the reveal does not apply. - /// Accepted names: pi, claude, codex, gemini, cursor, cline, opencode, - /// copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder. + /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, + /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, + /// qodercli, qoder. /// 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 50b2355b..25376f2a 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -30,6 +30,7 @@ pub struct AgentSoundOverrides { pub codex: AgentSoundSetting, pub gemini: AgentSoundSetting, pub cursor: AgentSoundSetting, + pub devin: AgentSoundSetting, pub agy: AgentSoundSetting, pub cline: AgentSoundSetting, pub open_code: AgentSoundSetting, @@ -122,6 +123,7 @@ impl AgentSoundOverrides { Some(Agent::Codex) => self.codex, Some(Agent::Gemini) => self.gemini, Some(Agent::Cursor) => self.cursor, + Some(Agent::Devin) => self.devin, Some(Agent::Antigravity) => self.agy, Some(Agent::Cline) => self.cline, Some(Agent::OpenCode) => self.open_code, @@ -159,6 +161,7 @@ impl Default for AgentSoundOverrides { codex: AgentSoundSetting::Default, gemini: AgentSoundSetting::Default, cursor: AgentSoundSetting::Default, + devin: AgentSoundSetting::Default, agy: AgentSoundSetting::Default, cline: AgentSoundSetting::Default, open_code: AgentSoundSetting::Default, diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 06ed4df6..a6da1277 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -243,6 +243,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("cline", include_str!("manifests/cline.toml")), ("codex", include_str!("manifests/codex.toml")), ("cursor", include_str!("manifests/cursor.toml")), + ("devin", include_str!("manifests/devin.toml")), ("droid", include_str!("manifests/droid.toml")), ("gemini", include_str!("manifests/gemini.toml")), ("grok", include_str!("manifests/grok.toml")), diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 8811777b..2570aa03 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -297,6 +297,7 @@ fn all_bundled_manifests_parse_and_validate() { Agent::Codex, Agent::Gemini, Agent::Cursor, + Agent::Devin, Agent::Antigravity, Agent::Cline, Agent::OpenCode, @@ -321,6 +322,65 @@ fn all_bundled_manifests_parse_and_validate() { } #[test] +fn devin_manifest_detects_idle_working_and_blocked_states() { + let idle = explain( + Agent::Devin, + "─────────────────────────────────────────────────────\n❭ Ask Devin to build features, fix bugs, or work on\n your code\n─────────────────────────────────────────────────────\nSWE-1.6 Context: 16k / 200k tokens (7%)", + ); + assert_eq!(idle.state, AgentState::Idle); + assert!(idle.visible_idle); + + let live_footer_idle = explain( + Agent::Devin, + "Done.\n\n────────────────────────────────────────────────── (bypass permissions on) ─\n❭\n────────────────────────────────────────────────────────────────────────────\nClaude Opus 4.6 Thinking Context: 38k / 200k tokens (18%)", + ); + assert_eq!(live_footer_idle.state, AgentState::Idle); + assert_eq!( + live_footer_idle + .matched_rule + .as_ref() + .map(|rule| rule.id.as_str()), + Some("live_prompt_footer") + ); + assert!(live_footer_idle.visible_idle); + + let welcome_footer_idle = explain( + Agent::Devin, + "⠀⠀⠀⠀⠀⣴⣾⣶⡄⠀⠀⠀⠀\n⠀⣴⣾⣶⡾⠛⠿⠟⠃⣴⣾⣶⡄ Devin CLI\n⠀⠛⠿⠟⠃⣴⣾⣶⡾⠛⠿⠟⠃ v2026.5.26-8\n⠀⣤⣶⣦⡄⠻⢿⠿⢷⣤⣶⣦⡄\n⠀⠻⢿⠿⢷⣤⣶⣦⡄⠻⢿⠿⠃ Hybrid\n⠀⠀⠀⠀⠀⠻⢿⠿⠃⠀⠀⠀⠀\n\n───────────────────────────\n❭ Ask Devin to build\n features, fix bugs, or\n work on your code\n───────────────────────────\nClaude Opus Looking for\n4.6 Thinkingplan mode? /\n plan", + ); + assert_eq!(welcome_footer_idle.state, AgentState::Idle); + assert_eq!( + welcome_footer_idle + .matched_rule + .as_ref() + .map(|rule| rule.id.as_str()), + Some("welcome_prompt_footer") + ); + assert!(welcome_footer_idle.visible_idle); + + let working = explain( + Agent::Devin, + "◔ Reading shell 91b655\n │ Timeout: 35s\n\n⠀⡆ Running tools · 27s (esc to interrupt)\n─────────────────────────────────────────────────────\n❭ Guide Devin while it works", + ); + assert_eq!(working.state, AgentState::Working); + assert!(working.visible_working); + + let trust_prompt = explain( + Agent::Devin, + "Do you trust the authors of this directory?\nFor security, devin should not be run in directories\nwith untrusted content.\n❭ 1 Yes, trust /private/tmp/devin-hook-probe\n· 2 No, exit", + ); + assert_eq!(trust_prompt.state, AgentState::Blocked); + assert!(trust_prompt.visible_blocker); + + let permission_prompt = explain( + Agent::Devin, + "⏺ Running command\n └ $ sleep 30\n\n❭ 1 Yes (Approve once)\n· 2 Yes, allow `sleep` commands\n· 3 Yes, always allow `sleep` commands\n· 4 No\n↑↓ select · ↵ confirm · esc cancel", + ); + assert_eq!(permission_prompt.state, AgentState::Blocked); + assert!(permission_prompt.visible_blocker); +} + +#[test] fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() { assert!(parse_manifest( r#" diff --git a/src/detect/manifests/devin.toml b/src/detect/manifests/devin.toml new file mode 100644 index 00000000..c9564f7c --- /dev/null +++ b/src/detect/manifests/devin.toml @@ -0,0 +1,86 @@ +id = "devin" +version = "2026.06.15.1" +min_engine_version = 1 +updated_at = "2026-06-15T00:00:00Z" +aliases = ["devin-cli", "devin cli"] + +[[rules]] +id = "workspace_trust_prompt" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", +] + +[[rules]] +id = "permission_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["approve once", "select", "confirm", "esc cancel"] + +[[rules]] +id = "running_tools_footer" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["running tools", "esc to interrupt"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "guide_while_working" +state = "working" +priority = 190 +region = "bottom_non_empty_lines(6)" +visible_working = true +contains = ["guide devin while it works"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "tool_reading_timeout" +state = "working" +priority = 180 +region = "bottom_non_empty_lines(8)" +visible_working = true +contains = ["reading shell ", "timeout:"] +not = [ + { contains = ["approve once", "esc cancel"] }, +] + +[[rules]] +id = "welcome_prompt_footer" +state = "idle" +priority = 120 +region = "bottom_non_empty_lines(8)" +visible_idle = true +contains = ["ask devin to build", "features, fix bugs", "your code"] +line_regex = ['^\s*❭ Ask Devin to build'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] + +[[rules]] +id = "live_prompt_footer" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +visible_idle = true +contains = ["context:"] +line_regex = ['^\s*❭'] +not = [ + { contains = ["approve once", "esc cancel"] }, + { contains = ["running tools", "esc to interrupt"] }, + { contains = ["guide devin while it works"] }, +] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 6a8a25c5..66443877 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -46,6 +46,7 @@ pub enum Agent { Codex, Gemini, Cursor, + Devin, Antigravity, Cline, OpenCode, @@ -61,12 +62,13 @@ pub enum Agent { } impl Agent { - pub const ALL: [Self; 17] = [ + pub const ALL: [Self; 18] = [ Self::Pi, Self::Claude, Self::Codex, Self::Gemini, Self::Cursor, + Self::Devin, Self::Antigravity, Self::Cline, Self::OpenCode, @@ -89,6 +91,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Codex => "codex", Agent::Gemini => "gemini", Agent::Cursor => "cursor", + Agent::Devin => "devin", Agent::Antigravity => "agy", Agent::Cline => "cline", Agent::OpenCode => "opencode", @@ -112,6 +115,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "codex" => Some(Agent::Codex), "gemini" => Some(Agent::Gemini), "cursor" | "cursor-agent" => Some(Agent::Cursor), + "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), "opencode" | "open-code" => Some(Agent::OpenCode), @@ -139,6 +143,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "codex" => Some(Agent::Codex), "gemini" => Some(Agent::Gemini), "cursor" | "cursor-agent" => Some(Agent::Cursor), + "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), "cline" => Some(Agent::Cline), "opencode" | "open-code" => Some(Agent::OpenCode), @@ -582,6 +587,8 @@ mod tests { assert_eq!(identify_agent("gemini"), Some(Agent::Gemini)); assert_eq!(identify_agent("cursor"), Some(Agent::Cursor)); assert_eq!(identify_agent("cursor-agent"), Some(Agent::Cursor)); + assert_eq!(identify_agent("devin"), Some(Agent::Devin)); + assert_eq!(identify_agent("devin-cli"), Some(Agent::Devin)); assert_eq!(identify_agent("agy"), Some(Agent::Antigravity)); assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity)); assert_eq!(identify_agent("cline"), Some(Agent::Cline)); @@ -606,6 +613,7 @@ mod tests { assert_eq!(parse_agent_label("pi"), Some(Agent::Pi)); assert_eq!(parse_agent_label("claude"), Some(Agent::Claude)); assert_eq!(parse_agent_label("cursor-agent"), Some(Agent::Cursor)); + assert_eq!(parse_agent_label("devin-cli"), Some(Agent::Devin)); assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity)); assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity)); assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode)); @@ -627,6 +635,7 @@ mod tests { assert_eq!(agent_label(Agent::Pi), "pi"); assert_eq!(agent_label(Agent::GithubCopilot), "copilot"); assert_eq!(agent_label(Agent::OpenCode), "opencode"); + assert_eq!(agent_label(Agent::Devin), "devin"); assert_eq!(agent_label(Agent::Antigravity), "agy"); assert_eq!(agent_label(Agent::Kiro), "kiro"); assert_eq!(agent_label(Agent::Grok), "grok"); @@ -647,6 +656,7 @@ mod tests { assert_eq!(identify_agent("Pi"), Some(Agent::Pi)); assert_eq!(identify_agent("CLAUDE"), Some(Agent::Claude)); assert_eq!(identify_agent("Codex"), Some(Agent::Codex)); + assert_eq!(identify_agent("Devin"), Some(Agent::Devin)); } #[test] diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/src/integration/assets/devin/herdr-agent-state.sh new file mode 100644 index 00000000..83f8c190 --- /dev/null +++ b/src/integration/assets/devin/herdr-agent-state.sh @@ -0,0 +1,173 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=devin +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +action="${1:-}" +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-devin-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true + +case "$action" in + session) ;; + *) exit 0 ;; +esac + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v python3 >/dev/null 2>&1 || exit 0 + +HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +import json +import os +import random +import socket +import subprocess +import time + +SOURCE = "herdr:devin" +AGENT = "devin" + + +def load_hook_input(path: str | None) -> dict: + if not path: + return {} + try: + with open(path, encoding="utf-8") as handle: + content = handle.read() + if not content.strip(): + return {} + parsed = json.loads(content) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def load_session_list(project_dir: str | None): + injected = os.environ.get("HERDR_DEVIN_LIST_JSON") + if injected is not None: + try: + parsed = json.loads(injected) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + + cmd = ["devin", "list", "--format", "json"] + try: + result = subprocess.run( + cmd, + cwd=project_dir or None, + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except Exception: + return [] + + if result.returncode != 0: + return [] + + try: + parsed = json.loads(result.stdout) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + + +def normalize_path(path: str | None) -> str | None: + if not isinstance(path, str) or not path: + return None + try: + return os.path.realpath(path) + except Exception: + return path + + +def hook_session_id(hook_input: dict) -> str | None: + for key in ("session_id", "sessionId"): + value = hook_input.get(key) + if isinstance(value, str) and value: + return value + return None + + +def hook_event_name(hook_input: dict) -> str: + value = hook_input.get("hook_event_name") + return value if isinstance(value, str) else "" + + +def allow_session_list_fallback(hook_input: dict) -> bool: + event = hook_event_name(hook_input) + if event == "UserPromptSubmit": + return False + if event == "SessionStart" and hook_input.get("source") == "startup": + return False + return True + + +def resolve_session_id(project_dir: str, hook_input: dict) -> str | None: + direct = hook_session_id(hook_input) + if direct: + return direct + if not allow_session_list_fallback(hook_input): + return None + + entries = load_session_list(project_dir) + project_dir = normalize_path(project_dir) + for entry in entries: + if not isinstance(entry, dict): + continue + session_id = entry.get("id") + if not isinstance(session_id, str) or not session_id: + continue + working_directory = normalize_path(entry.get("working_directory")) + if working_directory == project_dir: + return session_id + return None + + +pane_id = os.environ.get("HERDR_PANE_ID") +socket_path = os.environ.get("HERDR_SOCKET_PATH") +project_dir = os.environ.get("DEVIN_PROJECT_DIR") or os.getcwd() +hook_input = load_hook_input(os.environ.get("HERDR_HOOK_INPUT_FILE")) + +if not pane_id or not socket_path: + raise SystemExit(0) + +request_id = f"{SOURCE}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() + +session_id = resolve_session_id(project_dir, hook_input) +if not session_id: + raise SystemExit(0) +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": SOURCE, + "agent": AGENT, + "agent_session_id": session_id, + "seq": report_seq, + }, +} + +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(0.5) + client.connect(socket_path) + client.sendall((json.dumps(request) + "\n").encode()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/mod.rs b/src/integration/mod.rs index ab1e9e2d..234d1b10 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -92,6 +92,25 @@ const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ "notification", "sessionStart", ]; +const DEVIN_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const DEVIN_HOOK_ASSET: &str = include_str!("assets/devin/herdr-agent-state.sh"); +const DEVIN_INTEGRATION_VERSION: u32 = 1; +const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ + ("SessionStart", "session"), + ("UserPromptSubmit", "session"), + ("PreToolUse", "session"), + ("PostToolUse", "session"), + ("PermissionRequest", "session"), + ("Stop", "session"), +]; +const DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 6] = [ + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("SessionEnd", "release"), +]; const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -186,6 +205,12 @@ pub(crate) struct CopilotInstallPaths { } #[derive(Debug)] +pub(crate) struct DevinInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct DroidInstallPaths { pub hook_path: PathBuf, pub hooks_path: PathBuf, @@ -331,6 +356,14 @@ pub(crate) struct CopilotUninstallResult { } #[derive(Debug)] +pub(crate) struct DevinUninstallResult { + pub hook_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_settings: bool, +} + +#[derive(Debug)] pub(crate) struct DroidUninstallResult { pub hook_path: PathBuf, pub hooks_path: PathBuf, @@ -532,6 +565,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Devin => { + let installed = install_devin()?; + vec![ + format!( + "installed devin integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured devin settings at {}", + installed.settings_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Kimi => { let installed = install_kimi()?; vec![ @@ -739,6 +785,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Devin => { + let result = uninstall_devin()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed devin hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no devin hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr devin hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr devin hook entries found in {}", + result.settings_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Kimi => { let result = uninstall_kimi()?; let mut messages = Vec::new(); @@ -928,6 +1001,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Devin => "devin", crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", @@ -951,6 +1025,7 @@ fn integration_target_command_names( crate::api::schema::IntegrationTarget::Claude => &["claude"], crate::api::schema::IntegrationTarget::Codex => &["codex"], crate::api::schema::IntegrationTarget::Copilot => &["copilot"], + crate::api::schema::IntegrationTarget::Devin => &["devin"], crate::api::schema::IntegrationTarget::Droid => &["droid"], crate::api::schema::IntegrationTarget::Kimi => &["kimi"], crate::api::schema::IntegrationTarget::Opencode => &["opencode"], @@ -1162,7 +1237,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 12] { +); 13] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -1190,6 +1265,11 @@ fn integration_specs() -> [( COPILOT_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Devin, + devin_dir().map(|dir| dir.join(DEVIN_HOOK_INSTALL_NAME)), + DEVIN_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Droid, droid_dir().map(|dir| dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)), DROID_INTEGRATION_VERSION, @@ -1582,6 +1662,62 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { }) } +pub(crate) fn install_devin() -> io::Result<DevinInstallPaths> { + let dir = devin_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "devin config directory not found at {}. install devin cli first", + dir.display() + ))); + } + + let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME); + fs::write(&hook_path, DEVIN_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let settings_path = dir.join("config.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, + "devin settings", + "devin settings hooks", + )?; + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + None, + )?; + } + remove_legacy_bash_hook_file(&hook_path)?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(DevinInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { let dir = droid_dir()?; if !dir.is_dir() { @@ -1941,6 +2077,51 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { }) } +pub(crate) fn uninstall_devin() -> io::Result<DevinUninstallResult> { + let devin_dir = devin_dir()?; + let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME); + let settings_path = devin_dir.join("config.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, + "devin settings", + "devin settings hooks", + )? { + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DEVIN_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = + remove_file_if_exists(&hook_path)? | remove_legacy_bash_hook_file(&hook_path)?; + + Ok(DevinUninstallResult { + hook_path, + settings_path, + removed_hook_file, + updated_settings, + }) +} + pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { let droid_dir = droid_dir()?; let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME); @@ -3201,6 +3382,14 @@ fn copilot_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) } +fn devin_dir() -> io::Result<PathBuf> { + if let Some(value) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { + return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("devin")); + } + + Ok(home_dir()?.join(".config").join("devin")) +} + fn droid_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".factory")) } @@ -3387,6 +3576,7 @@ mod tests { std::env::remove_var(CODEX_HOME_ENV_VAR); std::env::remove_var(COPILOT_HOME_ENV_VAR); std::env::remove_var(KIMI_CODE_HOME_ENV_VAR); + std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); } @@ -3462,6 +3652,7 @@ mod tests { assert!(!integration_target_supported(IntegrationTarget::Kilo)); assert!(!integration_target_supported(IntegrationTarget::Hermes)); assert!(!integration_target_supported(IntegrationTarget::Cursor)); + assert!(!integration_target_supported(IntegrationTarget::Devin)); assert!(integration_target_supported(IntegrationTarget::Claude)); assert!(integration_target_supported(IntegrationTarget::Codex)); @@ -3489,6 +3680,7 @@ mod tests { fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("hermes.exe"), "").unwrap(); fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap(); + fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap(); assert!(!integration_target_available(IntegrationTarget::Pi)); assert!(!integration_target_available(IntegrationTarget::Omp)); @@ -3496,6 +3688,7 @@ mod tests { assert!(!integration_target_available(IntegrationTarget::Kilo)); assert!(!integration_target_available(IntegrationTarget::Hermes)); assert!(!integration_target_available(IntegrationTarget::Cursor)); + assert!(!integration_target_available(IntegrationTarget::Devin)); if let Some(path) = original_path { std::env::set_var("PATH", path); @@ -4933,6 +5126,241 @@ mod tests { } #[test] + fn install_devin_writes_hook_and_updates_settings() { + let _lock = integration_env_lock(); + let base = unique_base(); + let xdg_config = base.join("xdg"); + let devin_dir = xdg_config.join("devin"); + fs::create_dir_all(&devin_dir).unwrap(); + fs::write( + devin_dir.join("config.json"), + r#"{"theme_mode":"dark","hooks":{}}"#, + ) + .unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &xdg_config); + std::env::set_var("HOME", base.join("home")); + + let installed = install_devin().unwrap(); + let hook_content = fs::read_to_string(&installed.hook_path).unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + + assert_eq!(installed.hook_path, devin_dir.join(DEVIN_HOOK_INSTALL_NAME)); + assert_eq!(installed.settings_path, devin_dir.join("config.json")); + assert_eq!(hook_content, DEVIN_HOOK_ASSET); + assert_eq!(settings["theme_mode"], "dark"); + for (event, action) in DEVIN_HOOK_EVENTS { + let command = settings["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert!( + command.contains(DEVIN_HOOK_INSTALL_NAME) && command.ends_with(action), + "expected devin {event} hook command to end with {action}, got {command}" + ); + } + + clear_integration_path_env(); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_devin_is_idempotent_for_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let xdg_config = base.join("xdg"); + let devin_dir = xdg_config.join("devin"); + fs::create_dir_all(&devin_dir).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &xdg_config); + std::env::set_var("HOME", base.join("home")); + + install_devin().unwrap(); + install_devin().unwrap(); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()) + .unwrap(); + for (event, _) in DEVIN_HOOK_EVENTS { + assert_eq!( + settings["hooks"][event].as_array().unwrap().len(), + 1, + "expected hooks.{event} to be idempotent" + ); + } + + clear_integration_path_env(); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_devin_removes_legacy_lifecycle_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let xdg_config = base.join("xdg"); + let devin_dir = xdg_config.join("devin"); + fs::create_dir_all(&devin_dir).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &xdg_config); + std::env::set_var("HOME", base.join("home")); + + let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME); + let mut hooks = Map::new(); + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + hooks.insert( + event.to_string(), + json!([ + { + "hooks": [{ + "type": "command", + "command": hook_command(&hook_path, Some(action)), + "timeout": 10 + }] + } + ]), + ); + } + fs::write( + devin_dir.join("config.json"), + serde_json::to_string_pretty(&json!({ "hooks": hooks })).unwrap(), + ) + .unwrap(); + + install_devin().unwrap(); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()) + .unwrap(); + for (event, action) in DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS { + let legacy_command = hook_command(&hook_path, Some(action)); + let entries = settings["hooks"][event].as_array(); + assert!( + entries.is_none_or(|entries| { + entries.iter().all(|entry| { + entry + .get("hooks") + .and_then(Value::as_array) + .is_none_or(|hooks| { + hooks.iter().all(|hook| { + hook.get("command").and_then(Value::as_str) + != Some(legacy_command.as_str()) + }) + }) + }) + }), + "expected legacy devin {event} -> {action} hook to be removed" + ); + + if !DEVIN_HOOK_EVENTS + .iter() + .any(|(installed_event, _)| installed_event == &event) + { + continue; + } + + let session_command = hook_command(&hook_path, Some("session")); + let entries = entries.unwrap(); + assert!( + entries.iter().any(|entry| { + entry + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command").and_then(Value::as_str) + == Some(session_command.as_str()) + }) + }) + }), + "expected devin {event} session hook to be installed" + ); + } + + clear_integration_path_env(); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_devin_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let xdg_config = base.join("xdg"); + let devin_dir = xdg_config.join("devin"); + fs::create_dir_all(&devin_dir).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &xdg_config); + std::env::set_var("HOME", base.join("home")); + + install_devin().unwrap(); + + let hook_path = devin_dir.join(DEVIN_HOOK_INSTALL_NAME); + let mut settings: Value = + serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()) + .unwrap(); + settings["hooks"]["UserPromptSubmit"] + .as_array_mut() + .unwrap() + .push(json!({ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": "echo keep", + "timeout": 10 + }] + })); + fs::write( + devin_dir.join("config.json"), + serde_json::to_string_pretty(&settings).unwrap(), + ) + .unwrap(); + + let result = uninstall_devin().unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(devin_dir.join("config.json")).unwrap()) + .unwrap(); + + assert!(result.removed_hook_file); + assert!(result.updated_settings); + assert!(!hook_path.exists()); + assert_eq!( + settings["hooks"]["UserPromptSubmit"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo keep" + ); + assert!(settings["hooks"].get("SessionStart").is_none()); + assert!(settings["hooks"].get("PreToolUse").is_none()); + assert!(settings["hooks"].get("PermissionRequest").is_none()); + assert!(settings["hooks"].get("Stop").is_none()); + assert!(settings["hooks"].get("SessionEnd").is_none()); + + clear_integration_path_env(); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_devin_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let xdg_config = base.join("xdg"); + fs::create_dir_all(&xdg_config).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &xdg_config); + std::env::set_var("HOME", base.join("home")); + + let err = install_devin().unwrap_err().to_string(); + assert!(err.contains("devin config directory not found")); + + clear_integration_path_env(); + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_droid_writes_hook_to_settings_and_cleans_legacy_hooks_json() { let _lock = integration_env_lock(); let base = unique_base(); @@ -5561,6 +5989,12 @@ mod tests { assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session")); assert!(!COPILOT_HOOK_ASSET.contains("\"state\":")); assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent")); + assert!(DEVIN_HOOK_ASSET.contains("HERDR_DEVIN_LIST_JSON")); + assert!(DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent_session\"")); + assert!(!DEVIN_HOOK_ASSET.contains("\"method\": \"pane.report_agent\"")); + assert!(!DEVIN_HOOK_ASSET.contains("\"state\":")); + assert!(!DEVIN_HOOK_ASSET.contains("pane.release_agent")); + assert!(DEVIN_HOOK_ASSET.contains("agent_session_id")); assert!(DROID_HOOK_ASSET.contains("agent_session_id")); assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session")); assert!(!DROID_HOOK_ASSET.contains("\"state\": action")); diff --git a/src/main.rs b/src/main.rs index 99428897..d78dd2ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -322,8 +322,8 @@ pane_history = false # Optional allow-list: only reveal for focused panes whose detected agent # matches one of these names. Empty means apply to any focused pane. # If the list contains no valid names, the reveal does not apply. -# Accepted: pi, claude, codex, gemini, cursor, cline, opencode, copilot, -# kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder. +# Accepted: pi, claude, codex, gemini, cursor, devin, cline, opencode, +# copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder. # 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. diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 932e0735..9b9b6d43 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1073,31 +1073,37 @@ mod tests { #[test] fn session_only_report_does_not_create_hook_authority() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + for (agent, source, label, session_id) in [ + (Agent::Codex, "herdr:codex", "codex", "codex-session"), + (Agent::Devin, "herdr:devin", "devin", "devin-session"), + ] { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(agent), AgentState::Idle); - let mutation = terminal.set_agent_session_ref( - "herdr:codex".into(), - "codex".into(), - crate::agent_resume::AgentSessionRef::id("codex-session"), - Some(1), - ); + let mutation = terminal.set_agent_session_ref( + source.into(), + label.into(), + crate::agent_resume::AgentSessionRef::id(session_id), + Some(1), + ); - assert!(mutation.is_some()); - assert!(terminal.hook_authority.is_none()); - assert_eq!(terminal.state, AgentState::Idle); + assert!(mutation.is_some()); + assert!(terminal.hook_authority.is_none()); + assert!(!terminal.full_lifecycle_hook_authority_active()); + assert_eq!(terminal.state, AgentState::Idle); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Codex), - AgentState::Working, - false, - false, - false, - false, - Instant::now(), - ); + terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Working, + false, + false, + false, + false, + Instant::now(), + ); - assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Working); + } } #[test] |
