1use anyhow::Result;
2use once_cell::sync::Lazy;
3use regex::Regex;
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7use tokio::sync::mpsc;
8
9use crate::agents::{
10 AgentStatus, AgentTeamInfo, AgentType, ApprovalType, DetectionSource, MonitoredAgent,
11 TeamTaskSummaryItem,
12};
13use crate::audit::{AuditEvent, AuditLogger};
14use crate::config::{ClaudeSettingsCache, Settings};
15use crate::detectors::ClaudeCodeDetector;
16use crate::detectors::GeminiDetector;
17use crate::detectors::{get_detector, DetectionConfidence, DetectionContext, DetectionReason};
18use crate::git::GitCache;
19use crate::ipc::protocol::{WrapApprovalType, WrapState, WrapStatus};
20use crate::ipc::server::IpcRegistry;
21use crate::state::{MonitorScope, SharedState, TeamSnapshot};
22use crate::teams::{self, TaskStatus};
23use crate::tmux::{PaneInfo, ProcessCache, TmuxClient};
24
25struct CommittedAgentState {
27 status: String,
28 full_status: AgentStatus,
30 #[allow(dead_code)]
31 reason: DetectionReason,
32 agent_type: String,
33 committed_at_ms: u64,
34}
35
36struct PendingTransition {
38 new_status: String,
39 new_reason: DetectionReason,
40 first_seen: Instant,
41}
42
43fn debounce_threshold(from: &str, to: &str) -> Duration {
45 if to == "awaiting_approval" {
47 return Duration::from_millis(0);
48 }
49 if from == "awaiting_approval" {
51 return Duration::from_millis(200);
52 }
53 if (from == "idle" && to == "processing") || (from == "processing" && to == "idle") {
55 return Duration::from_millis(500);
56 }
57 Duration::from_millis(300)
59}
60
61#[derive(Debug)]
63pub enum PollMessage {
64 AgentsUpdated(Vec<MonitoredAgent>),
66 Error(String),
68}
69
70pub struct Poller {
72 client: TmuxClient,
73 process_cache: Arc<ProcessCache>,
74 claude_settings_cache: Arc<ClaudeSettingsCache>,
76 settings: Settings,
77 state: SharedState,
78 ipc_registry: IpcRegistry,
80 #[allow(dead_code)]
82 current_session: Option<String>,
83 #[allow(dead_code)]
85 current_window: Option<u32>,
86 audit_logger: AuditLogger,
88 audit_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<AuditEvent>>,
90 previous_statuses: HashMap<String, CommittedAgentState>,
92 pending_transitions: HashMap<String, PendingTransition>,
94 previous_agent_ids: HashSet<String>,
96 grace_periods: HashMap<String, Instant>,
100 git_cache: GitCache,
102}
103
104impl Poller {
105 pub fn new(
107 settings: Settings,
108 state: SharedState,
109 ipc_registry: IpcRegistry,
110 audit_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<AuditEvent>>,
111 ) -> Self {
112 let client = TmuxClient::with_capture_lines(settings.capture_lines);
113
114 let (current_session, current_window) = match client.get_current_location() {
116 Ok((session, window)) => (Some(session), Some(window)),
117 Err(_) => (None, None),
118 };
119
120 let audit_logger = AuditLogger::new(settings.audit.enabled, settings.audit.max_size_bytes);
121
122 Self {
123 client,
124 process_cache: Arc::new(ProcessCache::new()),
125 claude_settings_cache: Arc::new(ClaudeSettingsCache::new()),
126 settings,
127 state,
128 ipc_registry,
129 current_session,
130 current_window,
131 audit_logger,
132 audit_event_rx,
133 previous_statuses: HashMap::new(),
134 pending_transitions: HashMap::new(),
135 previous_agent_ids: HashSet::new(),
136 grace_periods: HashMap::new(),
137 git_cache: GitCache::new(),
138 }
139 }
140
141 pub fn start(self) -> mpsc::Receiver<PollMessage> {
143 let (tx, rx) = mpsc::channel(32);
144
145 tokio::spawn(async move {
146 self.run(tx).await;
147 });
148
149 rx
150 }
151
152 async fn run(mut self, tx: mpsc::Sender<PollMessage>) {
154 let normal_interval = self.settings.poll_interval_ms;
155 let fast_interval = self.settings.passthrough_poll_interval_ms;
156 let mut backoff_ms: u64 = 0;
157 let mut last_error: Option<String> = None;
158 let mut last_error_at: Option<Instant> = None;
159 let mut poll_count: u32 = 0;
160
161 loop {
162 let (should_stop, is_passthrough) = {
164 let state = self.state.read();
165 (!state.running, state.is_passthrough_mode())
166 };
167
168 if should_stop {
169 break;
170 }
171
172 let base_interval_ms = if is_passthrough {
174 fast_interval
175 } else {
176 normal_interval
177 };
178 let interval_ms = base_interval_ms.saturating_add(backoff_ms);
179 tokio::time::sleep(Duration::from_millis(interval_ms)).await;
180
181 match self.poll_once().await {
182 Ok((mut agents, all_panes)) => {
183 backoff_ms = 0;
184 last_error = None;
185 last_error_at = None;
186
187 poll_count = poll_count.wrapping_add(1);
189 if poll_count.is_multiple_of(10) {
190 self.process_cache.cleanup();
191 self.grace_periods
193 .retain(|_, ts| ts.elapsed().as_secs() < 30);
194 }
195
196 if self.settings.teams.enabled {
198 if poll_count.is_multiple_of(self.settings.teams.scan_interval) {
199 self.scan_and_apply_teams(&mut agents, &all_panes);
200 } else {
201 self.apply_cached_team_info(&mut agents);
202 }
203 }
204
205 if poll_count.is_multiple_of(20) {
207 self.update_git_info(&mut agents).await;
208 self.git_cache.cleanup();
209 } else {
210 self.apply_cached_git_info(&mut agents);
211 }
212
213 self.emit_audit_events(&mut agents);
215
216 if let Some(ref mut rx) = self.audit_event_rx {
218 while let Ok(event) = rx.try_recv() {
219 self.audit_logger.log(&event);
220 }
221 }
222
223 if tx.send(PollMessage::AgentsUpdated(agents)).await.is_err() {
224 break; }
226 }
227 Err(e) => {
228 let err_str = e.to_string();
229 let should_send = match &last_error {
230 Some(prev) if prev == &err_str => last_error_at
231 .map(|t| t.elapsed() >= Duration::from_secs(2))
232 .unwrap_or(true),
233 _ => true,
234 };
235
236 if should_send && tx.send(PollMessage::Error(err_str.clone())).await.is_err() {
237 break;
238 }
239
240 last_error = Some(err_str);
241 last_error_at = Some(Instant::now());
242 backoff_ms = if backoff_ms == 0 {
243 200
244 } else {
245 (backoff_ms * 2).min(2000)
246 };
247 }
248 }
249 }
250 }
251
252 async fn poll_once(&mut self) -> Result<(Vec<MonitoredAgent>, Vec<PaneInfo>)> {
257 let all_panes = self.client.list_all_panes()?;
259
260 let panes = all_panes.clone();
262
263 let selected_agent_id = {
264 let state = self.state.read();
265 state
266 .agent_order
267 .get(state.selection.selected_index)
268 .cloned()
269 };
270
271 let mut agents = Vec::new();
273
274 for pane in panes {
275 let direct_cmdline = self.process_cache.get_cmdline(pane.pid);
278 let child_cmdline = self.process_cache.get_child_cmdline(pane.pid);
279
280 let agent_type = pane
282 .detect_agent_type_with_cmdline(child_cmdline.as_deref())
283 .or_else(|| pane.detect_agent_type_with_cmdline(direct_cmdline.as_deref()));
284
285 if let Some(agent_type) = agent_type {
286 let wrap_state = {
289 let registry = self.ipc_registry.read();
290 registry.get(&pane.pane_id).cloned()
291 };
292 let is_selected = selected_agent_id.as_ref() == Some(&pane.target);
293
294 let (content_ansi, mut content) = if is_selected {
299 let ansi = self.client.capture_pane(&pane.target).unwrap_or_default();
301 let plain = strip_ansi(&ansi);
302 (ansi, plain)
303 } else if wrap_state.is_some() {
304 (String::new(), String::new())
306 } else {
307 let plain = self
309 .client
310 .capture_pane_plain(&pane.target)
311 .unwrap_or_default();
312 (String::new(), plain)
313 };
314
315 let title = self
316 .client
317 .get_pane_title(&pane.target)
318 .unwrap_or(pane.title.clone());
319
320 let mut screen_override = false;
322 let (status, context_warning, detection_reason) = if let Some(ref ws) = wrap_state {
323 let status = wrap_state_to_agent_status(ws);
325
326 if !matches!(status, AgentStatus::AwaitingApproval { .. }) {
329 let plain = if !content.is_empty() {
332 content.clone()
333 } else {
334 self.client
335 .capture_pane_plain(&pane.target)
336 .unwrap_or_default()
337 };
338 let detection_context = DetectionContext {
339 cwd: Some(pane.cwd.as_str()),
340 settings_cache: Some(&self.claude_settings_cache),
341 };
342 let detector = get_detector(&agent_type);
343 let result =
344 detector.detect_status_with_reason(&title, &plain, &detection_context);
345 if matches!(result.status, AgentStatus::AwaitingApproval { .. })
346 && result.reason.confidence == DetectionConfidence::High
347 {
348 let context_warning = detector.detect_context_warning(&plain);
350 content = plain;
351 screen_override = true;
352 (result.status, context_warning, Some(result.reason))
353 } else {
354 let status = enrich_ipc_activity(status, &result.status, &title);
357 let matched_text =
358 if let AgentStatus::Processing { ref activity } = status {
359 if !activity.is_empty() {
360 Some(format!("enriched: {}", activity))
361 } else {
362 None
363 }
364 } else {
365 None
366 };
367 let reason = DetectionReason {
368 rule: "ipc_state".to_string(),
369 confidence: DetectionConfidence::High,
370 matched_text,
371 };
372 (status, None, Some(reason))
373 }
374 } else {
375 let reason = DetectionReason {
376 rule: "ipc_state".to_string(),
377 confidence: DetectionConfidence::High,
378 matched_text: None,
379 };
380 (status, None, Some(reason))
381 }
382 } else {
383 let detection_context = DetectionContext {
385 cwd: Some(pane.cwd.as_str()),
386 settings_cache: Some(&self.claude_settings_cache),
387 };
388
389 let detector = get_detector(&agent_type);
391 let result =
392 detector.detect_status_with_reason(&title, &content, &detection_context);
393 let context_warning = detector.detect_context_warning(&content);
394 (result.status, context_warning, Some(result.reason))
395 };
396
397 let status = self.apply_grace_period(&pane.target, status, &detection_reason);
402
403 let mut agent = MonitoredAgent::new(
404 pane.target.clone(),
405 agent_type,
406 title,
407 pane.cwd.clone(),
408 pane.pid,
409 pane.session.clone(),
410 pane.window_name.clone(),
411 pane.window_index,
412 pane.pane_index,
413 );
414 agent.status = status;
415 agent.last_content = content;
416 agent.last_content_ansi = content_ansi;
417 agent.context_warning = context_warning;
418 agent.detection_reason = detection_reason;
419 agent.detection_source = if screen_override {
420 DetectionSource::CapturePane
421 } else if wrap_state.is_some() {
422 DetectionSource::IpcSocket
423 } else {
424 DetectionSource::CapturePane
425 };
426
427 match agent.agent_type {
429 AgentType::ClaudeCode => {
430 agent.mode = ClaudeCodeDetector::detect_mode(&agent.title);
431 }
432 AgentType::GeminiCli => {
433 agent.mode = GeminiDetector::detect_mode(&agent.last_content);
434 }
435 _ => {}
436 }
437
438 agents.push(agent);
439 }
440 }
441
442 agents.sort_by(|a, b| {
444 a.session
445 .cmp(&b.session)
446 .then(a.window_index.cmp(&b.window_index))
447 .then(a.pane_index.cmp(&b.pane_index))
448 });
449
450 let target_to_pane_id: HashMap<String, String> = all_panes
452 .iter()
453 .map(|p| (p.target.clone(), p.pane_id.clone()))
454 .collect();
455
456 {
458 let mut state = self.state.write();
459 state.target_to_pane_id = target_to_pane_id;
460 }
461
462 Ok((agents, all_panes))
463 }
464
465 fn scan_and_apply_teams(&self, agents: &mut Vec<MonitoredAgent>, all_panes: &[PaneInfo]) {
470 let team_configs = match teams::scan_teams() {
471 Ok(configs) => configs,
472 Err(_) => return,
473 };
474
475 if team_configs.is_empty() {
476 let mut state = self.state.write();
478 state.teams.clear();
479 return;
480 }
481
482 let agent_pids: Vec<(String, u32)> =
484 agents.iter().map(|a| (a.target.clone(), a.pid)).collect();
485
486 let all_pane_pids: Vec<(String, u32)> = all_panes
488 .iter()
489 .map(|p| (p.target.clone(), p.pid))
490 .collect();
491
492 let mut cmdline_cache: HashMap<u32, Option<String>> = HashMap::new();
494 for (_, pid) in agent_pids.iter().chain(all_pane_pids.iter()) {
495 cmdline_cache.entry(*pid).or_insert_with(|| {
496 self.process_cache
497 .get_child_cmdline(*pid)
498 .or_else(|| self.process_cache.get_cmdline(*pid))
499 });
500 }
501
502 let mut unique_pids: HashMap<u32, String> = HashMap::new();
504 for (target, pid) in agent_pids.iter().chain(all_pane_pids.iter()) {
505 unique_pids.entry(*pid).or_insert_with(|| target.clone());
506 }
507
508 let mut snapshots: HashMap<String, TeamSnapshot> = HashMap::new();
509
510 for team_config in &team_configs {
511 let tasks = teams::scan_tasks(&team_config.team_name).unwrap_or_default();
513
514 let member_panes = teams::map_members_to_panes(team_config, &all_pane_pids);
516
517 let mut cmdline_mapping: HashMap<String, String> = HashMap::new();
519 let mut matched_members: std::collections::HashSet<&str> =
520 std::collections::HashSet::new();
521 for (pid, target) in &unique_pids {
522 if matched_members.len() == team_config.members.len() {
523 break; }
525 if let Some(Some(cl)) = cmdline_cache.get(pid) {
526 for member in &team_config.members {
527 if matched_members.contains(member.name.as_str()) {
528 continue; }
530 let marker = format!("--agent-id {}", member.agent_id);
532 if cl.contains(&marker) {
533 cmdline_mapping.insert(member.name.clone(), target.clone());
534 matched_members.insert(&member.name);
535 break;
536 }
537 }
538 }
539 }
540
541 let mut final_mapping = member_panes;
543 for (name, target) in cmdline_mapping {
544 final_mapping.insert(name, target);
545 }
546
547 if let Some(leader) = team_config.members.first() {
549 if !final_mapping.contains_key(&leader.name) {
550 if let Some(leader_cwd) = &leader.cwd {
551 let mapped_targets: std::collections::HashSet<&str> =
552 final_mapping.values().map(|s| s.as_str()).collect();
553 if let Some(agent) = agents.iter().find(|a| {
555 a.cwd == *leader_cwd
556 && !a.is_virtual
557 && !mapped_targets.contains(a.target.as_str())
558 && a.team_info.is_none()
559 }) {
560 final_mapping.insert(leader.name.clone(), agent.target.clone());
561 }
562 }
563 }
564 }
565
566 for (member_name, pane_target) in &final_mapping {
568 let is_lead = team_config
569 .members
570 .first()
571 .map(|m| &m.name == member_name)
572 .unwrap_or(false);
573
574 let team_info =
575 build_member_team_info(&team_config.team_name, member_name, is_lead, &tasks);
576
577 if let Some(agent) = agents.iter_mut().find(|a| &a.target == pane_target) {
578 agent.team_info = Some(team_info);
580 } else if let Some(pane) = all_panes.iter().find(|p| &p.target == pane_target) {
581 let agent_type = pane.detect_agent_type().unwrap_or(AgentType::ClaudeCode);
583 let mut new_agent = MonitoredAgent::new(
584 pane.target.clone(),
585 agent_type,
586 pane.title.clone(),
587 pane.cwd.clone(),
588 pane.pid,
589 pane.session.clone(),
590 pane.window_name.clone(),
591 pane.window_index,
592 pane.pane_index,
593 );
594 new_agent.team_info = Some(team_info);
595 agents.push(new_agent);
596 }
597 }
598
599 let team_cwd: String = final_mapping
601 .values()
602 .find_map(|target| agents.iter().find(|a| &a.target == target))
603 .map(|a| a.cwd.clone())
604 .unwrap_or_default();
605
606 for member in &team_config.members {
608 if !final_mapping.contains_key(&member.name) {
609 let is_lead = team_config
610 .members
611 .first()
612 .map(|m| m.name == member.name)
613 .unwrap_or(false);
614
615 let team_info = build_member_team_info(
616 &team_config.team_name,
617 &member.name,
618 is_lead,
619 &tasks,
620 );
621 agents.push(create_virtual_agent(
622 &team_config.team_name,
623 &member.name,
624 team_info,
625 &team_cwd,
626 ));
627 }
628 }
629
630 let task_done = tasks
631 .iter()
632 .filter(|t| t.status == TaskStatus::Completed)
633 .count();
634 let task_total = tasks.len();
635 let task_in_progress = tasks
636 .iter()
637 .filter(|t| t.status == TaskStatus::InProgress)
638 .count();
639 let task_pending = tasks
640 .iter()
641 .filter(|t| t.status == TaskStatus::Pending)
642 .count();
643
644 snapshots.insert(
645 team_config.team_name.clone(),
646 TeamSnapshot {
647 config: team_config.clone(),
648 tasks,
649 member_panes: final_mapping,
650 last_scan: chrono::Utc::now(),
651 task_done,
652 task_total,
653 task_in_progress,
654 task_pending,
655 },
656 );
657 }
658
659 let mut state = self.state.write();
661 state.teams = snapshots;
662 }
663
664 fn apply_cached_team_info(&self, agents: &mut Vec<MonitoredAgent>) {
670 let state = self.state.read();
671 if state.teams.is_empty() {
672 return;
673 }
674
675 for snapshot in state.teams.values() {
676 for (member_name, pane_target) in &snapshot.member_panes {
677 let is_lead = snapshot
678 .config
679 .members
680 .first()
681 .map(|m| &m.name == member_name)
682 .unwrap_or(false);
683
684 let team_info = build_member_team_info(
685 &snapshot.config.team_name,
686 member_name,
687 is_lead,
688 &snapshot.tasks,
689 );
690
691 if let Some(agent) = agents.iter_mut().find(|a| &a.target == pane_target) {
692 agent.team_info = Some(team_info);
693 }
694 }
695
696 let team_cwd: String = snapshot
698 .member_panes
699 .values()
700 .find_map(|target| agents.iter().find(|a| &a.target == target))
701 .map(|a| a.cwd.clone())
702 .unwrap_or_default();
703
704 for member in &snapshot.config.members {
706 if !snapshot.member_panes.contains_key(&member.name) {
707 let is_lead = snapshot
708 .config
709 .members
710 .first()
711 .map(|m| m.name == member.name)
712 .unwrap_or(false);
713
714 let team_info = build_member_team_info(
715 &snapshot.config.team_name,
716 &member.name,
717 is_lead,
718 &snapshot.tasks,
719 );
720 agents.push(create_virtual_agent(
721 &snapshot.config.team_name,
722 &member.name,
723 team_info,
724 &team_cwd,
725 ));
726 }
727 }
728 }
729 }
730
731 #[allow(dead_code)]
733 fn matches_scope(&self, pane: &PaneInfo, scope: MonitorScope) -> bool {
734 match scope {
735 MonitorScope::AllSessions => true,
736 MonitorScope::CurrentSession => self
737 .current_session
738 .as_ref()
739 .map(|s| s == &pane.session)
740 .unwrap_or(true),
741 MonitorScope::CurrentWindow => {
742 let session_match = self
743 .current_session
744 .as_ref()
745 .map(|s| s == &pane.session)
746 .unwrap_or(true);
747 let window_match = self
748 .current_window
749 .map(|w| w == pane.window_index)
750 .unwrap_or(true);
751 session_match && window_match
752 }
753 }
754 }
755
756 fn emit_audit_events(&mut self, agents: &mut [MonitoredAgent]) {
758 let ts = SystemTime::now()
759 .duration_since(UNIX_EPOCH)
760 .unwrap_or_default()
761 .as_millis() as u64;
762
763 let current_ids: HashSet<String> = agents
764 .iter()
765 .filter(|a| !a.is_virtual)
766 .map(|a| a.target.clone())
767 .collect();
768
769 for target in &self.previous_agent_ids {
771 if !current_ids.contains(target) {
772 let (last_status, agent_type) = self
773 .previous_statuses
774 .get(target)
775 .map(|c| (c.status.clone(), c.agent_type.clone()))
776 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
777 self.audit_logger.log(&AuditEvent::AgentDisappeared {
778 ts,
779 pane_id: target.clone(),
780 agent_type,
781 last_status,
782 });
783 self.pending_transitions.remove(target);
785 }
786 }
787
788 for agent in agents.iter_mut() {
789 if agent.is_virtual {
790 continue;
791 }
792
793 let current_status_name = status_name(&agent.status).to_string();
794 let reason = agent
795 .detection_reason
796 .clone()
797 .unwrap_or_else(|| DetectionReason {
798 rule: "unknown".to_string(),
799 confidence: DetectionConfidence::Low,
800 matched_text: None,
801 });
802 let source = agent.detection_source.label().to_string();
803
804 if !self.previous_agent_ids.contains(&agent.target) {
805 self.audit_logger.log(&AuditEvent::AgentAppeared {
807 ts,
808 pane_id: agent.target.clone(),
809 agent_type: agent.agent_type.short_name().to_string(),
810 source,
811 initial_status: current_status_name.clone(),
812 });
813 self.previous_statuses.insert(
814 agent.target.clone(),
815 CommittedAgentState {
816 status: current_status_name,
817 full_status: agent.status.clone(),
818 reason,
819 agent_type: agent.agent_type.short_name().to_string(),
820 committed_at_ms: ts,
821 },
822 );
823 continue;
824 }
825
826 let committed = self.previous_statuses.get(&agent.target);
827 let committed_status = committed.map(|c| c.status.as_str()).unwrap_or("unknown");
828
829 if committed_status == current_status_name {
830 self.pending_transitions.remove(&agent.target);
832 continue;
833 }
834
835 let threshold = debounce_threshold(committed_status, ¤t_status_name);
837
838 if threshold.is_zero() {
839 self.pending_transitions.remove(&agent.target);
841 let prev_duration = committed.map(|c| ts.saturating_sub(c.committed_at_ms));
842
843 let screen_context = if !agent.last_content.is_empty() {
844 let lines: Vec<&str> = agent.last_content.lines().collect();
845 let start = lines.len().saturating_sub(20);
846 let tail = lines[start..].join("\n");
847 Some(if tail.len() > 2000 {
848 tail[..tail.floor_char_boundary(2000)].to_string()
849 } else {
850 tail
851 })
852 } else {
853 None
854 };
855
856 let (approval_type, approval_details) = extract_approval_info(&agent.status);
857 self.audit_logger.log(&AuditEvent::StateChanged {
858 ts,
859 pane_id: agent.target.clone(),
860 agent_type: agent.agent_type.short_name().to_string(),
861 source,
862 prev_status: committed_status.to_string(),
863 new_status: current_status_name.clone(),
864 reason: reason.clone(),
865 screen_context,
866 prev_state_duration_ms: prev_duration,
867 approval_type,
868 approval_details,
869 });
870 self.previous_statuses.insert(
871 agent.target.clone(),
872 CommittedAgentState {
873 status: current_status_name,
874 full_status: agent.status.clone(),
875 reason,
876 agent_type: agent.agent_type.short_name().to_string(),
877 committed_at_ms: ts,
878 },
879 );
880 } else if let Some(pending) = self.pending_transitions.get(&agent.target) {
881 if pending.new_status == current_status_name {
882 if pending.first_seen.elapsed() >= threshold {
884 let pending = self.pending_transitions.remove(&agent.target).unwrap();
886 let prev_duration = committed.map(|c| ts.saturating_sub(c.committed_at_ms));
887
888 let screen_context = if !agent.last_content.is_empty() {
889 let lines: Vec<&str> = agent.last_content.lines().collect();
890 let start = lines.len().saturating_sub(20);
891 let tail = lines[start..].join("\n");
892 Some(if tail.len() > 2000 {
893 tail[..tail.floor_char_boundary(2000)].to_string()
894 } else {
895 tail
896 })
897 } else {
898 None
899 };
900
901 let (approval_type, approval_details) =
902 extract_approval_info(&agent.status);
903 self.audit_logger.log(&AuditEvent::StateChanged {
904 ts,
905 pane_id: agent.target.clone(),
906 agent_type: agent.agent_type.short_name().to_string(),
907 source,
908 prev_status: committed_status.to_string(),
909 new_status: current_status_name.clone(),
910 reason: pending.new_reason.clone(),
911 screen_context,
912 prev_state_duration_ms: prev_duration,
913 approval_type,
914 approval_details,
915 });
916 self.previous_statuses.insert(
917 agent.target.clone(),
918 CommittedAgentState {
919 status: current_status_name.clone(),
920 full_status: agent.status.clone(),
921 reason: pending.new_reason,
922 agent_type: agent.agent_type.short_name().to_string(),
923 committed_at_ms: ts,
924 },
925 );
926 } else {
927 if let Some(committed) = self.previous_statuses.get(&agent.target) {
929 agent.status = committed.full_status.clone();
930 }
931 }
932 } else {
933 self.pending_transitions.insert(
935 agent.target.clone(),
936 PendingTransition {
937 new_status: current_status_name.clone(),
938 new_reason: reason,
939 first_seen: Instant::now(),
940 },
941 );
942 if let Some(committed) = self.previous_statuses.get(&agent.target) {
944 agent.status = match committed.status.as_str() {
945 "idle" => AgentStatus::Idle,
946 "processing" => AgentStatus::Processing {
947 activity: String::new(),
948 },
949 "error" => AgentStatus::Error {
950 message: String::new(),
951 },
952 _ => agent.status.clone(),
953 };
954 }
955 }
956 } else {
957 self.pending_transitions.insert(
959 agent.target.clone(),
960 PendingTransition {
961 new_status: current_status_name.clone(),
962 new_reason: reason,
963 first_seen: Instant::now(),
964 },
965 );
966 if let Some(committed) = self.previous_statuses.get(&agent.target) {
968 agent.status = match committed.status.as_str() {
969 "idle" => AgentStatus::Idle,
970 "processing" => AgentStatus::Processing {
971 activity: String::new(),
972 },
973 "error" => AgentStatus::Error {
974 message: String::new(),
975 },
976 _ => agent.status.clone(),
977 };
978 }
979 }
980 }
981
982 self.previous_agent_ids = current_ids;
983 }
984
985 fn apply_grace_period(
993 &mut self,
994 target: &str,
995 status: AgentStatus,
996 detection_reason: &Option<DetectionReason>,
997 ) -> AgentStatus {
998 const GRACE_PERIOD_SECS: u64 = 6;
999
1000 match &status {
1001 AgentStatus::Processing { .. } => {
1002 self.grace_periods
1004 .insert(target.to_string(), Instant::now());
1005 status
1006 }
1007 AgentStatus::AwaitingApproval { .. } | AgentStatus::Error { .. } => {
1008 self.grace_periods.remove(target);
1010 status
1011 }
1012 AgentStatus::Idle | AgentStatus::Unknown => {
1013 let is_low_confidence = detection_reason
1015 .as_ref()
1016 .map(|r| {
1017 r.rule == "fallback_no_indicator"
1018 || r.confidence == DetectionConfidence::Low
1019 })
1020 .unwrap_or(false);
1021
1022 if is_low_confidence || matches!(status, AgentStatus::Idle) {
1023 if let Some(last_processing) = self.grace_periods.get(target) {
1024 if last_processing.elapsed().as_secs() < GRACE_PERIOD_SECS {
1025 return AgentStatus::Processing {
1027 activity: String::new(),
1028 };
1029 } else {
1030 self.grace_periods.remove(target);
1032 }
1033 }
1034 }
1035 status
1036 }
1037 _ => status,
1038 }
1039 }
1040
1041 async fn update_git_info(&mut self, agents: &mut [MonitoredAgent]) {
1043 for agent in agents.iter_mut() {
1044 if agent.is_virtual || agent.cwd.is_empty() {
1045 continue;
1046 }
1047 if let Some(info) = self.git_cache.get_info(&agent.cwd).await {
1048 agent.git_branch = Some(info.branch);
1049 agent.git_dirty = Some(info.dirty);
1050 agent.is_worktree = Some(info.is_worktree);
1051 agent.git_common_dir = info.common_dir.clone();
1052 agent.worktree_name = crate::git::extract_claude_worktree_name(&agent.cwd);
1053 }
1054 }
1055 }
1056
1057 fn apply_cached_git_info(&self, agents: &mut [MonitoredAgent]) {
1059 for agent in agents.iter_mut() {
1060 if agent.is_virtual || agent.cwd.is_empty() {
1061 continue;
1062 }
1063 if let Some(info) = self.git_cache.get_cached(&agent.cwd) {
1064 agent.git_branch = Some(info.branch);
1065 agent.git_dirty = Some(info.dirty);
1066 agent.is_worktree = Some(info.is_worktree);
1067 agent.git_common_dir = info.common_dir.clone();
1068 agent.worktree_name = crate::git::extract_claude_worktree_name(&agent.cwd);
1069 }
1070 }
1071 }
1072
1073 pub fn cleanup_cache(&self) {
1075 self.process_cache.cleanup();
1076 }
1077}
1078
1079#[allow(dead_code)]
1081pub fn detect_agent_from_pane(pane: &PaneInfo, client: &TmuxClient) -> Option<MonitoredAgent> {
1082 let agent_type = pane.detect_agent_type()?;
1083
1084 let content_ansi = client.capture_pane(&pane.target).unwrap_or_default();
1085 let content = strip_ansi(&content_ansi);
1086 let title = client
1087 .get_pane_title(&pane.target)
1088 .unwrap_or(pane.title.clone());
1089
1090 let detector = get_detector(&agent_type);
1091 let status = detector.detect_status(&title, &content);
1092 let context_warning = detector.detect_context_warning(&content);
1093
1094 let mut agent = MonitoredAgent::new(
1095 pane.target.clone(),
1096 agent_type,
1097 title,
1098 pane.cwd.clone(),
1099 pane.pid,
1100 pane.session.clone(),
1101 pane.window_name.clone(),
1102 pane.window_index,
1103 pane.pane_index,
1104 );
1105 agent.status = status;
1106 agent.last_content = content;
1107 agent.last_content_ansi = content_ansi;
1108 agent.context_warning = context_warning;
1109
1110 Some(agent)
1111}
1112
1113fn strip_ansi(input: &str) -> String {
1114 static OSC_RE: Lazy<Regex> =
1116 Lazy::new(|| Regex::new(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)").unwrap());
1117 static CSI_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap());
1118
1119 let without_osc = OSC_RE.replace_all(input, "");
1120 CSI_RE.replace_all(&without_osc, "").to_string()
1121}
1122
1123fn build_member_team_info(
1125 team_name: &str,
1126 member_name: &str,
1127 is_lead: bool,
1128 tasks: &[teams::TeamTask],
1129) -> AgentTeamInfo {
1130 let current_task = tasks
1131 .iter()
1132 .find(|t| t.owner.as_deref() == Some(member_name) && t.status == TaskStatus::InProgress)
1133 .map(|t| TeamTaskSummaryItem {
1134 id: t.id.clone(),
1135 subject: t.subject.clone(),
1136 status: t.status,
1137 active_form: t.active_form.clone(),
1138 });
1139
1140 AgentTeamInfo {
1141 team_name: team_name.to_string(),
1142 member_name: member_name.to_string(),
1143 is_lead,
1144 current_task,
1145 }
1146}
1147
1148fn create_virtual_agent(
1152 team_name: &str,
1153 member_name: &str,
1154 team_info: AgentTeamInfo,
1155 cwd: &str,
1156) -> MonitoredAgent {
1157 let virtual_target = format!("~team:{}:{}", team_name, member_name);
1158 let mut agent = MonitoredAgent::new(
1159 virtual_target,
1160 AgentType::ClaudeCode,
1161 String::new(),
1162 cwd.to_string(),
1163 0,
1164 String::new(),
1165 String::new(),
1166 0,
1167 0,
1168 );
1169 agent.status = AgentStatus::Offline;
1170 agent.is_virtual = true;
1171 agent.team_info = Some(team_info);
1172 agent
1173}
1174
1175fn extract_approval_info(status: &AgentStatus) -> (Option<String>, Option<String>) {
1177 if let AgentStatus::AwaitingApproval {
1178 approval_type,
1179 details,
1180 } = status
1181 {
1182 let type_str = match approval_type {
1183 ApprovalType::FileEdit => "file_edit".to_string(),
1184 ApprovalType::FileCreate => "file_create".to_string(),
1185 ApprovalType::FileDelete => "file_delete".to_string(),
1186 ApprovalType::ShellCommand => "shell_command".to_string(),
1187 ApprovalType::McpTool => "mcp_tool".to_string(),
1188 ApprovalType::UserQuestion { .. } => "user_question".to_string(),
1189 ApprovalType::Other(s) => format!("other:{}", s),
1190 };
1191 let details_opt = if details.is_empty() {
1192 None
1193 } else {
1194 Some(details.clone())
1195 };
1196 (Some(type_str), details_opt)
1197 } else {
1198 (None, None)
1199 }
1200}
1201
1202fn status_name(status: &AgentStatus) -> &'static str {
1204 match status {
1205 AgentStatus::Idle => "idle",
1206 AgentStatus::Processing { .. } => "processing",
1207 AgentStatus::AwaitingApproval { .. } => "awaiting_approval",
1208 AgentStatus::Error { .. } => "error",
1209 AgentStatus::Offline => "offline",
1210 AgentStatus::Unknown => "unknown",
1211 }
1212}
1213
1214fn extract_activity_from_title(title: &str) -> String {
1220 if title.contains('✳') {
1222 return String::new();
1223 }
1224 let cleaned: String = title
1225 .chars()
1226 .filter(|&c| !matches!(c, '⠂' | '⠐' | '⏸' | '⇢' | '⏵'))
1227 .collect();
1228 let trimmed = cleaned.trim();
1229 if trimmed.is_empty() {
1230 return String::new();
1231 }
1232 trimmed.to_string()
1233}
1234
1235fn enrich_ipc_activity(
1240 ipc_status: AgentStatus,
1241 screen_status: &AgentStatus,
1242 title: &str,
1243) -> AgentStatus {
1244 if let AgentStatus::Processing { ref activity } = ipc_status {
1245 if activity.is_empty() {
1246 if let AgentStatus::Processing {
1248 activity: ref screen_activity,
1249 } = screen_status
1250 {
1251 if !screen_activity.is_empty() {
1252 return AgentStatus::Processing {
1253 activity: screen_activity.clone(),
1254 };
1255 }
1256 }
1257 let title_activity = extract_activity_from_title(title);
1259 if !title_activity.is_empty() {
1260 return AgentStatus::Processing {
1261 activity: title_activity,
1262 };
1263 }
1264 }
1265 }
1266 ipc_status
1267}
1268
1269fn wrap_state_to_agent_status(ws: &WrapState) -> AgentStatus {
1271 match ws.status {
1272 WrapStatus::Processing => AgentStatus::Processing {
1273 activity: String::new(),
1274 },
1275 WrapStatus::Idle => AgentStatus::Idle,
1276 WrapStatus::AwaitingApproval => {
1277 let approval_type = match ws.approval_type {
1278 Some(WrapApprovalType::UserQuestion) => ApprovalType::UserQuestion {
1279 choices: ws.choices.clone(),
1280 multi_select: ws.multi_select,
1281 cursor_position: ws.cursor_position,
1282 },
1283 Some(WrapApprovalType::FileEdit) => ApprovalType::FileEdit,
1284 Some(WrapApprovalType::ShellCommand) => ApprovalType::ShellCommand,
1285 Some(WrapApprovalType::McpTool) => ApprovalType::McpTool,
1286 Some(WrapApprovalType::YesNo) => ApprovalType::Other("Yes/No".to_string()),
1287 Some(WrapApprovalType::Other) => ApprovalType::Other("Approval".to_string()),
1288 None => ApprovalType::Other("Approval".to_string()),
1289 };
1290 let details = ws.details.clone().unwrap_or_default();
1291 AgentStatus::AwaitingApproval {
1292 approval_type,
1293 details,
1294 }
1295 }
1296 }
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use crate::state::AppState;
1303
1304 #[test]
1305 fn test_poller_creation() {
1306 let settings = Settings::default();
1307 let state = AppState::shared();
1308 let ipc_registry = Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new()));
1309 let _poller = Poller::new(settings, state, ipc_registry, None);
1310 }
1311
1312 #[test]
1313 fn test_extract_activity_from_title() {
1314 assert_eq!(extract_activity_from_title("⠐ Compacting"), "Compacting");
1316 assert_eq!(extract_activity_from_title("⠂ Levitating"), "Levitating");
1317 assert_eq!(extract_activity_from_title("✳"), "");
1319 assert_eq!(extract_activity_from_title("✳ idle text"), "");
1320 assert_eq!(extract_activity_from_title(""), "");
1322 assert_eq!(extract_activity_from_title(" "), "");
1323 assert_eq!(extract_activity_from_title("⠐"), "");
1325 assert_eq!(extract_activity_from_title("⏸ ⠐ Planning"), "Planning");
1327 }
1328
1329 #[test]
1330 fn test_enrich_ipc_activity_screen_priority() {
1331 let ipc = AgentStatus::Processing {
1332 activity: String::new(),
1333 };
1334 let screen = AgentStatus::Processing {
1335 activity: "✶ Compacting…".to_string(),
1336 };
1337 let result = enrich_ipc_activity(ipc, &screen, "⠐ Compacting");
1338 assert!(
1339 matches!(result, AgentStatus::Processing { ref activity } if activity == "✶ Compacting…")
1340 );
1341 }
1342
1343 #[test]
1344 fn test_enrich_ipc_activity_title_fallback() {
1345 let ipc = AgentStatus::Processing {
1346 activity: String::new(),
1347 };
1348 let screen = AgentStatus::Idle;
1350 let result = enrich_ipc_activity(ipc, &screen, "⠐ Compacting");
1351 assert!(
1352 matches!(result, AgentStatus::Processing { ref activity } if activity == "Compacting")
1353 );
1354 }
1355
1356 #[test]
1357 fn test_enrich_ipc_activity_no_enrichment_when_filled() {
1358 let ipc = AgentStatus::Processing {
1359 activity: "Already set".to_string(),
1360 };
1361 let screen = AgentStatus::Processing {
1362 activity: "Other".to_string(),
1363 };
1364 let result = enrich_ipc_activity(ipc, &screen, "⠐ Compacting");
1365 assert!(
1366 matches!(result, AgentStatus::Processing { ref activity } if activity == "Already set")
1367 );
1368 }
1369}