use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::debug;
use crate::tmux::agent_detect::{self, Debounce};
use crate::tmux::agent_state::{AgentKind, AgentState};
use crate::tmux::process_info;
pub const TICK_INTERVAL: Duration = Duration::from_secs(1);
const FIELD_SEP: char = ':';
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScreenAgent {
pub kind: AgentKind,
pub state: AgentState,
}
struct Entry {
kind: AgentKind,
debounce: Debounce,
last_activity: String,
}
#[derive(Clone, Default)]
pub struct AgentWatcher {
inner: Arc<RwLock<HashMap<String, Entry>>>,
}
impl AgentWatcher {
pub async fn snapshot(&self) -> HashMap<String, ScreenAgent> {
self.inner
.read()
.await
.iter()
.map(|(name, e)| {
(name.clone(), ScreenAgent { kind: e.kind, state: e.debounce.published() })
})
.collect()
}
}
pub fn spawn(watcher: AgentWatcher) {
tokio::spawn(async move {
loop {
tick(&watcher).await;
tokio::time::sleep(TICK_INTERVAL).await;
}
});
}
struct PaneInfo {
session: String,
pane_pid: u32,
activity: String,
title: String,
}
async fn list_active_panes() -> Vec<PaneInfo> {
let format = format!(
"#{{session_name}}{s}#{{window_active}}{s}#{{pane_active}}{s}#{{pane_pid}}{s}#{{window_activity}}{s}#{{pane_title}}",
s = FIELD_SEP
);
let output = match Command::new("tmux").args(["list-panes", "-a", "-F", &format]).output().await
{
Ok(o) if o.status.success() => o,
_ => return vec![],
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(6, FIELD_SEP).collect();
if parts.len() < 6 || parts[1] != "1" || parts[2] != "1" {
return None;
}
Some(PaneInfo {
session: parts[0].to_string(),
pane_pid: parts[3].parse().ok()?,
activity: parts[4].to_string(),
title: parts[5].to_string(),
})
})
.collect()
}
fn identify_agent(pane_pid: u32) -> Option<AgentKind> {
if let Some(kind) =
process_info::foreground_pid(pane_pid).and_then(process_info::read_process_cmdline)
{
return Some(kind);
}
process_info::walk_process_tree(pane_pid)
}
async fn tick(watcher: &AgentWatcher) {
let panes = list_active_panes().await;
if panes.is_empty() && watcher.inner.read().await.is_empty() {
return;
}
let mut old: HashMap<String, Entry> = watcher
.inner
.read()
.await
.iter()
.map(|(name, e)| {
(
name.clone(),
Entry {
kind: e.kind,
debounce: e.debounce.clone(),
last_activity: e.last_activity.clone(),
},
)
})
.collect();
let mut new_map: HashMap<String, Entry> = HashMap::new();
for pane in panes {
let Some(kind) = identify_agent(pane.pane_pid) else { continue };
let prev = old.remove(&pane.session).filter(|e| e.kind == kind);
if let Some(prev_entry) = prev {
if prev_entry.last_activity == pane.activity
&& prev_entry.debounce.published() == AgentState::Idle
{
new_map.insert(pane.session, prev_entry);
continue;
}
old.insert(pane.session.clone(), prev_entry);
}
let prev = old.remove(&pane.session);
let screen = match crate::tmux::capture_screen(&pane.session).await {
Ok(s) => s,
Err(_) => continue, };
let detection = agent_detect::evaluate(kind, &screen, &pane.title);
let mut debounce = prev.map(|e| e.debounce).unwrap_or_default();
let published = debounce.advance(&detection);
debug!(
"agent_watch: {} kind={} rule={} raw={:?} published={}",
pane.session,
kind.as_str(),
detection.rule_id,
detection.state,
published.as_str()
);
new_map.insert(pane.session, Entry { kind, debounce, last_activity: pane.activity });
}
*watcher.inner.write().await = new_map;
}