use crate::tmux::agent_state::AgentKind;
pub fn detect_agent_kind(command: &str) -> Option<AgentKind> {
let cmd = command.trim();
if cmd.is_empty() {
return None;
}
let first_token = cmd.split_whitespace().next()?;
let basename = first_token.rsplit(&['/', '\\'][..]).next().unwrap_or(first_token);
let stripped = if let Some(s) = strip_ext(basename, ".exe")
.or_else(|| strip_ext(basename, ".cmd"))
.or_else(|| strip_ext(basename, ".bat"))
{
s
} else {
basename
};
match stripped.to_lowercase().as_str() {
"claude" | "claude-code" => Some(AgentKind::Claude),
"codex" => Some(AgentKind::Codex),
"qoder" => Some(AgentKind::Qoder),
"node" | "nodejs" => {
let args = &cmd[first_token.len()..];
detect_agent_in_node_args(args)
}
_ => None,
}
}
fn detect_agent_in_node_args(args: &str) -> Option<AgentKind> {
for token in args.split_whitespace() {
for segment in token.split(&['/', '\\'][..]) {
let seg_lower = segment.to_lowercase();
match seg_lower.as_str() {
"claude" | "claude-code" => return Some(AgentKind::Claude),
"codex" => return Some(AgentKind::Codex),
"qoder" => return Some(AgentKind::Qoder),
_ => {}
}
}
}
None
}
fn strip_ext<'a>(name: &'a str, ext: &str) -> Option<&'a str> {
if name.len() > ext.len() && name[name.len() - ext.len()..].eq_ignore_ascii_case(ext) {
Some(&name[..name.len() - ext.len()])
} else {
None
}
}
pub fn claude_hook_settings() -> String {
let tmux_set = |state: &str, reason: &str, event: &str| -> String {
format!(
"tmux set-option -q @omniterm_agent claude:{}:{}:{}:$(date +%s).$$",
state, reason, event
)
};
let hooks = serde_json::json!({
"hooks": {
"UserPromptSubmit": [
{ "command": tmux_set("running", "", "UserPromptSubmit") }
],
"PreToolUse": [
{ "command": tmux_set("running", "", "PreToolUse") }
],
"PostToolUse": [
{ "command": tmux_set("running", "", "PostToolUse") }
],
"PermissionRequest": [
{ "command": tmux_set("waiting", "decision", "PermissionRequest") }
],
"Notification": [
{
"matcher": "permission_prompt",
"command": tmux_set("waiting", "decision", "permission_prompt")
},
{
"matcher": "elicitation_dialog",
"command": tmux_set("waiting", "decision", "elicitation_dialog")
}
],
"Stop": [
{ "command": tmux_set("idle", "done", "Stop") }
],
"StopFailure": [
{ "command": tmux_set("idle", "error", "StopFailure") }
],
"SessionEnd": [
{ "command": tmux_set("idle", "done", "SessionEnd") }
]
}
});
hooks.to_string()
}
pub fn codex_hook_args() -> Vec<String> {
let tmux_set = |state: &str, reason: &str, event: &str| -> String {
format!(
"tmux set-option -q @omniterm_agent codex:{}:{}:{}:$(date +%s).$$",
state, reason, event
)
};
let hooks: Vec<(&str, &str, &str)> = vec![
("running", "", "UserPromptSubmit"),
("running", "", "PreToolUse"),
("running", "", "PostToolUse"),
("waiting", "decision", "PermissionRequest"),
("idle", "done", "Stop"),
];
let mut args = Vec::new();
for (state, reason, event) in hooks {
let cmd = tmux_set(state, reason, event);
args.push("-c".to_string());
args.push(format!("hooks.{}.command={}", event, cmd));
}
args
}
pub fn augment_agent_command(command: &str) -> Option<String> {
let kind = detect_agent_kind(command)?;
let augmented = match kind {
AgentKind::Claude | AgentKind::Qoder => {
let settings_json = claude_hook_settings();
format!("{} --settings '{}'", command.trim(), settings_json)
}
AgentKind::Codex => {
let args = codex_hook_args();
let args_str = args.iter().map(|a| shell_quote(a)).collect::<Vec<_>>().join(" ");
format!("{} {}", command.trim(), args_str)
}
};
Some(augmented)
}
pub fn initial_agent_option_value(_kind: AgentKind) -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("omniterm:running::launch:{}", ts)
}
fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{}'", escaped)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_claude() {
assert_eq!(detect_agent_kind("claude"), Some(AgentKind::Claude));
assert_eq!(
detect_agent_kind("claude --dangerously-skip-permissions"),
Some(AgentKind::Claude)
);
}
#[test]
fn test_detect_claude_code() {
assert_eq!(detect_agent_kind("claude-code"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("claude-code --model haiku"), Some(AgentKind::Claude));
}
#[test]
fn test_detect_codex() {
assert_eq!(detect_agent_kind("codex"), Some(AgentKind::Codex));
assert_eq!(detect_agent_kind("codex --help"), Some(AgentKind::Codex));
}
#[test]
fn test_detect_case_insensitive() {
assert_eq!(detect_agent_kind("Claude"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("CLAUDE"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("Codex"), Some(AgentKind::Codex));
}
#[test]
fn test_detect_strip_extensions() {
assert_eq!(detect_agent_kind("claude.exe"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("Claude.EXE"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("codex.cmd"), Some(AgentKind::Codex));
assert_eq!(detect_agent_kind("claude.bat"), Some(AgentKind::Claude));
}
#[test]
fn test_detect_full_path() {
assert_eq!(detect_agent_kind("/usr/local/bin/claude"), Some(AgentKind::Claude));
assert_eq!(detect_agent_kind("C:\\Claude\\claude.exe"), Some(AgentKind::Claude));
}
#[test]
fn test_detect_non_agent() {
assert_eq!(detect_agent_kind("bash"), None);
assert_eq!(detect_agent_kind("zsh"), None);
assert_eq!(detect_agent_kind("vim"), None);
assert_eq!(detect_agent_kind(""), None);
assert_eq!(detect_agent_kind(" "), None);
}
#[test]
fn test_detect_qoder() {
assert_eq!(detect_agent_kind("qoder"), Some(AgentKind::Qoder));
assert_eq!(detect_agent_kind("qoder.exe"), Some(AgentKind::Qoder));
assert_eq!(detect_agent_kind("C:\\Users\\x\\qoder.exe"), Some(AgentKind::Qoder));
}
#[test]
fn test_detect_windows_negative_samples() {
assert_eq!(detect_agent_kind("claudette.exe"), None);
assert_eq!(detect_agent_kind("codextool.exe"), None);
assert_eq!(detect_agent_kind("qodercli.exe"), None);
assert_eq!(detect_agent_kind("C:\\Program Files\\claudette.exe"), None);
}
#[test]
fn test_detect_node_wrapper_claude() {
assert_eq!(
detect_agent_kind("node C:\\Users\\x\\claude\\bin\\cli.js"),
Some(AgentKind::Claude)
);
assert_eq!(
detect_agent_kind("node.exe /usr/local/lib/claude/index.js"),
Some(AgentKind::Claude)
);
}
#[test]
fn test_detect_node_wrapper_codex() {
assert_eq!(
detect_agent_kind("node C:\\tools\\codex\\bin\\main.js"),
Some(AgentKind::Codex)
);
}
#[test]
fn test_detect_node_wrapper_non_agent() {
assert_eq!(detect_agent_kind("node server.js"), None);
assert_eq!(detect_agent_kind("node.exe app/index.js"), None);
}
#[test]
fn test_claude_hook_settings_valid_json() {
let settings = claude_hook_settings();
let parsed: serde_json::Value =
serde_json::from_str(&settings).expect("should be valid JSON");
let hooks = &parsed["hooks"];
assert!(hooks["UserPromptSubmit"].is_array());
assert!(hooks["PreToolUse"].is_array());
assert!(hooks["PostToolUse"].is_array());
assert!(hooks["PermissionRequest"].is_array());
assert!(hooks["Notification"].is_array());
assert!(hooks["Stop"].is_array());
assert!(hooks["StopFailure"].is_array());
assert!(hooks["SessionEnd"].is_array());
let notifications = hooks["Notification"].as_array().unwrap();
assert_eq!(notifications.len(), 2);
assert_eq!(notifications[0]["matcher"], "permission_prompt");
assert_eq!(notifications[1]["matcher"], "elicitation_dialog");
let cmd = hooks["PermissionRequest"][0]["command"].as_str().unwrap();
assert!(cmd.contains(
"tmux set-option -q @omniterm_agent claude:waiting:decision:PermissionRequest"
));
assert!(cmd.contains("$(date +%s).$$"));
}
#[test]
fn test_codex_hook_args_format() {
let args = codex_hook_args();
assert_eq!(args.len(), 10);
let mut i = 0;
let expected_events =
["UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Stop"];
for event in &expected_events {
assert_eq!(args[i], "-c");
assert!(args[i + 1].starts_with(&format!("hooks.{}.command=", event)));
assert!(args[i + 1].contains("tmux set-option"));
i += 2;
}
}
#[test]
fn test_augment_claude() {
let result = augment_agent_command("claude --model sonnet");
assert!(result.is_some());
let cmd = result.unwrap();
assert!(cmd.starts_with("claude --model sonnet --settings '"));
assert!(cmd.contains("UserPromptSubmit"));
}
#[test]
fn test_augment_codex() {
let result = augment_agent_command("codex");
assert!(result.is_some());
let cmd = result.unwrap();
assert!(cmd.starts_with("codex "));
assert!(cmd.contains("-c"));
assert!(cmd.contains("UserPromptSubmit"));
}
#[test]
fn test_augment_non_agent() {
assert!(augment_agent_command("bash").is_none());
assert!(augment_agent_command("ls -la").is_none());
}
#[test]
fn test_initial_option_value_format() {
let val = initial_agent_option_value(AgentKind::Claude);
assert!(val.starts_with("omniterm:running::launch:"));
let parts: Vec<&str> = val.split(':').collect();
assert_eq!(parts.len(), 5);
let ts: u64 = parts[4].parse().expect("timestamp should be numeric");
assert!(ts > 1700000000); }
#[test]
fn test_shell_escaping_special_chars() {
use crate::tmux::agent_state::clean_token;
assert_eq!(clean_token("it's"), "it_s");
assert_eq!(clean_token("say \"hello\""), "say__hello_");
assert_eq!(clean_token("path\\to"), "path_to");
assert_eq!(clean_token("line1\nline2"), "line1_line2");
}
}