Skip to main content

tmai_core/monitor/
poller.rs

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
25/// Tracks the last committed (actually emitted) state for an agent
26struct CommittedAgentState {
27    status: String,
28    /// Full AgentStatus preserved for debounce override (retains activity/error text)
29    full_status: AgentStatus,
30    #[allow(dead_code)]
31    reason: DetectionReason,
32    agent_type: String,
33    committed_at_ms: u64,
34}
35
36/// A pending state transition waiting to be committed after debounce period
37struct PendingTransition {
38    new_status: String,
39    new_reason: DetectionReason,
40    first_seen: Instant,
41}
42
43/// Calculate debounce threshold for a state transition
44fn debounce_threshold(from: &str, to: &str) -> Duration {
45    // Approval should be shown immediately
46    if to == "awaiting_approval" {
47        return Duration::from_millis(0);
48    }
49    // User action after approval is fast
50    if from == "awaiting_approval" {
51        return Duration::from_millis(200);
52    }
53    // idle<->processing oscillation is the main noise source
54    if (from == "idle" && to == "processing") || (from == "processing" && to == "idle") {
55        return Duration::from_millis(500);
56    }
57    // Default
58    Duration::from_millis(300)
59}
60
61/// Message sent from poller to main loop
62#[derive(Debug)]
63pub enum PollMessage {
64    /// Updated list of agents
65    AgentsUpdated(Vec<MonitoredAgent>),
66    /// Error during polling
67    Error(String),
68}
69
70/// Poller for monitoring tmux panes
71pub struct Poller {
72    client: TmuxClient,
73    process_cache: Arc<ProcessCache>,
74    /// Cache for Claude Code settings (spinnerVerbs)
75    claude_settings_cache: Arc<ClaudeSettingsCache>,
76    settings: Settings,
77    state: SharedState,
78    /// IPC registry for reading wrapper states
79    ipc_registry: IpcRegistry,
80    /// Current session name (captured at startup, unused while scope is disabled)
81    #[allow(dead_code)]
82    current_session: Option<String>,
83    /// Current window index (captured at startup, unused while scope is disabled)
84    #[allow(dead_code)]
85    current_window: Option<u32>,
86    /// Audit logger for detection events
87    audit_logger: AuditLogger,
88    /// Receiver for audit events from external sources (UI, Web API)
89    audit_event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<AuditEvent>>,
90    /// Previous status per agent target for change detection
91    previous_statuses: HashMap<String, CommittedAgentState>,
92    /// Pending state transitions waiting for debounce period to elapse
93    pending_transitions: HashMap<String, PendingTransition>,
94    /// Set of agent targets seen in the previous poll
95    previous_agent_ids: HashSet<String>,
96    /// Grace period tracker: keeps agents in Processing for up to 6 seconds after
97    /// the spinner disappears, preventing Processing→Idle→Processing flicker
98    /// during tool call gaps.
99    grace_periods: HashMap<String, Instant>,
100    /// Git branch/dirty cache for agent cwd directories
101    git_cache: GitCache,
102}
103
104impl Poller {
105    /// Create a new poller
106    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        // Capture current location at startup for scope filtering
115        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    /// Start polling in a background task
142    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    /// Run the polling loop
153    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            // Check if we should stop and get passthrough state
163            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            // Use faster interval in passthrough mode for responsive preview updates
173            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                    // Periodic cache cleanup (every 10 polls)
188                    poll_count = poll_count.wrapping_add(1);
189                    if poll_count.is_multiple_of(10) {
190                        self.process_cache.cleanup();
191                        // Remove expired grace periods (> 30s old to avoid unbounded growth)
192                        self.grace_periods
193                            .retain(|_, ts| ts.elapsed().as_secs() < 30);
194                    }
195
196                    // Team scanning at configured interval, or re-apply cached info
197                    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                    // Git branch detection (every ~10 seconds)
206                    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                    // Audit: track state transitions
214                    self.emit_audit_events(&mut agents);
215
216                    // Drain externally-submitted audit events (from UI/Web)
217                    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; // Receiver dropped
225                    }
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    /// Perform a single poll
253    ///
254    /// Returns `(agents, all_panes)` where `all_panes` includes detached sessions
255    /// for use in team scanning.
256    async fn poll_once(&mut self) -> Result<(Vec<MonitoredAgent>, Vec<PaneInfo>)> {
257        // Always get all panes (needed for team scanning)
258        let all_panes = self.client.list_all_panes()?;
259
260        // Use all panes (scope filtering temporarily disabled — always AllSessions)
261        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        // Filter and convert to monitored agents
272        let mut agents = Vec::new();
273
274        for pane in panes {
275            // Get cmdline from process cache for better detection
276            // Try direct cmdline first, then child process cmdline (for shell -> agent)
277            let direct_cmdline = self.process_cache.get_cmdline(pane.pid);
278            let child_cmdline = self.process_cache.get_child_cmdline(pane.pid);
279
280            // Try detection with child cmdline first (more specific for agents under shell)
281            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                // Try to read state from IPC registry first
287                // Use pane_id (global unique ID like "5" from "%5") not pane_index (local window index)
288                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                // Optimize capture-pane based on selection and IPC state:
295                // - Selected: ANSI capture for preview
296                // - Non-selected + IPC: skip capture-pane entirely (state from IPC registry)
297                // - Non-selected + capture-pane mode: plain capture for detection only
298                let (content_ansi, mut content) = if is_selected {
299                    // Selected agent: full ANSI capture for preview
300                    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                    // Non-selected + IPC mode: skip capture-pane entirely
305                    (String::new(), String::new())
306                } else {
307                    // Non-selected + capture-pane mode: plain capture for detection
308                    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                // Determine status: use IPC state if available, otherwise detect from content
321                let mut screen_override = false;
322                let (status, context_warning, detection_reason) = if let Some(ref ws) = wrap_state {
323                    // Convert WrapState to AgentStatus
324                    let status = wrap_state_to_agent_status(ws);
325
326                    // P1: IPC Approval lag correction — when IPC reports non-Approval,
327                    // check screen content for High-confidence Approval patterns
328                    if !matches!(status, AgentStatus::AwaitingApproval { .. }) {
329                        // Reuse existing content if available (selected agent already has
330                        // plain text from ANSI capture), otherwise capture for non-selected agents
331                        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                            // Screen override: Approval visible on screen but IPC lagging
349                            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                            // Enrich IPC Processing with screen-detected activity
355                            // (e.g., "Compacting..." from title or spinner verb)
356                            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                    // Build detection context for this pane
384                    let detection_context = DetectionContext {
385                        cwd: Some(pane.cwd.as_str()),
386                        settings_cache: Some(&self.claude_settings_cache),
387                    };
388
389                    // Detect status using appropriate detector with reason
390                    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                // Grace period: prevent Processing→Idle flicker during tool call gaps.
398                // When Processing is detected, record the timestamp.
399                // When Idle or fallback is detected, maintain Processing if within 6 seconds.
400                // Approval and Error always bypass the grace period.
401                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                // Detect permission mode from title/content
428                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        // Sort by session and window/pane
443        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        // Build target → pane_id mapping for IPC key sending
451        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        // Update in app state
457        {
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    /// Scan for teams and apply team info to agents
466    ///
467    /// Also performs cross-session scanning and creates virtual agents
468    /// for team members whose panes are not found.
469    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            // Clear teams from state
477            let mut state = self.state.write();
478            state.teams.clear();
479            return;
480        }
481
482        // Collect agent pids for mapping (from already-detected agents)
483        let agent_pids: Vec<(String, u32)> =
484            agents.iter().map(|a| (a.target.clone(), a.pid)).collect();
485
486        // Also collect pids from all panes for broader matching
487        let all_pane_pids: Vec<(String, u32)> = all_panes
488            .iter()
489            .map(|p| (p.target.clone(), p.pid))
490            .collect();
491
492        // Pre-build cmdline cache to avoid duplicate lookups for overlapping pids
493        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        // Deduplicate (target, pid) pairs to avoid redundant iterations
503        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            // Scan tasks for this team
512            let tasks = teams::scan_tasks(&team_config.team_name).unwrap_or_default();
513
514            // Map members to panes using all pane pids (broader scope)
515            let member_panes = teams::map_members_to_panes(team_config, &all_pane_pids);
516
517            // Try cmdline-based matching: child process cmdline contains --agent-id
518            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; // All members matched
524                }
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; // Already matched
529                        }
530                        // Match --agent-id member_agent_id in cmdline
531                        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            // Merge mappings (cmdline-based takes priority over heuristic)
542            let mut final_mapping = member_panes;
543            for (name, target) in cmdline_mapping {
544                final_mapping.insert(name, target);
545            }
546
547            // Fallback: match unmapped leader by cwd (leader has no --agent-id flag)
548            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                        // Find an agent with matching cwd that isn't already mapped
554                        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            // Apply team info to matching agents and detect out-of-scope panes
567            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 already in list — apply team info
579                    agent.team_info = Some(team_info);
580                } else if let Some(pane) = all_panes.iter().find(|p| &p.target == pane_target) {
581                    // Pane found but not in agents list (out of scope) — add as new agent
582                    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            // Determine cwd for virtual agents from any matched teammate
600            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            // Create virtual agents for members without detected panes
607            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        // Update state with team snapshots
660        let mut state = self.state.write();
661        state.teams = snapshots;
662    }
663
664    /// Re-apply cached team info from stored snapshots on non-scan polls
665    ///
666    /// Since `poll_once()` creates fresh `MonitoredAgent` instances every poll,
667    /// team info would be lost on polls where `scan_and_apply_teams` doesn't run.
668    /// This method uses the persisted `TeamSnapshot` data to re-apply team info.
669    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            // Determine cwd for virtual agents from any matched teammate
697            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            // Re-create virtual agents for unmapped members
705            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    /// Check if a pane matches the current monitor scope (temporarily unused)
732    #[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    /// Emit audit events for state transitions with debounce
757    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        // AgentDisappeared: was in previous, not in current
770        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                // Clean up pending transition for disappeared agent
784                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                // AgentAppeared - no debounce needed
806                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                // Status same as committed - cancel any pending transition (oscillation)
831                self.pending_transitions.remove(&agent.target);
832                continue;
833            }
834
835            // Status differs from committed - check debounce
836            let threshold = debounce_threshold(committed_status, &current_status_name);
837
838            if threshold.is_zero() {
839                // Immediate commit (e.g., -> awaiting_approval)
840                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                    // Still in same pending transition - check if threshold elapsed
883                    if pending.first_seen.elapsed() >= threshold {
884                        // Commit the transition
885                        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                        // Still within debounce window - override agent status for UI stability
928                        if let Some(committed) = self.previous_statuses.get(&agent.target) {
929                            agent.status = committed.full_status.clone();
930                        }
931                    }
932                } else {
933                    // Different pending status - replace pending transition
934                    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                    // Override agent status for UI stability
943                    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                // No pending transition yet - start one
958                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                // Override agent status for UI stability
967                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    /// Apply spinner grace period to prevent Processing→Idle flicker.
986    ///
987    /// When a spinner disappears between tool calls, the detector briefly sees Idle
988    /// before the next tool starts. This method holds the agent in Processing for
989    /// up to 6 seconds after the last Processing detection.
990    ///
991    /// Approval and Error statuses always bypass the grace period.
992    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                // Update grace period timestamp
1003                self.grace_periods
1004                    .insert(target.to_string(), Instant::now());
1005                status
1006            }
1007            AgentStatus::AwaitingApproval { .. } | AgentStatus::Error { .. } => {
1008                // Always pass through immediately — these are high-priority states
1009                self.grace_periods.remove(target);
1010                status
1011            }
1012            AgentStatus::Idle | AgentStatus::Unknown => {
1013                // Check if grace period applies (Idle or low-confidence fallback)
1014                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                            // Within grace period — maintain Processing
1026                            return AgentStatus::Processing {
1027                                activity: String::new(),
1028                            };
1029                        } else {
1030                            // Grace period expired — allow transition
1031                            self.grace_periods.remove(target);
1032                        }
1033                    }
1034                }
1035                status
1036            }
1037            _ => status,
1038        }
1039    }
1040
1041    /// Fetch and apply git branch/dirty info for all agents
1042    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    /// Apply cached git info on non-refresh polls (no git commands executed)
1058    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    /// Cleanup the process cache
1074    pub fn cleanup_cache(&self) {
1075        self.process_cache.cleanup();
1076    }
1077}
1078
1079/// Helper to detect agent from pane info
1080#[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    // Remove OSC and CSI sequences for detection logic.
1115    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
1123/// Build `AgentTeamInfo` for a team member, finding their current in-progress task.
1124fn 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
1148/// Create a virtual `MonitoredAgent` for a team member whose pane was not found.
1149///
1150/// `cwd` is inherited from a matched teammate so virtual agents group correctly.
1151fn 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
1175/// Extract approval_type and approval_details from an AgentStatus for audit logging
1176fn 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
1202/// Get a short name for an AgentStatus variant
1203fn 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
1214/// Extract activity text from a tmux pane title
1215///
1216/// Strips Braille spinner characters (`⠂`, `⠐`) and mode icons, returning the
1217/// remaining text as activity.  Returns empty string when an idle indicator (`✳`)
1218/// is present or when no meaningful text remains.
1219fn extract_activity_from_title(title: &str) -> String {
1220    // Idle indicator means no processing activity
1221    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
1235/// Enrich IPC Processing status with screen-detected activity
1236///
1237/// When IPC reports Processing with empty activity, first try the screen-detected
1238/// activity, then fall back to extracting activity directly from the pane title.
1239fn 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            // 1. Try screen-detected activity
1247            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            // 2. Fall back to title-based extraction
1258            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
1269/// Convert WrapState from state file to AgentStatus
1270fn 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        // Braille spinner + text
1315        assert_eq!(extract_activity_from_title("⠐ Compacting"), "Compacting");
1316        assert_eq!(extract_activity_from_title("⠂ Levitating"), "Levitating");
1317        // Idle indicator → empty
1318        assert_eq!(extract_activity_from_title("✳"), "");
1319        assert_eq!(extract_activity_from_title("✳ idle text"), "");
1320        // Empty / whitespace
1321        assert_eq!(extract_activity_from_title(""), "");
1322        assert_eq!(extract_activity_from_title("   "), "");
1323        // Pure spinner chars
1324        assert_eq!(extract_activity_from_title("⠐"), "");
1325        // Mode icons stripped
1326        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        // Screen returns Idle (e.g., ✳ in title caused screen detector to return Idle)
1349        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}