1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum AgentKind {
7 ClaudeCode,
8 Codex,
9 CursorAgent,
10 OpenCode,
11 Gemini,
12}
13
14impl fmt::Display for AgentKind {
15 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16 match self {
17 AgentKind::ClaudeCode => write!(f, "Claude Code"),
18 AgentKind::Codex => write!(f, "Codex"),
19 AgentKind::CursorAgent => write!(f, "Cursor"),
20 AgentKind::OpenCode => write!(f, "OpenCode"),
21 AgentKind::Gemini => write!(f, "Gemini"),
22 }
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum AgentState {
33 Running,
35 Waiting,
37 Idle,
39 Unknown,
41}
42
43impl AgentState {
44 fn attention_priority(self) -> u8 {
46 match self {
47 AgentState::Waiting => 3,
48 AgentState::Running => 2,
49 AgentState::Idle => 1,
50 AgentState::Unknown => 0,
51 }
52 }
53}
54
55impl fmt::Display for AgentState {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 AgentState::Running => write!(f, "Running"),
59 AgentState::Waiting => write!(f, "Waiting"),
60 AgentState::Idle => write!(f, "Idle"),
61 AgentState::Unknown => write!(f, "Unknown"),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68pub struct AgentStatus {
69 pub kind: AgentKind,
70 pub state: AgentState,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub enum KindSource {
75 PaneCommand,
76 ChildProcess,
77 PaneTitle,
78 ContentFallbackHost,
79 ContentFallbackWrapper,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83pub enum StateSource {
84 ContentPattern,
85 ActivityRecency,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct DetectionDebug {
90 pub kind_source: KindSource,
91 pub kind_rule: &'static str,
92 pub state_source: StateSource,
93 pub state_rule: &'static str,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct DetectionResult {
101 pub status: AgentStatus,
102 pub pane_id: String,
103 pub debug: DetectionDebug,
104}
105
106pub mod detect;
107
108pub fn detect_for_session(
113 tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
114 session_name: &str,
115) -> Option<DetectionResult> {
116 let all_pane_data = tmux.list_all_panes_with_activity();
119 if let Some(data) = all_pane_data.get(session_name) {
120 return detect_from_pane_data(tmux, data);
121 }
122
123 let panes = tmux.list_panes_detailed(session_name);
124 let activity = tmux.session_activity(session_name).unwrap_or(0);
125
126 let data = crate::tmux::provider::SessionPaneData {
127 panes,
128 pane_titles: std::collections::HashMap::new(),
129 session_activity: activity,
130 };
131
132 detect_from_pane_data(tmux, &data)
133}
134
135pub fn detect_for_sessions_batched(
145 tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
146 session_names: &[String],
147 all_pane_data: &std::collections::HashMap<
148 String,
149 crate::tmux::provider::SessionPaneData,
150 impl std::hash::BuildHasher,
151 >,
152) -> Vec<(String, Option<DetectionResult>)> {
153 session_names
154 .iter()
155 .map(|session_name| {
156 let status = all_pane_data
157 .get(session_name)
158 .and_then(|data| detect_from_pane_data(tmux, data));
159 (session_name.clone(), status)
160 })
161 .collect()
162}
163
164fn detect_from_pane_data(
169 tmux: &(impl crate::tmux::TmuxProvider + ?Sized),
170 data: &crate::tmux::provider::SessionPaneData,
171) -> Option<DetectionResult> {
172 let mut best: Option<DetectionResult> = None;
173
174 for pane in &data.panes {
175 let mut kind_from_content_fallback = false;
176 let command = pane.command.trim();
177 let pane_title = data.pane_titles.get(&pane.pane_id).map(String::as_str);
178 let allow_wrapper_fallback = looks_like_version_command(command);
179 let mut kind_debug = detect::detect_agent_kind(command, None)
180 .map(|kind| (kind, KindSource::PaneCommand, "agent.kind.command_pattern"));
181 if kind_debug.is_none() && (may_host_agent(command) || allow_wrapper_fallback) {
182 kind_debug = get_child_process_args(pane.pid)
183 .as_deref()
184 .and_then(|args| detect::detect_agent_kind(command, Some(args)))
185 .map(|kind| (kind, KindSource::ChildProcess, "agent.kind.child_process"));
186 }
187 if kind_debug.is_none() && allow_wrapper_fallback {
188 kind_debug = pane_title
189 .and_then(detect::detect_agent_kind_from_title)
190 .map(|kind| (kind, KindSource::PaneTitle, "agent.kind.pane_title"));
191 }
192 let mut kind = kind_debug.as_ref().map(|(kind, _, _)| *kind);
193 let mut captured_content: Option<String> = None;
194
195 if kind.is_none() && (may_host_agent(command) || allow_wrapper_fallback) {
198 captured_content = tmux.capture_pane_content(&pane.pane_id, 30);
199 kind_debug = captured_content.as_deref().and_then(|content| {
200 if allow_wrapper_fallback {
201 detect::detect_agent_kind_from_wrapper_content(content).map(|kind| {
202 (
203 kind,
204 KindSource::ContentFallbackWrapper,
205 "agent.kind.content_wrapper",
206 )
207 })
208 } else {
209 detect::detect_agent_kind_from_content(content).map(|kind| {
210 (
211 kind,
212 KindSource::ContentFallbackHost,
213 "agent.kind.content_fallback",
214 )
215 })
216 }
217 });
218 kind = kind_debug.as_ref().map(|(kind, _, _)| *kind);
219 kind_from_content_fallback = kind.is_some();
220 }
221
222 if let Some(kind) = kind
223 && let Some(content) =
224 captured_content.or_else(|| tmux.capture_pane_content(&pane.pane_id, 30))
225 {
226 let mut state = detect::detect_state(&content, kind);
227 let mut state_source = StateSource::ContentPattern;
228 let mut state_rule = detect::detect_state_rule(&content, kind, state);
229
230 let kind_source = kind_debug
238 .as_ref()
239 .map_or(KindSource::PaneCommand, |(_, source, _)| *source);
240
241 if state == AgentState::Unknown
242 && kind != AgentKind::Codex
243 && !content.trim().is_empty()
244 && kind_source != KindSource::PaneTitle
245 {
246 state = infer_state_from_activity_ts(data.session_activity);
247 if state == AgentState::Running {
248 state_source = StateSource::ActivityRecency;
249 state_rule = "agent.state.activity_recent";
250 }
251 }
252 if state == AgentState::Unknown && kind_from_content_fallback {
256 continue;
257 }
258
259 let status = AgentStatus { kind, state };
260 let (kind_source, kind_rule) = kind_debug.as_ref().map_or(
261 (KindSource::PaneCommand, "agent.kind.unknown"),
262 |(_, source, rule)| (*source, *rule),
263 );
264 let result = DetectionResult {
265 status,
266 pane_id: pane.pane_id.clone(),
267 debug: DetectionDebug {
268 kind_source,
269 kind_rule,
270 state_source,
271 state_rule,
272 },
273 };
274 if best.as_ref().is_none_or(|b| {
275 status.state.attention_priority() > b.status.state.attention_priority()
276 }) {
277 best = Some(result);
278 }
279 }
280 }
281
282 best
283}
284
285fn infer_state_from_activity_ts(activity_ts: u64) -> AgentState {
290 let now = std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .map(|d| d.as_secs())
293 .unwrap_or(0);
294
295 if now.saturating_sub(activity_ts) <= ACTIVITY_RECENCY_SECS {
296 AgentState::Running
297 } else {
298 AgentState::Unknown
299 }
300}
301
302const AGENT_HOST_COMMANDS: &[&str] = &[
307 "bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "csh", "nu", "nushell", "pwsh", "node",
308];
309
310fn may_host_agent(command: &str) -> bool {
311 AGENT_HOST_COMMANDS
312 .iter()
313 .any(|s| command.eq_ignore_ascii_case(s))
314}
315
316fn looks_like_version_command(command: &str) -> bool {
320 let mut parts = command.split('.');
321 let Some(first) = parts.next() else {
322 return false;
323 };
324 if first.is_empty() || !first.chars().all(|c| c.is_ascii_digit()) {
325 return false;
326 }
327 let mut part_count = 1usize;
328 for part in parts {
329 if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
330 return false;
331 }
332 part_count += 1;
333 }
334 part_count >= 2
335}
336
337const ACTIVITY_RECENCY_SECS: u64 = 3;
341
342const MAX_CHILD_DEPTH: usize = 8;
345
346fn get_child_process_args(pid: u32) -> Option<String> {
350 let mut args = String::new();
351
352 if get_child_args_procfs(pid, &mut args, 0) {
354 if !args.is_empty() {
355 return Some(args);
356 }
357 } else {
358 get_child_args_pgrep(pid, &mut args, 0);
360 if !args.is_empty() {
361 return Some(args);
362 }
363 }
364
365 None
366}
367
368fn get_child_args_procfs(pid: u32, args: &mut String, depth: usize) -> bool {
371 if depth >= MAX_CHILD_DEPTH {
372 return true;
373 }
374 let children_path = format!("/proc/{pid}/task/{pid}/children");
375 let Ok(children) = std::fs::read_to_string(&children_path) else {
376 return false; };
378 for child_pid_str in children.split_whitespace() {
379 if let Ok(raw) = std::fs::read(format!("/proc/{child_pid_str}/cmdline")) {
380 let readable = String::from_utf8_lossy(&raw).replace('\0', " ");
381 args.push_str(&readable);
382 args.push('\n');
383 }
384 if let Ok(child_pid) = child_pid_str.parse::<u32>() {
386 get_child_args_procfs(child_pid, args, depth + 1);
387 }
388 }
389 true
390}
391
392fn get_child_args_pgrep(pid: u32, args: &mut String, depth: usize) {
394 if depth >= MAX_CHILD_DEPTH {
395 return;
396 }
397 let Ok(pgrep_output) = std::process::Command::new("pgrep")
398 .args(["-P", &pid.to_string()])
399 .output()
400 else {
401 return;
402 };
403 if !pgrep_output.status.success() {
404 return;
405 }
406
407 let pgrep_str = String::from_utf8_lossy(&pgrep_output.stdout);
408 let child_pids: Vec<&str> = pgrep_str.lines().filter(|s| !s.is_empty()).collect();
409 if child_pids.is_empty() {
410 return;
411 }
412
413 let mut ps_cmd = std::process::Command::new("ps");
414 ps_cmd.args(["-o", "args="]);
415 for cpid in &child_pids {
416 ps_cmd.args(["-p", cpid]);
417 }
418 if let Ok(output) = ps_cmd.output()
419 && output.status.success()
420 {
421 let text = String::from_utf8_lossy(&output.stdout);
422 if !text.trim().is_empty() {
423 args.push_str(&text);
424 }
425 }
426
427 for cpid_str in &child_pids {
429 if let Ok(cpid) = cpid_str.parse::<u32>() {
430 get_child_args_pgrep(cpid, args, depth + 1);
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::tmux::mock::MockTmuxProvider;
439 use crate::tmux::provider::{PaneInfo, TmuxProvider};
440
441 fn mock_with_agent(session: &str, command: &str, pane_content: &str) -> MockTmuxProvider {
442 let mut tmux = MockTmuxProvider::default();
443 let pane_id = "%0";
444 tmux.pane_info.insert(
445 session.to_string(),
446 vec![PaneInfo {
447 pane_id: pane_id.to_string(),
448 command: command.to_string(),
449 pid: 99999, }],
451 );
452 tmux.pane_content
453 .insert(pane_id.to_string(), pane_content.to_string());
454 tmux
455 }
456
457 #[test]
458 fn detect_claude_code_running() {
459 let tmux = mock_with_agent("my-session", "claude", "⠋ Reading file src/main.rs");
460 let status = detect_for_session(&tmux, "my-session").unwrap().status;
461 assert_eq!(status.kind, AgentKind::ClaudeCode);
462 assert_eq!(status.state, AgentState::Running);
463 }
464
465 #[test]
466 fn detect_claude_code_waiting() {
467 let tmux = mock_with_agent(
468 "my-session",
469 "claude",
470 "Allow write to src/main.rs?\n Yes, allow\n No, deny",
471 );
472 let status = detect_for_session(&tmux, "my-session").unwrap().status;
473 assert_eq!(status.kind, AgentKind::ClaudeCode);
474 assert_eq!(status.state, AgentState::Waiting);
475 }
476
477 #[test]
478 fn detect_claude_code_idle() {
479 let tmux = mock_with_agent("my-session", "claude", "❯ \n? for shortcuts");
480 let status = detect_for_session(&tmux, "my-session").unwrap().status;
481 assert_eq!(status.kind, AgentKind::ClaudeCode);
482 assert_eq!(status.state, AgentState::Idle);
483 }
484
485 #[test]
486 fn detect_codex_running() {
487 let tmux = mock_with_agent(
488 "codex-session",
489 "codex",
490 "⠋ Searching codebase\nesc to interrupt",
491 );
492 let status = detect_for_session(&tmux, "codex-session").unwrap().status;
493 assert_eq!(status.kind, AgentKind::Codex);
494 assert_eq!(status.state, AgentState::Running);
495 }
496
497 #[test]
498 fn detect_codex_waiting() {
499 let tmux = mock_with_agent(
500 "codex-session",
501 "codex",
502 "Would you like to run the following command?\n$ touch test.txt\n› 1. Yes, proceed (y)\n 2. Yes, and don't ask again (p)\n 3. No (esc)\n\n Press enter to confirm or esc to cancel",
503 );
504 let status = detect_for_session(&tmux, "codex-session").unwrap().status;
505 assert_eq!(status.kind, AgentKind::Codex);
506 assert_eq!(status.state, AgentState::Waiting);
507 }
508
509 #[test]
510 fn detect_cursor_agent_running() {
511 let state = detect::detect_state(
513 "⠋ Editing file src/main.rs\nesc to interrupt",
514 AgentKind::CursorAgent,
515 );
516 assert_eq!(state, AgentState::Running);
517 }
518
519 #[test]
520 fn detect_cursor_agent_waiting() {
521 let state = detect::detect_state(
522 "Do you trust the contents of this directory?\n\n▶ [a] Trust this workspace\n [w] Trust without MCP\n [q] Quit\n\nUse arrow keys to navigate, Enter to select",
523 AgentKind::CursorAgent,
524 );
525 assert_eq!(state, AgentState::Waiting);
526 }
527
528 #[test]
529 fn no_agent_in_regular_shell() {
530 let tmux = mock_with_agent("shell-session", "bash", "$ ls -la\ntotal 42");
531 assert!(detect_for_session(&tmux, "shell-session").is_none());
532 }
533
534 #[test]
535 fn stale_claude_shell_transcript_not_detected() {
536 let tmux = mock_with_agent(
537 "shell-session",
538 "zsh",
539 "⠋ Reading file src/main.rs\nesc to interrupt\n❯ \n? for shortcuts",
540 );
541 assert!(detect_for_session(&tmux, "shell-session").is_none());
542 }
543
544 #[test]
545 fn stale_cursor_shell_transcript_not_detected() {
546 let tmux = mock_with_agent(
547 "shell-session",
548 "zsh",
549 "/ commands\nDo you trust the contents of this directory?\nEnter to select",
550 );
551 assert!(detect_for_session(&tmux, "shell-session").is_none());
552 }
553
554 #[test]
555 fn stale_opencode_shell_transcript_not_detected() {
556 let tmux = mock_with_agent(
557 "shell-session",
558 "zsh",
559 "⬝■■■■■■⬝ esc interrupt ctrl+t variants tab agents ctrl+p commands",
560 );
561 assert!(detect_for_session(&tmux, "shell-session").is_none());
562 }
563
564 #[test]
565 fn stale_gemini_shell_transcript_not_detected() {
566 let tmux = mock_with_agent(
567 "shell-session",
568 "zsh",
569 "Action required\nallow execution\n? for shortcuts",
570 );
571 assert!(detect_for_session(&tmux, "shell-session").is_none());
572 }
573
574 #[test]
575 fn no_panes_returns_none() {
576 let tmux = MockTmuxProvider::default();
577 assert!(detect_for_session(&tmux, "nonexistent").is_none());
578 }
579
580 #[test]
581 fn agent_found_in_second_pane() {
582 let mut tmux = MockTmuxProvider::default();
583 let session = "multi-pane";
584 tmux.pane_info.insert(
585 session.to_string(),
586 vec![
587 PaneInfo {
588 pane_id: "%0".to_string(),
589 command: "bash".to_string(),
590 pid: 11111,
591 },
592 PaneInfo {
593 pane_id: "%1".to_string(),
594 command: "claude".to_string(),
595 pid: 22222,
596 },
597 ],
598 );
599 tmux.pane_content
600 .insert("%0".to_string(), "$ vim file.txt".to_string());
601 tmux.pane_content
602 .insert("%1".to_string(), "Esc to interrupt".to_string());
603
604 let status = detect_for_session(&tmux, session).unwrap().status;
605 assert_eq!(status.kind, AgentKind::ClaudeCode);
606 assert_eq!(status.state, AgentState::Running);
607 }
608
609 #[test]
610 fn agent_with_ansi_codes_in_output() {
611 let tmux = mock_with_agent("ansi-session", "claude", "\x1B[32m⠹ Running tool\x1B[0m");
612 let status = detect_for_session(&tmux, "ansi-session").unwrap().status;
613 assert_eq!(status.state, AgentState::Running);
614 }
615
616 #[test]
617 fn pane_has_agent_command_with_empty_content() {
618 let mut tmux = MockTmuxProvider::default();
620 tmux.pane_info.insert(
621 "empty-content".to_string(),
622 vec![PaneInfo {
623 pane_id: "%0".to_string(),
624 command: "claude".to_string(),
625 pid: 44444,
626 }],
627 );
628 tmux.pane_content.insert("%0".to_string(), String::new());
629 let status = detect_for_session(&tmux, "empty-content").unwrap().status;
630 assert_eq!(status.kind, AgentKind::ClaudeCode);
631 assert_eq!(status.state, AgentState::Unknown);
632 }
633
634 #[test]
635 fn pane_has_agent_command_but_no_content() {
636 let mut tmux = MockTmuxProvider::default();
637 tmux.pane_info.insert(
638 "empty-pane".to_string(),
639 vec![PaneInfo {
640 pane_id: "%0".to_string(),
641 command: "claude".to_string(),
642 pid: 33333,
643 }],
644 );
645 assert!(detect_for_session(&tmux, "empty-pane").is_none());
647 }
648
649 fn mock_multi_agent(session: &str, agents: &[(&str, &str)]) -> MockTmuxProvider {
651 let mut tmux = MockTmuxProvider::default();
652 let panes: Vec<PaneInfo> = agents
653 .iter()
654 .enumerate()
655 .map(|(i, (command, _))| PaneInfo {
656 pane_id: format!("%{i}"),
657 command: command.to_string(),
658 pid: 90000 + u32::try_from(i).expect("test has fewer than u32::MAX agents"),
659 })
660 .collect();
661 tmux.pane_info.insert(session.to_string(), panes);
662 for (i, (_, content)) in agents.iter().enumerate() {
663 tmux.pane_content
664 .insert(format!("%{i}"), content.to_string());
665 }
666 tmux
667 }
668
669 #[test]
670 fn multi_agent_waiting_beats_running() {
671 let tmux = mock_multi_agent(
672 "multi",
673 &[
674 ("claude", "⠋ Reading file src/main.rs"),
675 ("claude", "Allow write?\n Yes, allow\n No, deny"),
676 ],
677 );
678 let status = detect_for_session(&tmux, "multi").unwrap().status;
679 assert_eq!(status.state, AgentState::Waiting);
680 }
681
682 #[test]
683 fn multi_agent_waiting_beats_idle() {
684 let tmux = mock_multi_agent(
685 "multi",
686 &[
687 ("claude", "❯ \n? for shortcuts"),
688 ("claude", "Allow write?\n Yes, allow\n No, deny"),
689 ],
690 );
691 let status = detect_for_session(&tmux, "multi").unwrap().status;
692 assert_eq!(status.state, AgentState::Waiting);
693 }
694
695 #[test]
696 fn multi_agent_running_beats_idle() {
697 let tmux = mock_multi_agent(
698 "multi",
699 &[
700 ("claude", "⠋ Reading file src/main.rs"),
701 ("claude", "❯ \n? for shortcuts"),
702 ],
703 );
704 let status = detect_for_session(&tmux, "multi").unwrap().status;
705 assert_eq!(status.state, AgentState::Running);
706 }
707
708 #[test]
709 fn multi_agent_across_windows() {
710 let mut tmux = MockTmuxProvider::default();
711 let session = "multi-win";
712 tmux.pane_info.insert(
713 session.to_string(),
714 vec![
715 PaneInfo {
716 pane_id: "%10".to_string(),
717 command: "claude".to_string(),
718 pid: 80001,
719 },
720 PaneInfo {
721 pane_id: "%11".to_string(),
722 command: "claude".to_string(),
723 pid: 80002,
724 },
725 ],
726 );
727 tmux.pane_content
728 .insert("%10".to_string(), "⠋ Reading file".to_string());
729 tmux.pane_content
730 .insert("%11".to_string(), "Allow write?\n Yes, allow".to_string());
731
732 let status = detect_for_session(&tmux, session).unwrap().status;
733 assert_eq!(status.state, AgentState::Waiting);
734 }
735
736 #[test]
737 fn may_host_agent_matches_common_shells() {
738 assert!(super::may_host_agent("bash"));
739 assert!(super::may_host_agent("zsh"));
740 assert!(super::may_host_agent("fish"));
741 assert!(super::may_host_agent("sh"));
742 assert!(super::may_host_agent("dash"));
743 assert!(super::may_host_agent("nu"));
744 assert!(super::may_host_agent("nushell"));
745 }
746
747 #[test]
748 fn may_host_agent_rejects_non_shells() {
749 assert!(!super::may_host_agent("vim"));
750 assert!(!super::may_host_agent("hx"));
751
752 assert!(!super::may_host_agent("python3"));
753 assert!(!super::may_host_agent("cargo"));
754 assert!(!super::may_host_agent("claude"));
755 assert!(!super::may_host_agent("codex"));
756 }
757
758 #[test]
759 fn may_host_agent_case_insensitive() {
760 assert!(super::may_host_agent("Bash"));
761 assert!(super::may_host_agent("ZSH"));
762 assert!(super::may_host_agent("Fish"));
763 }
764
765 #[test]
766 fn looks_like_version_command_matches_semver_like_strings() {
767 assert!(super::looks_like_version_command("2.1.63"));
768 assert!(super::looks_like_version_command("0.106.0"));
769 assert!(!super::looks_like_version_command("node"));
770 assert!(!super::looks_like_version_command("v2.1.63"));
771 assert!(!super::looks_like_version_command("2.1.beta"));
772 }
773
774 #[test]
775 fn attention_priority_ordering() {
776 assert!(
778 AgentState::Waiting.attention_priority() > AgentState::Running.attention_priority()
779 );
780 assert!(AgentState::Running.attention_priority() > AgentState::Idle.attention_priority());
781 assert!(AgentState::Idle.attention_priority() > AgentState::Unknown.attention_priority());
782 }
783
784 #[test]
785 fn multi_agent_running_beats_unknown() {
786 let tmux = mock_multi_agent(
788 "multi",
789 &[
790 ("claude", ""), ("claude", "⠋ Reading file src/main.rs"), ],
793 );
794 let status = detect_for_session(&tmux, "multi").unwrap().status;
795 assert_eq!(status.state, AgentState::Running);
796 }
797
798 #[test]
799 fn child_process_skipped_for_non_shell() {
800 let tmux = mock_with_agent("editor-session", "hx", "normal mode");
805 assert!(detect_for_session(&tmux, "editor-session").is_none());
806 }
807
808 #[test]
809 fn child_process_checked_for_shell() {
810 let tmux = mock_with_agent("shell-session", "bash", "$ ls -la");
814 assert!(detect_for_session(&tmux, "shell-session").is_none());
815 }
816
817 #[test]
818 fn detect_opencode_running() {
819 let _tmux = mock_with_agent(
820 "oc-session",
821 "node",
822 "⬝■■■■■■⬝ esc interrupt ctrl+t variants tab agents ctrl+p commands",
823 );
824 let state = detect::detect_state(
827 "⬝■■■■■■⬝ esc interrupt ctrl+t variants tab agents ctrl+p commands",
828 AgentKind::OpenCode,
829 );
830 assert_eq!(state, AgentState::Running);
831 }
832
833 #[test]
834 fn detect_opencode_idle() {
835 let state = detect::detect_state(
836 " ┃ Build GPT-5.3 Codex OpenAI\n ╹▀▀▀\n ctrl+t variants tab agents ctrl+p commands",
837 AgentKind::OpenCode,
838 );
839 assert_eq!(state, AgentState::Idle);
840 }
841
842 #[test]
843 fn detect_opencode_via_command_name() {
844 let tmux = mock_with_agent(
845 "oc-session",
846 "opencode",
847 " ctrl+t variants tab agents ctrl+p commands",
848 );
849 let status = detect_for_session(&tmux, "oc-session").unwrap().status;
850 assert_eq!(status.kind, AgentKind::OpenCode);
851 assert_eq!(status.state, AgentState::Idle);
852 }
853
854 #[test]
855 fn may_host_agent_includes_node() {
856 assert!(super::may_host_agent("node"));
857 assert!(super::may_host_agent("Node"));
858 }
859
860 #[test]
861 fn detect_codex_kind_from_content_when_process_lookup_misses() {
862 let mut tmux = MockTmuxProvider::default();
863 let session = "content-fallback";
864 tmux.pane_info.insert(
865 session.to_string(),
866 vec![PaneInfo {
867 pane_id: "%0".to_string(),
868 command: "node".to_string(),
869 pid: 99999, }],
871 );
872 tmux.pane_content.insert(
873 "%0".to_string(),
874 "› \n\
875 gpt-5.3-codex high · 100% left · ~/Development/kiosk"
876 .to_string(),
877 );
878
879 let status = detect_for_session(&tmux, session).unwrap().status;
880 assert_eq!(status.kind, AgentKind::Codex);
881 assert_eq!(status.state, AgentState::Idle);
882 }
883
884 #[test]
885 fn detect_claude_kind_from_wrapper_command_and_content() {
886 let mut tmux = MockTmuxProvider::default();
887 let session = "wrapper-claude";
888 tmux.pane_info.insert(
889 session.to_string(),
890 vec![PaneInfo {
891 pane_id: "%0".to_string(),
892 command: "2.1.63".to_string(),
893 pid: 99999, }],
895 );
896 tmux.pane_content.insert(
897 "%0".to_string(),
898 "▐▛███▜▌ Claude Code v2.1.63\n\
899 ▝▜█████▛▘ Opus 4.6 · Claude Max\n\
900 Do you want to proceed?\n\
901 ❯ 1. Yes"
902 .to_string(),
903 );
904
905 let status = detect_for_session(&tmux, session).unwrap().status;
906 assert_eq!(status.kind, AgentKind::ClaudeCode);
907 assert_eq!(status.state, AgentState::Waiting);
908 }
909
910 #[test]
911 fn detect_claude_kind_from_wrapper_command_and_pane_title() {
912 let mut tmux = MockTmuxProvider::default();
913 let session = "wrapper-title-claude";
914 tmux.pane_info.insert(
915 session.to_string(),
916 vec![PaneInfo {
917 pane_id: "%0".to_string(),
918 command: "2.1.63".to_string(),
919 pid: 99999, }],
921 );
922 tmux.pane_titles
923 .insert("%0".to_string(), "✳ Claude Code".to_string());
924 tmux.pane_content.insert(
925 "%0".to_string(),
926 "Bash command\nDo you want to proceed?\n❯ 1. Yes".to_string(),
927 );
928
929 let status = detect_for_session(&tmux, session).unwrap().status;
930 assert_eq!(status.kind, AgentKind::ClaudeCode);
931 assert_eq!(status.state, AgentState::Waiting);
932 }
933
934 #[test]
935 fn pane_title_not_used_for_non_host_command() {
936 let mut tmux = MockTmuxProvider::default();
937 let session = "title-non-host";
938 tmux.pane_info.insert(
939 session.to_string(),
940 vec![PaneInfo {
941 pane_id: "%0".to_string(),
942 command: "vim".to_string(),
943 pid: 99999,
944 }],
945 );
946 tmux.pane_titles
947 .insert("%0".to_string(), "OpenAI Codex".to_string());
948 tmux.pane_content
949 .insert("%0".to_string(), "regular editor text".to_string());
950
951 assert!(
952 detect_for_session(&tmux, session).is_none(),
953 "pane title fallback should be restricted to host/wrapper commands"
954 );
955 }
956
957 #[test]
958 fn pane_title_not_used_for_host_shell_command() {
959 let mut tmux = MockTmuxProvider::default();
960 let session = "title-host-shell";
961 tmux.pane_info.insert(
962 session.to_string(),
963 vec![PaneInfo {
964 pane_id: "%0".to_string(),
965 command: "zsh".to_string(),
966 pid: 99999,
967 }],
968 );
969 tmux.pane_titles
970 .insert("%0".to_string(), "✳ Claude Code".to_string());
971 tmux.pane_content.insert(
972 "%0".to_string(),
973 "stale transcript text\n0 | host $".to_string(),
974 );
975
976 assert!(
977 detect_for_session(&tmux, session).is_none(),
978 "shell panes should not infer agent kind from title alone"
979 );
980 }
981
982 #[test]
983 fn content_fallback_unknown_is_ignored() {
984 let mut tmux = MockTmuxProvider::default();
985 let session = "content-fallback-unknown";
986 tmux.pane_info.insert(
987 session.to_string(),
988 vec![PaneInfo {
989 pane_id: "%0".to_string(),
990 command: "zsh".to_string(),
991 pid: 99999, }],
993 );
994 tmux.pane_content.insert(
995 "%0".to_string(),
996 "╭────────────────────────────────╮\n\
997 │ >_ OpenAI Codex (v0.106.0) │\n\
998 ╰────────────────────────────────╯"
999 .to_string(),
1000 );
1001
1002 assert!(
1003 detect_for_session(&tmux, session).is_none(),
1004 "Content-only fallback with Unknown state should not report an agent"
1005 );
1006 }
1007
1008 #[test]
1009 fn content_fallback_running_beats_idle_across_panes() {
1010 let mut tmux = MockTmuxProvider::default();
1011 let session = "content-fallback-priority";
1012 tmux.pane_info.insert(
1013 session.to_string(),
1014 vec![
1015 PaneInfo {
1016 pane_id: "%0".to_string(),
1017 command: "node".to_string(),
1018 pid: 90001,
1019 },
1020 PaneInfo {
1021 pane_id: "%1".to_string(),
1022 command: "node".to_string(),
1023 pid: 90002,
1024 },
1025 ],
1026 );
1027 tmux.pane_content.insert(
1028 "%0".to_string(),
1029 "› Write tests for @filename\n\
1030 gpt-5.3-codex high · 100% left · ~/Development/kiosk"
1031 .to_string(),
1032 );
1033 tmux.pane_content.insert(
1034 "%1".to_string(),
1035 "› Please can you sleep in bash for 60s\n\
1036 • Implementing periodic commentary during sleep (46s • esc to interrupt) · 1 background terminal r…\n\
1037 › Improve documentation in @filename\n\
1038 gpt-5.3-codex high · 100% left · ~/Development/dotfiles"
1039 .to_string(),
1040 );
1041
1042 let status = detect_for_session(&tmux, session).unwrap().status;
1043 assert_eq!(status.kind, AgentKind::Codex);
1044 assert_eq!(status.state, AgentState::Running);
1045 }
1046 #[test]
1047 fn unknown_upgrades_to_running_with_recent_activity() {
1048 let now = std::time::SystemTime::now()
1051 .duration_since(std::time::UNIX_EPOCH)
1052 .unwrap()
1053 .as_secs();
1054
1055 let mut tmux = MockTmuxProvider::default();
1056 let session = "active-session";
1057 tmux.pane_info.insert(
1058 session.to_string(),
1059 vec![PaneInfo {
1060 pane_id: "%0".to_string(),
1061 command: "claude".to_string(),
1062 pid: 99999,
1063 }],
1064 );
1065 tmux.pane_content.insert(
1067 "%0".to_string(),
1068 "Some random output with no indicators".to_string(),
1069 );
1070 tmux.session_activity_ts.insert(session.to_string(), now);
1072
1073 let status = detect_for_session(&tmux, session).unwrap().status;
1074 assert_eq!(status.kind, AgentKind::ClaudeCode);
1075 assert_eq!(
1076 status.state,
1077 AgentState::Running,
1078 "Unknown + recent activity should infer Running"
1079 );
1080 }
1081
1082 #[test]
1083 fn title_inferred_unknown_not_upgraded_by_activity() {
1084 let now = std::time::SystemTime::now()
1085 .duration_since(std::time::UNIX_EPOCH)
1086 .unwrap()
1087 .as_secs();
1088
1089 let mut tmux = MockTmuxProvider::default();
1090 let session = "title-unknown-active";
1091 tmux.pane_info.insert(
1092 session.to_string(),
1093 vec![PaneInfo {
1094 pane_id: "%0".to_string(),
1095 command: "2.1.63".to_string(),
1096 pid: 99999,
1097 }],
1098 );
1099 tmux.pane_titles
1100 .insert("%0".to_string(), "✳ Claude Code".to_string());
1101 tmux.pane_content.insert(
1102 "%0".to_string(),
1103 "plain shell text without markers".to_string(),
1104 );
1105 tmux.session_activity_ts.insert(session.to_string(), now);
1106
1107 let status = detect_for_session(&tmux, session).unwrap().status;
1108 assert_eq!(status.kind, AgentKind::ClaudeCode);
1109 assert_eq!(
1110 status.state,
1111 AgentState::Unknown,
1112 "title-inferred unknown should not be upgraded by activity"
1113 );
1114 }
1115
1116 #[test]
1117 fn unknown_stays_unknown_with_stale_activity() {
1118 let stale_ts = std::time::SystemTime::now()
1121 .duration_since(std::time::UNIX_EPOCH)
1122 .unwrap()
1123 .as_secs()
1124 - 60; let mut tmux = MockTmuxProvider::default();
1127 let session = "stale-session";
1128 tmux.pane_info.insert(
1129 session.to_string(),
1130 vec![PaneInfo {
1131 pane_id: "%0".to_string(),
1132 command: "claude".to_string(),
1133 pid: 99999,
1134 }],
1135 );
1136 tmux.pane_content.insert(
1137 "%0".to_string(),
1138 "Some random output with no indicators".to_string(),
1139 );
1140 tmux.session_activity_ts
1141 .insert(session.to_string(), stale_ts);
1142
1143 let status = detect_for_session(&tmux, session).unwrap().status;
1144 assert_eq!(
1145 status.state,
1146 AgentState::Unknown,
1147 "Unknown + stale activity should stay Unknown"
1148 );
1149 }
1150
1151 #[test]
1152 fn codex_unknown_not_upgraded_by_recent_session_activity() {
1153 let now = std::time::SystemTime::now()
1156 .duration_since(std::time::UNIX_EPOCH)
1157 .unwrap()
1158 .as_secs();
1159
1160 let mut tmux = MockTmuxProvider::default();
1161 let session = "codex-unknown-active";
1162 tmux.pane_info.insert(
1163 session.to_string(),
1164 vec![PaneInfo {
1165 pane_id: "%0".to_string(),
1166 command: "codex".to_string(),
1167 pid: 99999,
1168 }],
1169 );
1170 tmux.pane_content.insert(
1171 "%0".to_string(),
1172 "› Review main.py and find all the bugs\n 100% context left".to_string(),
1173 );
1174 tmux.session_activity_ts.insert(session.to_string(), now);
1175
1176 let status = detect_for_session(&tmux, session).unwrap().status;
1177 assert_eq!(status.kind, AgentKind::Codex);
1178 assert_eq!(
1179 status.state,
1180 AgentState::Unknown,
1181 "Codex unknown should not be upgraded by session activity"
1182 );
1183 }
1184
1185 #[test]
1186 fn activity_does_not_override_idle() {
1187 let now = std::time::SystemTime::now()
1190 .duration_since(std::time::UNIX_EPOCH)
1191 .unwrap()
1192 .as_secs();
1193
1194 let mut tmux = MockTmuxProvider::default();
1195 let session = "idle-session";
1196 tmux.pane_info.insert(
1197 session.to_string(),
1198 vec![PaneInfo {
1199 pane_id: "%0".to_string(),
1200 command: "claude".to_string(),
1201 pid: 99999,
1202 }],
1203 );
1204 tmux.pane_content
1205 .insert("%0".to_string(), "❯ \n? for shortcuts".to_string());
1206 tmux.session_activity_ts.insert(session.to_string(), now);
1207
1208 let status = detect_for_session(&tmux, session).unwrap().status;
1209 assert_eq!(
1210 status.state,
1211 AgentState::Idle,
1212 "Idle state should not be overridden by recent activity"
1213 );
1214 }
1215 #[test]
1218 fn batched_matches_single_session_results() {
1219 let mut tmux = MockTmuxProvider::default();
1222 let sessions = ["claude-session", "codex-session", "empty-session"];
1223
1224 tmux.pane_info.insert(
1225 "claude-session".to_string(),
1226 vec![PaneInfo {
1227 pane_id: "%0".to_string(),
1228 command: "claude".to_string(),
1229 pid: 10001,
1230 }],
1231 );
1232 tmux.pane_content
1233 .insert("%0".to_string(), "⠋ Reading file".to_string());
1234
1235 tmux.pane_info.insert(
1236 "codex-session".to_string(),
1237 vec![PaneInfo {
1238 pane_id: "%1".to_string(),
1239 command: "codex".to_string(),
1240 pid: 10002,
1241 }],
1242 );
1243 tmux.pane_content
1244 .insert("%1".to_string(), "? for shortcuts".to_string());
1245
1246 let session_names: Vec<String> = sessions.iter().map(ToString::to_string).collect();
1249
1250 let individual: Vec<Option<AgentStatus>> = session_names
1252 .iter()
1253 .map(|s| detect_for_session(&tmux, s).map(|r| r.status))
1254 .collect();
1255
1256 let all_pane_data = tmux.list_all_panes_with_activity();
1258 let batched = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1259
1260 for (i, session) in session_names.iter().enumerate() {
1262 let batch_status = batched
1263 .iter()
1264 .find(|(s, _)| s == session)
1265 .map(|(_, r)| r.as_ref().map(|d| d.status));
1266 assert_eq!(
1267 batch_status,
1268 Some(individual[i]),
1269 "Mismatch for session {session}"
1270 );
1271 }
1272 }
1273
1274 #[test]
1275 fn batched_returns_none_for_unknown_sessions() {
1276 let tmux = MockTmuxProvider::default();
1277 let session_names = vec!["nonexistent".to_string()];
1278 let all_pane_data = tmux.list_all_panes_with_activity();
1279
1280 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1281 assert_eq!(results.len(), 1);
1282 assert_eq!(results[0].0, "nonexistent");
1283 assert!(results[0].1.is_none());
1284 }
1285
1286 #[test]
1287 fn batched_preserves_session_order() {
1288 let mut tmux = MockTmuxProvider::default();
1289 for name in &["z-session", "a-session", "m-session"] {
1290 tmux.pane_info.insert(
1291 name.to_string(),
1292 vec![PaneInfo {
1293 pane_id: format!("%{}", name.chars().next().unwrap()),
1294 command: "claude".to_string(),
1295 pid: 10000,
1296 }],
1297 );
1298 tmux.pane_content.insert(
1299 format!("%{}", name.chars().next().unwrap()),
1300 "❯ \n? for shortcuts".to_string(),
1301 );
1302 }
1303
1304 let session_names: Vec<String> = ["z-session", "a-session", "m-session"]
1305 .iter()
1306 .map(ToString::to_string)
1307 .collect();
1308 let all_pane_data = tmux.list_all_panes_with_activity();
1309 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1310
1311 assert_eq!(results[0].0, "z-session");
1312 assert_eq!(results[1].0, "a-session");
1313 assert_eq!(results[2].0, "m-session");
1314 }
1315
1316 #[test]
1317 fn batched_activity_inference_uses_prefetched_timestamp() {
1318 let now = std::time::SystemTime::now()
1322 .duration_since(std::time::UNIX_EPOCH)
1323 .unwrap()
1324 .as_secs();
1325
1326 let mut tmux = MockTmuxProvider::default();
1327 let session = "active-session";
1328 tmux.pane_info.insert(
1329 session.to_string(),
1330 vec![PaneInfo {
1331 pane_id: "%0".to_string(),
1332 command: "claude".to_string(),
1333 pid: 99999,
1334 }],
1335 );
1336 tmux.pane_content.insert(
1338 "%0".to_string(),
1339 "Some output with no indicators".to_string(),
1340 );
1341 tmux.session_activity_ts.insert(session.to_string(), now);
1343
1344 let session_names = vec![session.to_string()];
1345 let all_pane_data = tmux.list_all_panes_with_activity();
1346 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1347
1348 assert_eq!(
1349 results[0].1.as_ref().unwrap().status.state,
1350 AgentState::Running,
1351 "Should infer Running from pre-fetched recent activity"
1352 );
1353 }
1354
1355 #[test]
1356 fn batched_codex_unknown_not_upgraded_by_prefetched_activity() {
1357 let now = std::time::SystemTime::now()
1358 .duration_since(std::time::UNIX_EPOCH)
1359 .unwrap()
1360 .as_secs();
1361
1362 let mut tmux = MockTmuxProvider::default();
1363 let session = "codex-unknown-active";
1364 tmux.pane_info.insert(
1365 session.to_string(),
1366 vec![PaneInfo {
1367 pane_id: "%0".to_string(),
1368 command: "codex".to_string(),
1369 pid: 99999,
1370 }],
1371 );
1372 tmux.pane_content.insert(
1373 "%0".to_string(),
1374 "› Review main.py and find all the bugs\n 100% context left".to_string(),
1375 );
1376 tmux.session_activity_ts.insert(session.to_string(), now);
1377
1378 let session_names = vec![session.to_string()];
1379 let all_pane_data = tmux.list_all_panes_with_activity();
1380 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1381
1382 assert_eq!(results[0].1.as_ref().unwrap().status.kind, AgentKind::Codex);
1383 assert_eq!(
1384 results[0].1.as_ref().unwrap().status.state,
1385 AgentState::Unknown,
1386 "Batched detection should keep Codex Unknown despite recent activity"
1387 );
1388 }
1389
1390 #[test]
1391 fn batched_content_fallback_unknown_is_ignored() {
1392 let mut tmux = MockTmuxProvider::default();
1393 let session = "content-fallback-unknown";
1394 tmux.pane_info.insert(
1395 session.to_string(),
1396 vec![PaneInfo {
1397 pane_id: "%0".to_string(),
1398 command: "zsh".to_string(),
1399 pid: 99999,
1400 }],
1401 );
1402 tmux.pane_content.insert(
1403 "%0".to_string(),
1404 "╭────────────────────────────────╮\n\
1405 │ >_ OpenAI Codex (v0.106.0) │\n\
1406 ╰────────────────────────────────╯"
1407 .to_string(),
1408 );
1409
1410 let session_names = vec![session.to_string()];
1411 let all_pane_data = tmux.list_all_panes_with_activity();
1412 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1413
1414 assert_eq!(results.len(), 1);
1415 assert!(results[0].1.is_none());
1416 }
1417
1418 #[test]
1419 fn batched_empty_sessions_list() {
1420 let tmux = MockTmuxProvider::default();
1421 let all_pane_data = tmux.list_all_panes_with_activity();
1422 let results = super::detect_for_sessions_batched(&tmux, &[], &all_pane_data);
1423 assert!(results.is_empty());
1424 }
1425
1426 #[test]
1427 fn batched_mixed_agents_and_shells() {
1428 let mut tmux = MockTmuxProvider::default();
1430
1431 tmux.pane_info.insert(
1433 "dev-kiosk".to_string(),
1434 vec![PaneInfo {
1435 pane_id: "%0".to_string(),
1436 command: "claude".to_string(),
1437 pid: 10001,
1438 }],
1439 );
1440 tmux.pane_content
1441 .insert("%0".to_string(), "❯ \n? for shortcuts".to_string());
1442
1443 tmux.pane_info.insert(
1445 "dev-other".to_string(),
1446 vec![PaneInfo {
1447 pane_id: "%1".to_string(),
1448 command: "zsh".to_string(),
1449 pid: 10002,
1450 }],
1451 );
1452 tmux.pane_content
1453 .insert("%1".to_string(), "$ ls -la".to_string());
1454
1455 let session_names = vec!["dev-kiosk".to_string(), "dev-other".to_string()];
1456 let all_pane_data = tmux.list_all_panes_with_activity();
1457 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1458
1459 assert!(results[0].1.is_some(), "Should detect agent in dev-kiosk");
1460 assert!(
1461 results[1].1.is_none(),
1462 "Should not detect agent in dev-other"
1463 );
1464 }
1465
1466 #[test]
1467 fn batched_multi_pane_priority() {
1468 let mut tmux = MockTmuxProvider::default();
1470 tmux.pane_info.insert(
1471 "multi".to_string(),
1472 vec![
1473 PaneInfo {
1474 pane_id: "%0".to_string(),
1475 command: "claude".to_string(),
1476 pid: 10001,
1477 },
1478 PaneInfo {
1479 pane_id: "%1".to_string(),
1480 command: "claude".to_string(),
1481 pid: 10002,
1482 },
1483 ],
1484 );
1485 tmux.pane_content
1486 .insert("%0".to_string(), "⠋ Reading file".to_string());
1487 tmux.pane_content
1488 .insert("%1".to_string(), "Allow write?\n Yes, allow".to_string());
1489
1490 let session_names = vec!["multi".to_string()];
1491 let all_pane_data = tmux.list_all_panes_with_activity();
1492 let results = super::detect_for_sessions_batched(&tmux, &session_names, &all_pane_data);
1493
1494 assert_eq!(
1495 results[0].1.as_ref().unwrap().status.state,
1496 AgentState::Waiting
1497 );
1498 }
1499
1500 #[test]
1501 fn detect_from_pane_data_empty_panes() {
1502 let tmux = MockTmuxProvider::default();
1503 let data = crate::tmux::provider::SessionPaneData {
1504 panes: vec![],
1505 pane_titles: std::collections::HashMap::new(),
1506 session_activity: 0,
1507 };
1508 assert!(super::detect_from_pane_data(&tmux, &data).is_none());
1509 }
1510}