aboutsummaryrefslogtreecommitdiff
path: root/src/detect
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-13 03:00:35 +0300
committerGitHub <noreply@github.com>2026-08-13 03:00:35 +0300
commita4d52ab60282b2ad021cb90978a7ed4d3898704b (patch)
treecdd5f55d6d392d54541b86da7f1a00a48c211d03 /src/detect
parent8b635c918066dd184bacc29b4c06c40a3efea3f5 (diff)
feat: add qwen code detection and restore (#2743)
refs #2730
Diffstat (limited to 'src/detect')
-rw-r--r--src/detect/manifest.rs1
-rw-r--r--src/detect/manifests/qwen.toml89
-rw-r--r--src/detect/mod.rs54
3 files changed, 141 insertions, 3 deletions
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,