use anyhow::Result;
use ignore::gitignore::Gitignore;
use notify::{RecursiveMode, Watcher};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::cmd::Cmd;
use crate::config::Config;
use crate::git::GitStatus;
use crate::multiplexer::{Multiplexer, create_backend, detect_backend};
use crate::state::StateStore;
use super::app::SidebarLayoutMode;
use super::snapshot::build_snapshot;
pub fn socket_path(instance_id: &str) -> PathBuf {
let safe_id = instance_id.replace(['/', '\\'], "-");
std::env::temp_dir().join(format!("workmux-sidebar-{}.sock", safe_id))
}
struct TmuxState {
window_statuses: HashMap<String, Option<String>>,
active_windows: HashSet<(String, String)>,
pane_window_ids: HashMap<String, String>,
active_pane_ids: HashSet<String>,
window_pane_counts: HashMap<String, usize>,
}
fn query_tmux_state() -> TmuxState {
let format = "#{pane_id}\t#{session_name}\t#{window_id}\t#{@workmux_pane_status}\t#{window_active}\t#{session_attached}\t#{pane_active}";
let output = Cmd::new("tmux")
.args(&["list-panes", "-a", "-F", format])
.run_and_capture_stdout()
.unwrap_or_default();
let mut window_statuses = HashMap::new();
let mut active_windows = HashSet::new();
let mut pane_window_ids = HashMap::new();
let mut active_pane_ids = HashSet::new();
let mut window_pane_counts: HashMap<String, usize> = HashMap::new();
for line in output.lines() {
let mut parts = line.split('\t');
let (
Some(pane_id),
Some(session),
Some(window_id),
Some(status),
Some(win_active),
Some(sess_attached),
Some(pane_active),
) = (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
)
else {
continue;
};
let win_active = win_active == "1";
let sess_attached = sess_attached == "1";
let pane_active = pane_active == "1";
let status_val = if status.is_empty() {
None
} else {
Some(status.to_string())
};
window_statuses.insert(pane_id.to_string(), status_val);
pane_window_ids.insert(pane_id.to_string(), window_id.to_string());
*window_pane_counts.entry(window_id.to_string()).or_default() += 1;
if win_active && sess_attached {
active_windows.insert((session.to_string(), window_id.to_string()));
}
if pane_active {
active_pane_ids.insert(pane_id.to_string());
}
}
TmuxState {
window_statuses,
active_windows,
pane_window_ids,
active_pane_ids,
window_pane_counts,
}
}
struct SocketServer {
clients: Arc<Mutex<Vec<UnixStream>>>,
cached_payload: Arc<Mutex<Vec<u8>>>,
}
impl SocketServer {
fn bind(path: &Path) -> std::io::Result<Self> {
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
listener.set_nonblocking(true)?;
let clients: Arc<Mutex<Vec<UnixStream>>> = Arc::new(Mutex::new(Vec::new()));
let clients_clone = clients.clone();
let cached_payload: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let cached_clone = cached_payload.clone();
thread::spawn(move || {
loop {
match listener.accept() {
Ok((mut stream, _)) => {
let _ = stream.set_nonblocking(false);
let _ = stream.set_write_timeout(Some(Duration::from_millis(100)));
let payload = cached_clone.lock().ok().map(|p| p.clone());
let cache_ok = match payload {
Some(ref p) if !p.is_empty() => stream.write_all(p).is_ok(),
_ => true,
};
if cache_ok {
let mut clients = clients_clone.lock().unwrap();
clients.push(stream);
tracing::debug!(clients = clients.len(), "sidebar client connected");
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
});
Ok(Self {
clients,
cached_payload,
})
}
fn broadcast(&self, snapshot: &super::snapshot::SidebarSnapshot) {
let data = serde_json::to_vec(snapshot).unwrap_or_default();
let len = (data.len() as u32).to_be_bytes();
if let Ok(mut cached) = self.cached_payload.lock() {
cached.clear();
cached.extend_from_slice(&len);
cached.extend_from_slice(&data);
}
let mut clients = std::mem::take(&mut *self.clients.lock().unwrap());
let before = clients.len();
clients
.retain_mut(|stream| stream.write_all(&len).is_ok() && stream.write_all(&data).is_ok());
let dropped = before - clients.len();
if dropped > 0 {
tracing::info!(
dropped,
remaining = clients.len(),
payload_bytes = data.len(),
"sidebar broadcast: clients disconnected"
);
}
self.clients.lock().unwrap().append(&mut clients);
}
fn client_count(&self) -> usize {
self.clients.lock().unwrap().len()
}
}
fn read_sidebar_layout_mode(config: &Config) -> Option<SidebarLayoutMode> {
if let Ok(output) = Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sidebar_layout"])
.run_and_capture_stdout()
{
match output.trim() {
"tiles" => return Some(SidebarLayoutMode::Tiles),
"compact" => return Some(SidebarLayoutMode::Compact),
_ => {}
}
}
if let Ok(store) = StateStore::new()
&& let Ok(settings) = store.load_settings()
{
match settings.sidebar_layout.as_deref() {
Some("tiles") => return Some(SidebarLayoutMode::Tiles),
Some("compact") => return Some(SidebarLayoutMode::Compact),
_ => {}
}
}
match config.sidebar.layout.as_deref() {
Some("tiles") => return Some(SidebarLayoutMode::Tiles),
Some("compact") => return Some(SidebarLayoutMode::Compact),
_ => {}
}
None
}
fn read_sleeping_panes() -> HashSet<String> {
Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sleeping_panes"])
.run_and_capture_stdout()
.ok()
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default()
}
type GitCache = Arc<Mutex<HashMap<PathBuf, GitStatus>>>;
fn resolve_git_dir(worktree_path: &Path) -> Option<PathBuf> {
let dot_git = worktree_path.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
let content = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = content.strip_prefix("gitdir: ")?.trim();
let path = PathBuf::from(gitdir);
if path.is_absolute() {
return Some(path);
}
Some(worktree_path.join(path))
} else {
None
}
}
fn resolve_common_git_dir(gitdir: &Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(gitdir.join("commondir")).ok()?;
let rel = content.trim();
let path = if Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
gitdir.join(rel)
};
path.canonicalize().ok().or(Some(path))
}
fn build_gitignore(worktree: &Path) -> Gitignore {
let mut builder = ignore::gitignore::GitignoreBuilder::new(worktree);
if let Some(err) = builder.add(worktree.join(".gitignore")) {
tracing::debug!(
"failed to parse .gitignore for {}: {}",
worktree.display(),
err
);
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
fn is_event_ignored(
event_path: &Path,
worktree: &Path,
gitignores: &HashMap<PathBuf, Gitignore>,
) -> bool {
let Ok(rel) = event_path.strip_prefix(worktree) else {
return false;
};
let rel_str = rel.to_string_lossy();
if rel_str.starts_with(".git/") || rel_str == ".git" {
return rel_str.starts_with(".git/objects/") || rel_str.starts_with(".git/logs/");
}
if let Some(gi) = gitignores.get(worktree) {
gi.matched_path_or_any_parents(event_path, false)
.is_ignore()
} else {
false
}
}
fn git_status_semantically_equal(a: &GitStatus, b: &GitStatus) -> bool {
a.ahead == b.ahead
&& a.behind == b.behind
&& a.has_conflict == b.has_conflict
&& a.is_dirty == b.is_dirty
&& a.lines_added == b.lines_added
&& a.lines_removed == b.lines_removed
&& a.uncommitted_added == b.uncommitted_added
&& a.uncommitted_removed == b.uncommitted_removed
&& a.base_branch == b.base_branch
&& a.branch == b.branch
&& a.has_upstream == b.has_upstream
}
fn find_worktrees_for_path(
event_path: &Path,
watch_to_worktrees: &HashMap<PathBuf, HashSet<PathBuf>>,
) -> Vec<PathBuf> {
let mut result = Vec::new();
for (watched_dir, worktrees) in watch_to_worktrees {
if event_path.starts_with(watched_dir) {
result.extend(worktrees.iter().cloned());
}
}
result
}
fn add_watch(
watcher: &mut notify::RecommendedWatcher,
path: &Path,
mode: RecursiveMode,
worktree: &Path,
watch_to_worktrees: &mut HashMap<PathBuf, HashSet<PathBuf>>,
watched_for_worktree: &mut Vec<PathBuf>,
) {
let already_watching = watch_to_worktrees.get(path).is_some_and(|s| !s.is_empty());
if !already_watching && let Err(e) = watcher.watch(path, mode) {
tracing::warn!("failed to watch {}: {}", path.display(), e);
return;
}
watch_to_worktrees
.entry(path.to_path_buf())
.or_default()
.insert(worktree.to_path_buf());
watched_for_worktree.push(path.to_path_buf());
}
fn remove_worktree_watch(
watcher: &mut notify::RecommendedWatcher,
watch_path: &Path,
worktree: &Path,
watch_to_worktrees: &mut HashMap<PathBuf, HashSet<PathBuf>>,
) {
if let Some(worktrees) = watch_to_worktrees.get_mut(watch_path) {
worktrees.remove(worktree);
if worktrees.is_empty() {
watch_to_worktrees.remove(watch_path);
let _ = watcher.unwatch(watch_path);
}
}
}
fn platform_supports_worktree_watches() -> bool {
cfg!(target_os = "macos")
}
fn setup_worktree_watches(
watcher: &mut notify::RecommendedWatcher,
worktree: &Path,
watch_to_worktrees: &mut HashMap<PathBuf, HashSet<PathBuf>>,
) -> Vec<PathBuf> {
let mut watched = Vec::new();
let dot_git = worktree.join(".git");
let is_linked = dot_git.is_file();
if is_linked {
if let Some(git_dir) = resolve_git_dir(worktree) {
add_watch(
watcher,
&git_dir,
RecursiveMode::NonRecursive,
worktree,
watch_to_worktrees,
&mut watched,
);
if let Some(common_dir) = resolve_common_git_dir(&git_dir) {
let refs_dir = common_dir.join("refs");
if refs_dir.is_dir() {
add_watch(
watcher,
&refs_dir,
RecursiveMode::Recursive,
worktree,
watch_to_worktrees,
&mut watched,
);
}
add_watch(
watcher,
&common_dir,
RecursiveMode::NonRecursive,
worktree,
watch_to_worktrees,
&mut watched,
);
}
}
} else if dot_git.is_dir() {
add_watch(
watcher,
&dot_git,
RecursiveMode::NonRecursive,
worktree,
watch_to_worktrees,
&mut watched,
);
let refs_dir = dot_git.join("refs");
if refs_dir.is_dir() {
add_watch(
watcher,
&refs_dir,
RecursiveMode::Recursive,
worktree,
watch_to_worktrees,
&mut watched,
);
}
}
if platform_supports_worktree_watches() {
add_watch(
watcher,
worktree,
RecursiveMode::Recursive,
worktree,
watch_to_worktrees,
&mut watched,
);
}
watched
}
fn next_worker_timeout(
pending: &HashMap<PathBuf, Instant>,
debounce: Duration,
last_sweep: Instant,
sweep_interval: Duration,
) -> Duration {
let now = Instant::now();
let sweep_wait = sweep_interval.saturating_sub(last_sweep.elapsed());
let mut min_wait = sweep_wait;
for last_event in pending.values() {
let ready_at = *last_event + debounce;
if ready_at <= now {
return Duration::from_millis(1);
}
let wait = ready_at - now;
if wait < min_wait {
min_wait = wait;
}
}
min_wait.min(Duration::from_secs(1))
}
fn refresh_git_status(path: &Path, cache: &GitCache) -> bool {
let new_status = crate::git::get_git_status(path, None);
let changed = cache
.lock()
.ok()
.map(|c| {
c.get(path)
.is_none_or(|old| !git_status_semantically_equal(old, &new_status))
})
.unwrap_or(true);
if let Ok(mut c) = cache.lock() {
c.insert(path.to_path_buf(), new_status);
}
changed
}
struct GitWorkerPath {
path: PathBuf,
is_stale: bool,
}
fn spawn_git_worker(
term: Arc<AtomicBool>,
dirty_flag: Arc<AtomicBool>,
wake_tx: std::sync::mpsc::SyncSender<()>,
) -> (GitCache, std::sync::mpsc::Sender<Vec<GitWorkerPath>>) {
let cache: GitCache = Arc::new(Mutex::new(HashMap::new()));
let cache_clone = cache.clone();
let (tx, rx) = std::sync::mpsc::channel::<Vec<GitWorkerPath>>();
thread::spawn(move || {
let (fs_tx, fs_rx) = std::sync::mpsc::sync_channel(256);
let fs_overflow = Arc::new(AtomicBool::new(false));
let fs_overflow_clone = fs_overflow.clone();
let mut watcher: Option<notify::RecommendedWatcher> = match notify::RecommendedWatcher::new(
move |event: notify::Result<notify::Event>| {
if let Ok(ref e) = event {
let dominated_by_noise = e.paths.iter().all(|p| {
let s = p.to_string_lossy();
s.contains("/.git/objects/") || s.contains("/.git/logs/")
});
if dominated_by_noise {
return;
}
}
if let Err(std::sync::mpsc::TrySendError::Full(_)) = fs_tx.try_send(event) {
fs_overflow_clone.store(true, Ordering::Relaxed);
}
},
notify::Config::default(),
) {
Ok(w) => Some(w),
Err(e) => {
tracing::warn!(
"filesystem watcher unavailable, falling back to polling: {}",
e
);
None
}
};
let mut active_entries: Vec<GitWorkerPath> = Vec::new();
let mut watch_to_worktrees: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
let mut worktree_watches: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut gitignores: HashMap<PathBuf, Gitignore> = HashMap::new();
let mut pending_worktrees: HashMap<PathBuf, Instant> = HashMap::new();
let mut path_stale: HashMap<PathBuf, bool> = HashMap::new();
let mut unique_active: Vec<PathBuf> = Vec::new();
let mut last_full_sweep = Instant::now();
let full_sweep_interval = if watcher.is_none() {
Duration::from_secs(2)
} else if platform_supports_worktree_watches() {
Duration::from_secs(30)
} else {
Duration::from_secs(5)
};
let debounce_duration = Duration::from_millis(300);
while !term.load(Ordering::Relaxed) {
if watcher.is_some() {
let timeout = next_worker_timeout(
&pending_worktrees,
debounce_duration,
last_full_sweep,
full_sweep_interval,
);
let mut process_event = |event: notify::Event| {
for path in &event.paths {
if path.file_name().is_some_and(|n| n == ".gitignore") {
for wt in find_worktrees_for_path(path, &watch_to_worktrees) {
let gi = build_gitignore(&wt);
gitignores.insert(wt, gi);
}
}
for wt in find_worktrees_for_path(path, &watch_to_worktrees) {
if is_event_ignored(path, &wt, &gitignores) {
continue;
}
pending_worktrees.entry(wt).or_insert_with(Instant::now);
}
}
};
match fs_rx.recv_timeout(timeout) {
Ok(Ok(event)) => process_event(event),
Ok(Err(e)) => {
tracing::warn!("filesystem watch error: {}", e);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
while let Ok(event_result) = fs_rx.try_recv() {
if let Ok(event) = event_result {
process_event(event);
}
}
if fs_overflow.swap(false, Ordering::Relaxed) {
let now = Instant::now();
for path in worktree_watches.keys() {
pending_worktrees.entry(path.clone()).or_insert(now);
}
}
} else {
let sleep = full_sweep_interval
.saturating_sub(last_full_sweep.elapsed())
.min(Duration::from_secs(1));
thread::sleep(sleep);
}
let mut paths_changed = false;
while let Ok(entries) = rx.try_recv() {
active_entries = entries;
paths_changed = true;
}
if paths_changed {
path_stale.clear();
for entry in &active_entries {
let e = path_stale.entry(entry.path.clone()).or_insert(true);
if !entry.is_stale {
*e = false;
}
}
unique_active = path_stale.keys().cloned().collect();
unique_active.sort();
let unique_set: HashSet<PathBuf> = unique_active.iter().cloned().collect();
if let Some(ref mut w) = watcher {
let removed: Vec<PathBuf> = worktree_watches
.keys()
.filter(|p| !unique_set.contains(*p))
.cloned()
.collect();
for path in &removed {
if let Some(watched_paths) = worktree_watches.remove(path) {
for wp in &watched_paths {
remove_worktree_watch(w, wp, path, &mut watch_to_worktrees);
}
}
gitignores.remove(path);
pending_worktrees.remove(path);
}
for path in &unique_active {
if worktree_watches.contains_key(path) {
continue;
}
let watched = setup_worktree_watches(w, path, &mut watch_to_worktrees);
worktree_watches.insert(path.clone(), watched);
gitignores.insert(path.clone(), build_gitignore(path));
pending_worktrees.insert(path.clone(), Instant::now() - debounce_duration);
}
if !removed.is_empty() {
if let Ok(mut c) = cache_clone.lock() {
c.retain(|p, _| unique_set.contains(p));
}
dirty_flag.store(true, Ordering::Relaxed);
let _ = wake_tx.try_send(());
}
} else {
if let Ok(mut c) = cache_clone.lock() {
let before = c.len();
c.retain(|p, _| unique_set.contains(p));
if c.len() != before {
dirty_flag.store(true, Ordering::Relaxed);
let _ = wake_tx.try_send(());
}
}
for path in &unique_active {
if !cache_clone
.lock()
.ok()
.is_some_and(|c| c.contains_key(path))
{
pending_worktrees
.insert(path.clone(), Instant::now() - debounce_duration);
}
}
}
}
let now = Instant::now();
let ready: Vec<PathBuf> = pending_worktrees
.iter()
.filter(|(_, last_event)| now.duration_since(**last_event) >= debounce_duration)
.map(|(path, _)| path.clone())
.collect();
let mut any_changed = false;
for path in &ready {
pending_worktrees.remove(path);
let is_stale = path_stale.get(path).copied().unwrap_or(false);
if is_stale {
continue;
}
if refresh_git_status(path, &cache_clone) {
any_changed = true;
}
}
if last_full_sweep.elapsed() >= full_sweep_interval {
last_full_sweep = Instant::now();
let sweep_paths: Vec<PathBuf> = if watcher.is_some() {
worktree_watches.keys().cloned().collect()
} else {
unique_active.clone()
};
for path in &sweep_paths {
pending_worktrees.remove(path);
if refresh_git_status(path, &cache_clone) {
any_changed = true;
}
}
}
if any_changed {
dirty_flag.store(true, Ordering::Relaxed);
let _ = wake_tx.try_send(());
}
}
});
(cache, tx)
}
struct InactivityTracker {
entries: HashMap<String, (u64, Instant, u64)>,
confirmed: HashMap<String, u64>,
timeout: Duration,
}
impl InactivityTracker {
fn new(timeout: Duration) -> Self {
Self {
entries: HashMap::new(),
confirmed: HashMap::new(),
timeout,
}
}
fn is_confirmed(&self, pane_id: &str, updated_ts: u64) -> bool {
self.confirmed
.get(pane_id)
.is_some_and(|&ts| updated_ts <= ts)
}
fn check_with(
&mut self,
agents: &[crate::multiplexer::AgentPane],
now: Instant,
capture: impl Fn(&str) -> Option<String>,
) -> HashSet<String> {
use std::hash::{Hash, Hasher};
let working: HashMap<&str, &crate::multiplexer::AgentPane> = agents
.iter()
.filter(|a| a.status == Some(crate::multiplexer::AgentStatus::Working))
.map(|a| (a.pane_id.as_str(), a))
.collect();
self.entries
.retain(|id, _| working.contains_key(id.as_str()));
self.confirmed
.retain(|id, _| working.contains_key(id.as_str()));
let resumed: Vec<String> = self
.confirmed
.iter()
.filter(|(id, confirmed_ts)| {
working
.get(id.as_str())
.is_some_and(|a| a.updated_ts.unwrap_or(0) > **confirmed_ts)
})
.map(|(id, _)| id.clone())
.collect();
for id in &resumed {
self.confirmed.remove(id);
self.entries.remove(id);
}
for (pane_id, agent) in &working {
if self.confirmed.contains_key(*pane_id) {
continue;
}
let Some(raw) = capture(pane_id) else {
continue;
};
let stripped = console::strip_ansi_codes(&raw);
let normalized = stripped.trim();
let mut hasher = std::hash::DefaultHasher::new();
normalized.hash(&mut hasher);
let hash = hasher.finish();
let current_rpc = agent.updated_ts.unwrap_or(0);
match self.entries.get(*pane_id) {
Some(&(prev_hash, first_seen, prev_rpc))
if prev_hash == hash && prev_rpc == current_rpc =>
{
if now.duration_since(first_seen) >= self.timeout {
self.confirmed.insert(pane_id.to_string(), current_rpc);
}
}
_ => {
self.entries
.insert(pane_id.to_string(), (hash, now, current_rpc));
}
}
}
self.confirmed.keys().cloned().collect()
}
}
pub fn run() -> Result<()> {
let mux = create_backend(detect_backend());
let instance_id = mux.instance_id();
let config = Config::load(None)?;
let status_icons = config.status_icons.clone();
tracing::info!(instance_id = %instance_id, "sidebar daemon starting");
let term = Arc::new(AtomicBool::new(false));
let dirty_flag = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGTERM, term.clone())?;
signal_hook::flag::register(signal_hook::consts::SIGUSR1, dirty_flag.clone())?;
let (wake_tx, wake_rx) = std::sync::mpsc::sync_channel::<()>(1);
let _wake_tx_keepalive = wake_tx.clone();
let sock_path = socket_path(&instance_id);
let _ = std::fs::remove_file(&sock_path); let server = SocketServer::bind(&sock_path)?;
let (git_cache, git_path_tx) = spawn_git_worker(term.clone(), dirty_flag.clone(), wake_tx);
Cmd::new("tmux")
.args(&[
"set-option",
"-g",
"@workmux_sidebar_daemon_pid",
&std::process::id().to_string(),
])
.run()?;
let mut inactivity_tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last_interrupted: HashSet<String> = HashSet::new();
let mut last_runtime_write = Instant::now();
let backend_name = mux.name().to_string();
let mut last_refresh = Instant::now();
let mut last_client_seen = Instant::now();
let mut dirty_pending = false;
let mut last_agent_list = String::new();
let mut last_health_log = Instant::now();
let refresh_interval = Duration::from_secs(2);
let debounce_interval = Duration::from_millis(50);
while !term.load(Ordering::Relaxed) {
if dirty_flag.swap(false, Ordering::Relaxed) {
dirty_pending = true;
}
let time_since_refresh = last_refresh.elapsed();
let debounce_cleared = dirty_pending && time_since_refresh >= debounce_interval;
let timer_expired = time_since_refresh >= refresh_interval;
if debounce_cleared || timer_expired {
dirty_pending = false;
last_refresh = Instant::now();
let tmux_state = query_tmux_state();
let agents = StateStore::new()
.and_then(|store| store.load_reconciled_agents(mux.as_ref()))
.ok();
let Some(agents) = agents else { continue };
let layout_mode = read_sidebar_layout_mode(&config).unwrap_or_default();
let sleeping_pane_ids = read_sleeping_panes();
let git_statuses = git_cache.lock().ok().map(|c| c.clone()).unwrap_or_default();
let captured_panes = gather_captures(&agents, mux.as_ref(), &inactivity_tracker);
let now = Instant::now();
let now_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let heartbeat_due = last_runtime_write.elapsed() >= Duration::from_secs(10);
let output = compute_tick(
TickInput {
agents,
tmux_state,
captured_panes,
now,
now_ts,
layout_mode,
git_statuses,
sleeping_pane_ids,
},
&mut inactivity_tracker,
&last_interrupted,
&status_icons,
heartbeat_due,
);
if let Ok(store) = StateStore::new()
&& apply_tick_effects(&output, &store, &backend_name, &instance_id)
{
last_runtime_write = Instant::now();
}
last_interrupted = output.next_interrupted;
server.broadcast(&output.snapshot);
let stale_threshold = 60 * 60; let entries: Vec<GitWorkerPath> = output
.snapshot
.agents
.iter()
.map(|a| GitWorkerPath {
path: a.path.clone(),
is_stale: a
.status_ts
.map(|ts| now_ts.saturating_sub(ts) > stale_threshold)
.unwrap_or(false),
})
.collect();
let _ = git_path_tx.send(entries);
let agent_list: String = output
.snapshot
.agents
.iter()
.map(|a| a.pane_id.as_str())
.collect::<Vec<_>>()
.join(" ");
if agent_list != last_agent_list {
if !agent_list.is_empty() {
let _ = Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_agents", &agent_list])
.run();
} else {
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sidebar_agents"])
.run();
}
last_agent_list = agent_list;
}
}
let cc = server.client_count();
if cc > 0 {
last_client_seen = Instant::now();
} else if last_client_seen.elapsed() > Duration::from_secs(10) {
tracing::info!("sidebar daemon exiting: no clients for 10s");
break;
}
if last_health_log.elapsed() >= Duration::from_secs(60) {
tracing::info!(clients = cc, "sidebar daemon alive");
last_health_log = Instant::now();
}
let wait = if dirty_pending {
debounce_interval.saturating_sub(last_refresh.elapsed())
} else {
refresh_interval
.saturating_sub(last_refresh.elapsed())
.min(Duration::from_millis(100))
};
let _ = wake_rx.recv_timeout(wait);
}
if term.load(Ordering::Relaxed) {
tracing::info!("sidebar daemon exiting: SIGTERM received");
}
let _ = std::fs::remove_file(&sock_path);
if let Ok(store) = StateStore::new() {
store.delete_runtime(&backend_name, &instance_id);
}
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sidebar_daemon_pid"])
.run();
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sidebar_agents"])
.run();
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sleeping_panes"])
.run();
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sidebar_scope"])
.run();
Ok(())
}
struct TickInput {
agents: Vec<crate::multiplexer::AgentPane>,
tmux_state: TmuxState,
captured_panes: HashMap<String, String>,
now: Instant,
now_ts: u64,
layout_mode: SidebarLayoutMode,
git_statuses: HashMap<PathBuf, GitStatus>,
sleeping_pane_ids: HashSet<String>,
}
struct AgentWrite {
pane_id: String,
status_ts: u64,
}
struct TickOutput {
snapshot: super::snapshot::SidebarSnapshot,
agent_writes: Vec<AgentWrite>,
runtime_write: Option<crate::state::RuntimeState>,
next_interrupted: HashSet<String>,
}
#[allow(clippy::too_many_arguments)]
fn compute_tick(
input: TickInput,
tracker: &mut InactivityTracker,
last_interrupted: &HashSet<String>,
status_icons: &crate::config::StatusIcons,
heartbeat_due: bool,
) -> TickOutput {
let TickInput {
mut agents,
tmux_state,
captured_panes,
now,
now_ts,
layout_mode,
git_statuses,
sleeping_pane_ids,
} = input;
let interrupted =
tracker.check_with(&agents, now, |pane_id| captured_panes.get(pane_id).cloned());
let mut agent_writes = Vec::new();
if !last_interrupted.is_empty() {
for agent in &mut agents {
if last_interrupted.contains(&agent.pane_id) && !interrupted.contains(&agent.pane_id) {
agent.status_ts = Some(now_ts);
agent_writes.push(AgentWrite {
pane_id: agent.pane_id.clone(),
status_ts: now_ts,
});
}
}
}
let mut snapshot = build_snapshot(
agents,
&tmux_state.window_statuses,
&tmux_state.pane_window_ids,
tmux_state.active_windows,
tmux_state.active_pane_ids,
tmux_state.window_pane_counts,
layout_mode,
status_icons,
git_statuses,
&sleeping_pane_ids,
);
snapshot.interrupted_pane_ids = interrupted.clone();
let runtime_write = if interrupted != *last_interrupted || heartbeat_due {
Some(crate::state::RuntimeState {
interrupted_pane_ids: interrupted.clone(),
updated_ts: now_ts,
})
} else {
None
};
TickOutput {
snapshot,
agent_writes,
runtime_write,
next_interrupted: interrupted,
}
}
fn apply_tick_effects(
output: &TickOutput,
store: &StateStore,
backend: &str,
instance: &str,
) -> bool {
for write in &output.agent_writes {
let pane_key = crate::state::PaneKey {
backend: backend.to_string(),
instance: instance.to_string(),
pane_id: write.pane_id.clone(),
};
if let Ok(Some(mut state)) = store.get_agent(&pane_key) {
state.status_ts = Some(write.status_ts);
let _ = store.upsert_agent(&state);
}
}
if let Some(ref runtime) = output.runtime_write {
let _ = store.write_runtime(backend, instance, runtime);
true
} else {
false
}
}
fn gather_captures(
agents: &[crate::multiplexer::AgentPane],
mux: &dyn Multiplexer,
tracker: &InactivityTracker,
) -> HashMap<String, String> {
agents
.iter()
.filter(|a| a.status == Some(crate::multiplexer::AgentStatus::Working))
.filter(|a| !tracker.is_confirmed(&a.pane_id, a.updated_ts.unwrap_or(0)))
.filter_map(|a| {
mux.capture_pane(&a.pane_id, 5)
.map(|content| (a.pane_id.clone(), content))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::multiplexer::{AgentPane, AgentStatus};
use std::cell::RefCell;
use std::path::PathBuf;
fn working_agent(pane_id: &str, updated_ts: u64) -> AgentPane {
AgentPane {
session: String::new(),
window_name: String::new(),
pane_id: pane_id.to_string(),
window_id: String::new(),
path: PathBuf::new(),
pane_title: None,
status: Some(AgentStatus::Working),
status_ts: Some(100),
updated_ts: Some(updated_ts),
}
}
fn done_agent(pane_id: &str) -> AgentPane {
AgentPane {
status: Some(AgentStatus::Done),
..working_agent(pane_id, 1)
}
}
#[test]
fn no_interruption_before_timeout() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
let result = tracker.check_with(&agents, t0, |_| Some("hello".into()));
assert!(result.is_empty());
let result = tracker.check_with(&agents, t0 + Duration::from_secs(5), |_| {
Some("hello".into())
});
assert!(result.is_empty());
}
#[test]
fn interruption_after_timeout() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
assert!(result.contains("%1"));
}
#[test]
fn changing_content_resets_window() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
tracker.check_with(&agents, t0 + Duration::from_secs(8), |_| {
Some("world".into())
});
let result = tracker.check_with(&agents, t0 + Duration::from_secs(13), |_| {
Some("world".into())
});
assert!(result.is_empty());
let result = tracker.check_with(&agents, t0 + Duration::from_secs(19), |_| {
Some("world".into())
});
assert!(result.contains("%1"));
}
#[test]
fn sticky_despite_content_change() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
assert!(result.contains("%1"));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(12), |_| {
Some("user typed something".into())
});
assert!(result.contains("%1"));
}
#[test]
fn clears_on_updated_ts_change() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
let resumed_agents = vec![working_agent("%1", 2)];
let result = tracker.check_with(&resumed_agents, t0 + Duration::from_secs(12), |_| {
Some("hello".into())
});
assert!(result.is_empty());
}
#[test]
fn fresh_window_after_resume() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
let resumed = vec![working_agent("%1", 2)];
tracker.check_with(&resumed, t0 + Duration::from_secs(12), |_| {
Some("hello".into())
});
let result = tracker.check_with(&resumed, t0 + Duration::from_secs(17), |_| {
Some("hello".into())
});
assert!(result.is_empty());
let result = tracker.check_with(&resumed, t0 + Duration::from_secs(23), |_| {
Some("hello".into())
});
assert!(result.contains("%1"));
}
#[test]
fn non_working_agents_ignored() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![done_agent("%1")];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
assert!(result.is_empty());
}
#[test]
fn leaves_working_clears_tracking() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let working = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&working, t0, |_| Some("hello".into()));
tracker.check_with(&working, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
let done = vec![done_agent("%1")];
let result = tracker.check_with(&done, t0 + Duration::from_secs(12), |_| {
Some("hello".into())
});
assert!(result.is_empty());
let working_again = vec![working_agent("%1", 3)];
let result = tracker.check_with(&working_again, t0 + Duration::from_secs(13), |_| {
Some("hello".into())
});
assert!(result.is_empty()); }
#[test]
fn capture_failure_skips_pane() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| None);
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| None);
assert!(result.is_empty());
}
#[test]
fn multiple_agents_tracked_independently() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1), working_agent("%2", 1)];
let t0 = Instant::now();
let content = RefCell::new(HashMap::from([
("%1".to_string(), "static".to_string()),
("%2".to_string(), "changing".to_string()),
]));
tracker.check_with(&agents, t0, |id| content.borrow().get(id).cloned());
content
.borrow_mut()
.insert("%2".to_string(), "new output".into());
tracker.check_with(&agents, t0 + Duration::from_secs(5), |id| {
content.borrow().get(id).cloned()
});
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |id| {
content.borrow().get(id).cloned()
});
assert_eq!(result, HashSet::from(["%1".to_string()]));
}
#[test]
fn rpc_update_before_timeout_resets_window() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let t0 = Instant::now();
tracker.check_with(&[working_agent("%1", 1)], t0, |_| Some("hello".into()));
tracker.check_with(
&[working_agent("%1", 2)],
t0 + Duration::from_secs(5),
|_| Some("hello".into()),
);
let result = tracker.check_with(
&[working_agent("%1", 2)],
t0 + Duration::from_secs(11),
|_| Some("hello".into()),
);
assert!(result.is_empty());
let result = tracker.check_with(
&[working_agent("%1", 2)],
t0 + Duration::from_secs(16),
|_| Some("hello".into()),
);
assert_eq!(result, HashSet::from(["%1".to_string()]));
}
#[test]
fn interruption_at_exact_timeout() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello".into()));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(10), |_| {
Some("hello".into())
});
assert_eq!(result, HashSet::from(["%1".to_string()]));
}
#[test]
fn ansi_and_whitespace_normalized() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| Some("hello\n".into()));
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("\x1b[31mhello\x1b[0m ".into())
});
assert_eq!(result, HashSet::from(["%1".to_string()]));
}
#[test]
fn capture_failure_does_not_create_baseline() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let agents = vec![working_agent("%1", 1)];
let t0 = Instant::now();
tracker.check_with(&agents, t0, |_| None);
let result = tracker.check_with(&agents, t0 + Duration::from_secs(11), |_| {
Some("hello".into())
});
assert!(result.is_empty());
}
mod tick {
use super::*;
use crate::config::StatusIcons;
use crate::multiplexer::AgentStatus;
use crate::state::{PaneKey, StateStore};
const BACKEND: &str = "tmux";
const INSTANCE: &str = "test";
fn test_store() -> (StateStore, tempfile::TempDir) {
let dir = tempfile::TempDir::new().unwrap();
let store = StateStore::with_path(dir.path().to_path_buf()).unwrap();
(store, dir)
}
fn pane_key(pane_id: &str) -> PaneKey {
PaneKey {
backend: BACKEND.to_string(),
instance: INSTANCE.to_string(),
pane_id: pane_id.to_string(),
}
}
fn seed_agent(store: &StateStore, pane_id: &str, status_ts: u64, updated_ts: u64) {
let state = crate::state::AgentState {
pane_key: pane_key(pane_id),
workdir: PathBuf::from("/tmp"),
status: Some(AgentStatus::Working),
status_ts: Some(status_ts),
pane_title: None,
pane_pid: 1,
command: "node".to_string(),
updated_ts,
window_name: None,
session_name: None,
boot_id: None,
};
store.upsert_agent(&state).unwrap();
}
fn do_tick(
tracker: &mut InactivityTracker,
last: &mut HashSet<String>,
agents: Vec<crate::multiplexer::AgentPane>,
captures: HashMap<String, String>,
now: Instant,
now_ts: u64,
) -> TickOutput {
let output = compute_tick(
TickInput {
agents,
tmux_state: TmuxState {
window_statuses: HashMap::new(),
active_windows: HashSet::new(),
pane_window_ids: HashMap::new(),
active_pane_ids: HashSet::new(),
window_pane_counts: HashMap::new(),
},
captured_panes: captures,
now,
now_ts,
layout_mode: SidebarLayoutMode::default(),
git_statuses: HashMap::new(),
sleeping_pane_ids: HashSet::new(),
},
tracker,
last,
&StatusIcons::default(),
false,
);
*last = output.next_interrupted.clone();
output
}
fn cap(content: &str) -> HashMap<String, String> {
HashMap::from([("%1".to_string(), content.to_string())])
}
fn cap2(content: &str) -> HashMap<String, String> {
HashMap::from([
("%1".to_string(), content.to_string()),
("%2".to_string(), content.to_string()),
])
}
#[test]
fn resumed_agent_gets_status_ts_reset() {
let (store, _dir) = test_store();
seed_agent(&store, "%1", 100, 1);
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last = HashSet::new();
let t0 = Instant::now();
do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0,
1000,
);
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0 + Duration::from_secs(11),
1011,
);
assert!(output.snapshot.interrupted_pane_ids.contains("%1"));
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 2)],
cap("hello"),
t0 + Duration::from_secs(12),
1012,
);
assert!(output.snapshot.interrupted_pane_ids.is_empty());
let agent = output
.snapshot
.agents
.iter()
.find(|a| a.pane_id == "%1")
.unwrap();
assert_eq!(agent.status_ts, Some(1012));
assert_eq!(output.agent_writes.len(), 1);
assert_eq!(output.agent_writes[0].status_ts, 1012);
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
assert_eq!(
store.get_agent(&pane_key("%1")).unwrap().unwrap().status_ts,
Some(1012)
);
}
#[test]
fn only_resumed_agent_gets_reset() {
let (store, _dir) = test_store();
seed_agent(&store, "%1", 100, 1);
seed_agent(&store, "%2", 200, 1);
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last = HashSet::new();
let t0 = Instant::now();
let agents = vec![working_agent("%1", 1), working_agent("%2", 1)];
do_tick(
&mut tracker,
&mut last,
agents.clone(),
cap2("hello"),
t0,
1000,
);
do_tick(
&mut tracker,
&mut last,
agents,
cap2("hello"),
t0 + Duration::from_secs(11),
1011,
);
let mixed = vec![working_agent("%1", 2), working_agent("%2", 1)];
let output = do_tick(
&mut tracker,
&mut last,
mixed,
cap2("hello"),
t0 + Duration::from_secs(12),
1012,
);
assert_eq!(output.agent_writes.len(), 1);
assert_eq!(output.agent_writes[0].pane_id, "%1");
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
assert_eq!(
store.get_agent(&pane_key("%1")).unwrap().unwrap().status_ts,
Some(1012)
);
assert_eq!(
store.get_agent(&pane_key("%2")).unwrap().unwrap().status_ts,
Some(200)
);
}
#[test]
fn runtime_file_reflects_interrupted_set() {
let (store, _dir) = test_store();
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last = HashSet::new();
let t0 = Instant::now();
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0,
1000,
);
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0 + Duration::from_secs(11),
1011,
);
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
assert!(
store
.read_runtime(BACKEND, INSTANCE)
.interrupted_pane_ids
.contains("%1")
);
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 2)],
cap("hello"),
t0 + Duration::from_secs(12),
1012,
);
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
assert!(
store
.read_runtime(BACKEND, INSTANCE)
.interrupted_pane_ids
.is_empty()
);
}
#[test]
fn missing_agent_file_does_not_panic() {
let (store, _dir) = test_store();
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last = HashSet::new();
let t0 = Instant::now();
do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0,
1000,
);
do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0 + Duration::from_secs(11),
1011,
);
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 2)],
cap("hello"),
t0 + Duration::from_secs(12),
1012,
);
assert!(output.snapshot.interrupted_pane_ids.is_empty());
apply_tick_effects(&output, &store, BACKEND, INSTANCE);
}
#[test]
fn snapshot_has_correct_status_ts_on_resume_tick() {
let mut tracker = InactivityTracker::new(Duration::from_secs(10));
let mut last = HashSet::new();
let t0 = Instant::now();
do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0,
1000,
);
do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 1)],
cap("hello"),
t0 + Duration::from_secs(11),
1011,
);
let output = do_tick(
&mut tracker,
&mut last,
vec![working_agent("%1", 2)],
cap("hello"),
t0 + Duration::from_secs(12),
1012,
);
let agent = output
.snapshot
.agents
.iter()
.find(|a| a.pane_id == "%1")
.unwrap();
assert_eq!(agent.status_ts, Some(1012));
assert!(!output.snapshot.interrupted_pane_ids.contains("%1"));
}
}
}