aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent8b635c918066dd184bacc29b4c06c40a3efea3f5 (diff)
feat: add qwen code detection and restore (#2743)
refs #2730
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs31
-rw-r--r--src/api/schema/integrations.rs4
-rw-r--r--src/cli/integration.rs9
-rw-r--r--src/config/model.rs2
-rw-r--r--src/config/sidebar.rs1
-rw-r--r--src/config/sound.rs3
-rw-r--r--src/detect/manifest.rs1
-rw-r--r--src/detect/manifests/qwen.toml89
-rw-r--r--src/detect/mod.rs54
-rw-r--r--src/integration/actions.rs44
-rw-r--r--src/integration/assets/qwen/herdr-agent-session.ps137
-rw-r--r--src/integration/assets/qwen/herdr-agent-session.sh48
-rw-r--r--src/integration/env.rs5
-rw-r--r--src/integration/mod.rs12
-rw-r--r--src/integration/registry.rs10
-rw-r--r--src/integration/targets.rs97
-rw-r--r--src/integration/tests.rs104
-rw-r--r--src/integration/types.rs14
-rw-r--r--src/main.rs3
-rw-r--r--src/terminal/state.rs71
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);