diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-13 03:00:35 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-13 03:00:35 +0300 |
| commit | a4d52ab60282b2ad021cb90978a7ed4d3898704b (patch) | |
| tree | cdd5f55d6d392d54541b86da7f1a00a48c211d03 /src | |
| parent | 8b635c918066dd184bacc29b4c06c40a3efea3f5 (diff) | |
feat: add qwen code detection and restore (#2743)
refs #2730
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 31 | ||||
| -rw-r--r-- | src/api/schema/integrations.rs | 4 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/config/model.rs | 2 | ||||
| -rw-r--r-- | src/config/sidebar.rs | 1 | ||||
| -rw-r--r-- | src/config/sound.rs | 3 | ||||
| -rw-r--r-- | src/detect/manifest.rs | 1 | ||||
| -rw-r--r-- | src/detect/manifests/qwen.toml | 89 | ||||
| -rw-r--r-- | src/detect/mod.rs | 54 | ||||
| -rw-r--r-- | src/integration/actions.rs | 44 | ||||
| -rw-r--r-- | src/integration/assets/qwen/herdr-agent-session.ps1 | 37 | ||||
| -rw-r--r-- | src/integration/assets/qwen/herdr-agent-session.sh | 48 | ||||
| -rw-r--r-- | src/integration/env.rs | 5 | ||||
| -rw-r--r-- | src/integration/mod.rs | 12 | ||||
| -rw-r--r-- | src/integration/registry.rs | 10 | ||||
| -rw-r--r-- | src/integration/targets.rs | 97 | ||||
| -rw-r--r-- | src/integration/tests.rs | 104 | ||||
| -rw-r--r-- | src/integration/types.rs | 14 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/terminal/state.rs | 71 |
20 files changed, 623 insertions, 16 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index e2fc47e9..cd1ae7bb 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -71,9 +71,10 @@ pub fn session_ref_from_report( pub fn normalize_session_start_source(value: Option<String>) -> Option<String> { match value.as_deref().map(str::trim) { - Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork" | "select")) => { - Some(source.to_string()) - } + Some( + source @ ("startup" | "resume" | "clear" | "compact" | "branch" | "new" | "fork" + | "select"), + ) => Some(source.to_string()), _ => None, } } @@ -87,6 +88,7 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { | ("herdr:devin", "devin") | ("herdr:droid", "droid") | ("herdr:qodercli", "qodercli") + | ("herdr:qwen", "qwen") | ("herdr:cursor", "cursor") | ("herdr:grok", "grok") ) @@ -177,6 +179,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< session_ref.value.clone(), ] } + ("herdr:qwen", "qwen", AgentSessionRefKind::Id) => { + vec!["qwen".into(), "--resume".into(), session_ref.value.clone()] + } ("herdr:kilo", "kilo", AgentSessionRefKind::Id) => { vec!["kilo".into(), "--session".into(), session_ref.value.clone()] } @@ -234,6 +239,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") | ("herdr:qodercli", "qodercli") + | ("herdr:qwen", "qwen") | ("herdr:kilo", "kilo") | ("herdr:cursor", "cursor") | ("herdr:antigravity_cli", "agy") @@ -402,6 +408,16 @@ mod tests { ); assert_eq!( plan( + "herdr:qwen", + "qwen", + &AgentSessionRef::id("qwen-session").unwrap() + ) + .unwrap() + .argv, + vec!["qwen", "--resume", "qwen-session"] + ); + assert_eq!( + plan( "herdr:kilo", "kilo", &AgentSessionRef::id("kilo-session").unwrap() @@ -576,6 +592,11 @@ mod tests { assert_eq!(session_ref.value, "qoder-id"); let session_ref = + session_ref_from_report("herdr:qwen", "qwen", Some("qwen-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "qwen-id"); + + let session_ref = session_ref_from_report("herdr:antigravity_cli", "agy", Some("agy-id".into()), None) .unwrap(); assert_eq!(session_ref.kind, AgentSessionRefKind::Id); @@ -601,6 +622,10 @@ mod tests { Some("compact".into()) ); assert_eq!( + normalize_session_start_source(Some("branch".into())), + Some("branch".into()) + ); + assert_eq!( normalize_session_start_source(Some("new".into())), Some("new".into()) ); diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index ef55f8e2..6151dedb 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -25,6 +25,7 @@ pub enum IntegrationTarget { Kilo, Hermes, Qodercli, + Qwen, Cursor, Mastracode, AntigravityCli, @@ -32,7 +33,7 @@ pub enum IntegrationTarget { } impl IntegrationTarget { - pub(crate) const ALL: [Self; 16] = [ + pub(crate) const ALL: [Self; 17] = [ Self::Pi, Self::Omp, Self::Claude, @@ -45,6 +46,7 @@ impl IntegrationTarget { Self::Kilo, Self::Hermes, Self::Qodercli, + Self::Qwen, Self::Cursor, Self::Mastracode, Self::AntigravityCli, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 27620590..d0a53f7b 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -110,13 +110,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|devin|droid|kimi|opencode|kilo|hermes|qodercli|cursor|mastracode|grok>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|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|cursor|mastracode|grok>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|devin|droid|kimi|opencode|kilo|hermes|qodercli|qwen|cursor|mastracode|grok>" ); return Ok(None); } @@ -134,6 +134,7 @@ fn parse_integration_target( "kilo" => IntegrationTarget::Kilo, "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, + "qwen" => IntegrationTarget::Qwen, "cursor" => IntegrationTarget::Cursor, "mastracode" => IntegrationTarget::Mastracode, "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, @@ -141,7 +142,7 @@ fn parse_integration_target( _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, antigravity-cli, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, cursor, mastracode, antigravity-cli, grok" ); return Ok(None); } @@ -164,6 +165,7 @@ fn print_integration_help() { eprintln!(" herdr integration install kilo"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); + eprintln!(" herdr integration install qwen"); eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); eprintln!(" herdr integration install antigravity-cli"); @@ -180,6 +182,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall kilo"); eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); + eprintln!(" herdr integration uninstall qwen"); eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); eprintln!(" herdr integration uninstall antigravity-cli"); diff --git a/src/config/model.rs b/src/config/model.rs index 9b5904fb..6d6083ad 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -989,7 +989,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, maki. + /// qodercli, qoder, qwen, qwen-code, maki. /// Default: empty. pub cjk_ime_agents: Vec<String>, /// Cursor shape rendered for the IME anchor when diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index f7201134..c548c839 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -621,6 +621,7 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru Agent::Hermes, Agent::Kilo, Agent::Qodercli, + Agent::Qwen, Agent::Maki, ]; let entries = agents diff --git a/src/config/sound.rs b/src/config/sound.rs index 217737d8..c84f0d59 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -43,6 +43,7 @@ pub struct AgentSoundOverrides { pub hermes: AgentSoundSetting, pub kilo: AgentSoundSetting, pub qodercli: AgentSoundSetting, + pub qwen: AgentSoundSetting, pub maki: AgentSoundSetting, } @@ -139,6 +140,7 @@ impl AgentSoundOverrides { Some(Agent::Hermes) => self.hermes, Some(Agent::Kilo) => self.kilo, Some(Agent::Qodercli) => self.qodercli, + Some(Agent::Qwen) => self.qwen, Some(Agent::Maki) => self.maki, None => AgentSoundSetting::Default, } @@ -178,6 +180,7 @@ impl Default for AgentSoundOverrides { hermes: AgentSoundSetting::Default, kilo: AgentSoundSetting::Default, qodercli: AgentSoundSetting::Default, + qwen: AgentSoundSetting::Default, maki: AgentSoundSetting::Default, } } diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index ed3de507..b9fe786a 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -255,6 +255,7 @@ const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ ("opencode", include_str!("manifests/opencode.toml")), ("pi", include_str!("manifests/pi.toml")), ("qodercli", include_str!("manifests/qodercli.toml")), + ("qwen", include_str!("manifests/qwen.toml")), ("copilot", include_str!("manifests/github-copilot.toml")), ]; diff --git a/src/detect/manifests/qwen.toml b/src/detect/manifests/qwen.toml new file mode 100644 index 00000000..52ab242a --- /dev/null +++ b/src/detect/manifests/qwen.toml @@ -0,0 +1,89 @@ +id = "qwen" +version = "2026.08.12.1" +min_engine_version = 2 +updated_at = "2026-08-12T00:00:00Z" +aliases = ["qwen-code", "qwen code"] + +# Qwen Code keeps its composer visible while responding, so the prompt box is +# not idle evidence by itself. Active turns show an "esc to cancel" status +# line; confirmation flows replace that line with a stable waiting phrase. +# OSC 9;4 is supplemental because Qwen emits it only while a tool is executing +# and only in terminals that advertise progress support. + +[[rules]] +id = "waiting_for_confirmation" +state = "blocked" +priority = 1000 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["waiting for user confirmation"] + +[[rules]] +id = "tool_confirmation" +state = "blocked" +priority = 990 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["yes, allow once"] +any = [ + { contains = ["apply this change?"] }, + { contains = ["allow execution of:"] }, + { contains = ["allow execution of mcp tool"] }, + { contains = ["do you want to proceed?"] }, + { contains = ["shell command execution"] }, +] + +[[rules]] +id = "question_dialog" +state = "blocked" +priority = 980 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["enter: select", "esc: cancel"] +line_regex = ['^\s*[❯›]\s*1\.\s+'] + +[[rules]] +id = "folder_trust_dialog" +state = "blocked" +priority = 970 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 900 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$'] + +[[rules]] +id = "narrow_cancel_hint_working" +state = "working" +priority = 890 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['(?i)^\s*\(esc to cancel\)\s*$'] + +[[rules]] +id = "osc_tool_progress_working" +state = "working" +priority = 850 +region = "osc_progress" +visible_working = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(30)" +visible_idle = true +line_regex = ['^\s*>\s*(?:type\s*)?.*$'] +any = [ + { contains = ["type your message"] }, + { contains = ["your message or @path/to/file"] }, + { contains = ["@path/to/file"] }, + { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] }, +] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index b9f9655f..f78567f6 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -61,11 +61,12 @@ pub enum Agent { Hermes, Kilo, Qodercli, + Qwen, Maki, } impl Agent { - pub const ALL: [Self; 21] = [ + pub const ALL: [Self; 22] = [ Self::Pi, Self::Claude, Self::Codex, @@ -86,10 +87,11 @@ impl Agent { Self::Hermes, Self::Kilo, Self::Qodercli, + Self::Qwen, Self::Maki, ]; - pub const SCREEN_MANIFEST_AGENTS: [Self; 19] = [ + pub const SCREEN_MANIFEST_AGENTS: [Self; 20] = [ Self::Pi, Self::Claude, Self::Codex, @@ -108,6 +110,7 @@ impl Agent { Self::Hermes, Self::Kilo, Self::Qodercli, + Self::Qwen, Self::Maki, ]; } @@ -134,6 +137,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Hermes => "hermes", Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", + Agent::Qwen => "qwen", Agent::Maki => "maki", } } @@ -166,6 +170,7 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Hermes => "hermes", Agent::Kilo => "kilo", Agent::Qodercli => "qodercli", + Agent::Qwen => "qwen", Agent::Maki => "maki", } } @@ -202,6 +207,7 @@ fn lookup_agent(name: &str) -> Option<Agent> { "hermes" | "hermes-agent" => Some(Agent::Hermes), "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), + "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), "maki" => Some(Agent::Maki), _ => None, } @@ -301,7 +307,7 @@ 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:antigravity_cli", "agy") + ("herdr:hermes", "hermes") | ("herdr:qwen", "qwen") | ("herdr:antigravity_cli", "agy") ) } @@ -345,6 +351,19 @@ fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> Stri return effective.to_string(); } + if let Some(runtime) = process.argv.as_deref().and_then(|argv| argv.first()) { + let runtime_name = normalized_agent_lookup_name(path_basename(runtime)); + if matches!(runtime_name.as_str(), "node" | "bun") { + if let Some(wrapped_agent) = + wrapped_agent_name_from_runtime_argv(runtime, process.argv.as_deref()) + { + if identify_agent(&wrapped_agent) == Some(Agent::Qwen) { + return wrapped_agent; + } + } + } + } + if let Some(wrapped_agent) = argv0_agent_name(process.argv.as_deref()) .or_else(|| cmdline_argv0_agent_name(process.cmdline.as_deref().unwrap_or_default())) { @@ -550,6 +569,9 @@ fn agent_name_from_known_package_path(path: &str) -> Option<String> { { return Some(agent_label(Agent::Pi).to_string()); } + if window == ["node_modules", "@qwen-code", "qwen-code", "dist", "index"] { + return Some(agent_label(Agent::Qwen).to_string()); + } } for window in components.windows(4) { if window == ["node_modules", "mastracode", "dist", "cli"] { @@ -709,6 +731,8 @@ mod tests { assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes)); assert_eq!(identify_agent("kilo"), Some(Agent::Kilo)); 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("maki"), Some(Agent::Maki)); } @@ -734,6 +758,7 @@ mod tests { assert_eq!(parse_agent_label("kiro-cli"), Some(Agent::Kiro)); 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("maki"), Some(Agent::Maki)); assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo)); } @@ -777,6 +802,7 @@ mod tests { (Agent::Hermes, "hermes"), (Agent::Kilo, "kilo"), (Agent::Qodercli, "qodercli"), + (Agent::Qwen, "qwen"), (Agent::Maki, "maki"), ]; assert_eq!(expected.len(), Agent::ALL.len()); @@ -806,6 +832,7 @@ mod tests { fn session_identity_integrations_leave_state_to_screen_detection() { for (source, label, agent) in [ ("herdr:hermes", "hermes", Agent::Hermes), + ("herdr:qwen", "qwen", Agent::Qwen), ("herdr:antigravity_cli", "agy", Agent::Antigravity), ] { assert!(!full_lifecycle_hook_authority(source, label)); @@ -847,6 +874,27 @@ mod tests { } #[test] + fn identify_agent_in_job_detects_node_wrapped_qwen() { + for argv in [ + vec!["node", "/home/user/.fnm/bin/qwen"], + vec![ + "node.exe", + r"C:\Users\user\AppData\Roaming\npm\node_modules\@qwen-code\qwen-code\dist\index.js", + ], + ] { + 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::Qwen, "qwen".to_string())) + ); + } + } + + #[test] fn identify_agent_in_job_prefers_recognized_process_group_leader() { let job = crate::platform::ForegroundJob { process_group_id: 42, diff --git a/src/integration/actions.rs b/src/integration/actions.rs index 00bf8c8f..24d1f9e3 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_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_pi, uninstall_qodercli, uninstall_qwen, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -192,6 +192,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::Qwen => { + let installed = install_qwen()?; + vec![ + format!( + "installed qwen integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured qwen settings at {}", + installed.settings_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Cursor => { let installed = install_cursor()?; vec![ @@ -559,6 +572,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Qwen => { + let result = uninstall_qwen()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed qwen hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no qwen hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr qwen hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr qwen hook entries found in {}", + result.settings_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Cursor => { let result = uninstall_cursor()?; let mut messages = Vec::new(); diff --git a/src/integration/assets/qwen/herdr-agent-session.ps1 b/src/integration/assets/qwen/herdr-agent-session.ps1 new file mode 100644 index 00000000..882caf86 --- /dev/null +++ b/src/integration/assets/qwen/herdr-agent-session.ps1 @@ -0,0 +1,37 @@ +# 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=qwen +# 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.session_id)) { exit 0 } + +$seq = [DateTime]::UtcNow.Ticks +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$commandArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:qwen", "--agent", "qwen", + "--agent-session-id", [string]$payload.session_id, + "--seq", [string]$seq +) +if ($payload.source -in @("startup", "resume", "clear", "compact", "branch")) { + $commandArgs += @("--session-start-source", [string]$payload.source) +} +try { + & $herdr @commandArgs 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/qwen/herdr-agent-session.sh b/src/integration/assets/qwen/herdr-agent-session.sh new file mode 100644 index 00000000..226c2a97 --- /dev/null +++ b/src/integration/assets/qwen/herdr-agent-session.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=qwen +# 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 + +python3 -c ' +import json +import os +import subprocess +import sys +import time + +try: + payload = json.load(sys.stdin) + session_id = payload.get("session_id") + source = payload.get("source") + if not isinstance(session_id, str) or not session_id: + raise ValueError + command = os.environ.get("HERDR_BIN_PATH") or "herdr" + args = [ + command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"], + "--source", "herdr:qwen", "--agent", "qwen", + "--agent-session-id", session_id, "--seq", str(time.time_ns()), + ] + if source in ("startup", "resume", "clear", "compact", "branch"): + args.extend(["--session-start-source", source]) + 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 e86f6ee8..8f3a9e65 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -16,6 +16,7 @@ pub(crate) const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; pub(crate) const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; pub(crate) const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; +pub(crate) const QWEN_HOME_ENV_VAR: &str = "QWEN_HOME"; pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR"; pub(crate) const ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR: &str = "ANTIGRAVITY_CLI_CONFIG_DIR"; pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR"; @@ -157,6 +158,10 @@ pub(crate) fn qodercli_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"]) } +pub(crate) fn qwen_dir() -> io::Result<PathBuf> { + config_dir_from_env_or_home(QWEN_HOME_ENV_VAR, &[".qwen"]) +} + 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 597aabc5..c87ccac6 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -192,6 +192,18 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { }; const QODERCLI_INTEGRATION_VERSION: u32 = 3; const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const QWEN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-session.ps1" +} else { + "herdr-agent-session.sh" +}; +const QWEN_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/qwen/herdr-agent-session.ps1") +} else { + include_str!("assets/qwen/herdr-agent-session.sh") +}; +const QWEN_INTEGRATION_VERSION: u32 = 1; +const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; 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 b7bb3bf2..46251970 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -20,6 +20,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Kilo => "kilo", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", + crate::api::schema::IntegrationTarget::Qwen => "qwen", crate::api::schema::IntegrationTarget::Cursor => "cursor", crate::api::schema::IntegrationTarget::Mastracode => "mastracode", crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", @@ -49,6 +50,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), + crate::api::schema::IntegrationTarget::Qwen => &["qwen"], crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], @@ -75,6 +77,7 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati | crate::api::schema::IntegrationTarget::Droid | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli + | crate::api::schema::IntegrationTarget::Qwen | crate::api::schema::IntegrationTarget::AntigravityCli | crate::api::schema::IntegrationTarget::Devin | crate::api::schema::IntegrationTarget::Hermes @@ -264,7 +267,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 16] { +); 17] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -330,6 +333,11 @@ fn integration_specs() -> [( super::QODERCLI_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Qwen, + qwen_dir().map(|dir| dir.join("hooks").join(super::QWEN_HOOK_INSTALL_NAME)), + super::QWEN_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Cursor, cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), super::CURSOR_INTEGRATION_VERSION, diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 375d2713..0150ccaa 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -20,7 +20,7 @@ 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, pi_extension_dir, qodercli_dir, + opencode_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,6 +37,7 @@ use super::types::{ KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, 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, @@ -55,6 +56,7 @@ use super::{ OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_SPEC, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, + QWEN_HOOK_ASSET, QWEN_HOOK_EVENTS, QWEN_HOOK_INSTALL_NAME, }; fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { @@ -945,6 +947,59 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { }) } +pub(crate) fn install_qwen() -> io::Result<QwenInstallPaths> { + let dir = qwen_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "qwen code config directory not found at {}. install qwen code first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(QWEN_HOOK_INSTALL_NAME); + fs::write(&hook_path, QWEN_HOOK_ASSET)?; + make_executable(&hook_path)?; + + 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, + "qwen settings", + "qwen settings hooks", + )?; + for (event, action) in QWEN_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10_000, + Some("*"), + )?; + } + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + Ok(QwenInstallPaths { + hook_path, + settings_path, + }) +} + pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> { let dir = cursor_dir()?; if !dir.is_dir() { @@ -1047,6 +1102,46 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { }) } +pub(crate) fn uninstall_qwen() -> io::Result<QwenUninstallResult> { + let hook_path = qwen_dir()?.join("hooks").join(QWEN_HOOK_INSTALL_NAME); + let settings_path = qwen_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, + "qwen settings", + "qwen settings hooks", + )? { + for (event, action) in QWEN_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)?; + + Ok(QwenUninstallResult { + 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 324ae6ce..d54183f2 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -101,6 +101,7 @@ fn clear_integration_path_env() { 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(QWEN_HOME_ENV_VAR); std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); @@ -195,6 +196,7 @@ fn windows_supports_portable_integrations() { assert!(integration_target_supported(IntegrationTarget::Droid)); assert!(integration_target_supported(IntegrationTarget::Kimi)); assert!(integration_target_supported(IntegrationTarget::Qodercli)); + assert!(integration_target_supported(IntegrationTarget::Qwen)); } #[cfg(windows)] @@ -3187,6 +3189,108 @@ fn install_qodercli_errors_when_config_dir_missing() { } #[test] +fn install_qwen_writes_session_hook_and_preserves_settings() { + let _lock = integration_env_lock(); + let base = unique_base(); + let qwen_dir = base.join(".qwen"); + fs::create_dir_all(&qwen_dir).unwrap(); + fs::write( + qwen_dir.join("settings.json"), + r#"{"permissions":{"allow":["Read"]},"hooks":{}}"#, + ) + .unwrap(); + std::env::set_var(QWEN_HOME_ENV_VAR, &qwen_dir); + + let installed = install_qwen().unwrap(); + + assert_eq!( + installed.hook_path, + qwen_dir.join("hooks").join(QWEN_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.settings_path, qwen_dir.join("settings.json")); + assert!(installed.hook_path.is_file()); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + let entries = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["matcher"], "*"); + assert_eq!(entries[0]["hooks"][0]["timeout"], 10_000); + let command = entries[0]["hooks"][0]["command"].as_str().unwrap(); + assert!(command.contains(QWEN_HOOK_INSTALL_NAME)); + assert!(command.ends_with("session")); + assert!(settings.get("permissions").is_some()); + let hook_asset = fs::read_to_string(&installed.hook_path).unwrap(); + assert!(hook_asset.contains("HERDR_INTEGRATION_ID=qwen")); + assert!(hook_asset.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(hook_asset.contains("herdr:qwen")); + + install_qwen().unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + assert_eq!( + settings["hooks"]["SessionStart"].as_array().unwrap().len(), + 1 + ); + + std::env::remove_var(QWEN_HOME_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_qwen_removes_only_herdr_hook() { + let _lock = integration_env_lock(); + let base = unique_base(); + let qwen_dir = base.join(".qwen"); + fs::create_dir_all(&qwen_dir).unwrap(); + std::env::set_var(QWEN_HOME_ENV_VAR, &qwen_dir); + + install_qwen().unwrap(); + let settings_path = qwen_dir.join("settings.json"); + let mut settings: Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + settings["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(json!({ + "matcher": "resume", + "hooks": [{"type": "command", "command": "echo user-defined"}], + })); + fs::write( + &settings_path, + serde_json::to_string_pretty(&settings).unwrap(), + ) + .unwrap(); + + let result = uninstall_qwen().unwrap(); + assert!(result.removed_hook_file); + assert!(result.updated_settings); + + let settings: Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + let remaining = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["hooks"][0]["command"], "echo user-defined"); + + std::env::remove_var(QWEN_HOME_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_qwen_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let missing = base.join(".qwen"); + std::env::set_var(QWEN_HOME_ENV_VAR, &missing); + + let err = install_qwen().unwrap_err().to_string(); + assert!(err.contains("qwen code config directory not found")); + + std::env::remove_var(QWEN_HOME_ENV_VAR); + 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 c5c24267..c7eaf795 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -70,6 +70,12 @@ pub(crate) struct QodercliInstallPaths { } #[derive(Debug)] +pub(crate) struct QwenInstallPaths { + pub hook_path: PathBuf, + pub settings_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct CursorInstallPaths { pub hook_path: PathBuf, pub hooks_path: PathBuf, @@ -119,6 +125,14 @@ pub(crate) struct QodercliUninstallResult { pub updated_settings: bool, } +#[derive(Debug)] +pub(crate) struct QwenUninstallResult { + 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, diff --git a/src/main.rs b/src/main.rs index 4d915158..d3b72192 100644 --- a/src/main.rs +++ b/src/main.rs @@ -450,7 +450,8 @@ pane_history = false # 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, devin, cline, opencode, -# copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder. +# copilot, kimi, kiro, droid, amp, grok, hermes, kilo, qodercli, qoder, qwen, +# qwen-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. diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4b1fdaef..5c659eb9 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1334,6 +1334,11 @@ impl TerminalState { "omp", Some("startup" | "new" | "resume" | "fork") ) + | ( + "herdr:qwen", + "qwen", + Some("startup" | "clear" | "resume" | "compact" | "branch") + ) | ("herdr:antigravity_cli", "agy", None) ) } @@ -4552,6 +4557,72 @@ mod tests { } #[test] + fn qwen_lifecycle_session_ref_replaces_existing_session_ref() { + for session_start_source in ["startup", "clear", "resume", "compact", "branch"] { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Qwen), AgentState::Idle); + terminal + .set_agent_session_ref( + "herdr:qwen".into(), + "qwen".into(), + crate::agent_resume::AgentSessionRef::id("qwen-session"), + Some(20), + ) + .expect("initial session should be accepted"); + + let next_session = format!("qwen-{session_start_source}-session"); + let mutation = terminal + .set_agent_session_ref_for_session_start( + "herdr:qwen".into(), + "qwen".into(), + crate::agent_resume::AgentSessionRef::id(&next_session), + Some(21), + Some(session_start_source.into()), + ) + .unwrap_or_else(|| panic!("{session_start_source} should replace the session")); + + assert!(mutation.session_ref_changed); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some(next_session.as_str()) + ); + } + } + + #[test] + fn qwen_session_ref_does_not_replace_without_foreground_qwen() { + let mut terminal = test_terminal(); + terminal + .set_agent_session_ref( + "herdr:qwen".into(), + "qwen".into(), + crate::agent_resume::AgentSessionRef::id("qwen-parent"), + Some(20), + ) + .expect("initial session should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:qwen".into(), + "qwen".into(), + crate::agent_resume::AgentSessionRef::id("qwen-branch"), + Some(21), + Some("branch".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("qwen-parent") + ); + } + + #[test] fn opencode_server_new_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); |
