1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
|
//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's
//! transition policy → notifications and `@agent_state` write-back.
use std::collections::HashMap;
use detect_core::{detect_agent_with_osc, AgentState};
use tracing::{debug, info, warn};
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
pub struct Watcher {
/// Last published state per pane id. `status` reads the same values back
/// from tmux's `@agent_state`, so no other state is needed.
states: HashMap<String, AgentState>,
interval_secs: u64,
sounds: bool,
}
/// herdr's notification policy (`notification_toast_for_state_change`).
pub fn notification_kind_for_transition(
prev_state: AgentState,
new_state: AgentState,
) -> Option<NotificationKind> {
if new_state == prev_state {
return None;
}
match new_state {
AgentState::Blocked => Some(NotificationKind::NeedsAttention),
AgentState::Idle
if matches!(prev_state, AgentState::Working | AgentState::Blocked) =>
{
Some(NotificationKind::Finished)
}
_ => None,
}
}
impl Watcher {
pub fn new(interval_secs: u64, sounds: bool) -> Self {
Self {
states: HashMap::new(),
interval_secs: interval_secs.max(1),
sounds,
}
}
pub fn run(&mut self) -> Result<(), String> {
info!(interval = self.interval_secs, "watching tmux panes");
// dingger is a client of the tmux server, which may not exist yet —
// it starts when the first terminal opens one. Wait for it, then
// keep watching across server restarts.
let mut had_server = false;
loop {
match self.tick() {
Ok(()) => {
if !had_server {
info!("tmux server available");
had_server = true;
}
}
Err(err) => {
if had_server {
warn!(%err, "tmux server unavailable; waiting for it to return");
}
had_server = false;
}
}
std::thread::sleep(std::time::Duration::from_secs(self.interval_secs));
}
}
fn tick(&mut self) -> Result<(), String> {
let panes = tmux::list_panes()?;
// Forget panes that went away.
self.states
.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
for pane in panes {
let prev_state = self
.states
.get(&pane.pane_id)
.copied()
.unwrap_or(AgentState::Unknown);
// The detection result is an observation of live agent state.
// Viewer screens (transcripts, pickers) return
// `skip_state_update: true` — they say nothing about state and
// leave the previous one standing (herdr's publishing contract).
let observed = if pane.dead {
Some(AgentState::Idle)
} else if let Some(agent) = pane.agent {
let screen = match tmux::capture_pane(&pane.pane_id) {
Ok(screen) => screen,
Err(err) => {
warn!(target = %pane.target, %err, "capture failed");
continue;
}
};
// tmux pane_title carries the last OSC title the application
// set; tmux does not re-expose OSC progress sequences.
let detection =
detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, "");
(!detection.skip_state_update).then_some(detection.state)
} else {
// A pane that stops running an agent (agent quit into a
// shell) is no longer an observation.
Some(AgentState::Unknown)
};
let Some(new_state) = observed else {
debug!(target = %pane.target, "observation skipped (viewer screen)");
continue;
};
if new_state == prev_state {
continue;
}
self.states.insert(pane.pane_id.clone(), new_state);
debug!(target = %pane.target, ?prev_state, ?new_state, "state change");
if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) {
warn!(target = %pane.target, %err, "failed to write @agent_state");
}
let agent_label = pane
.agent
.map(detect_core::agent_label)
.unwrap_or("agent")
.to_string();
if let Some(kind) = notification_kind_for_transition(prev_state, new_state) {
if pane.suppresses_notifications() {
debug!(target = %pane.target, "suppressed (active pane)");
continue;
}
let context = format!("{} ({})", pane.target, agent_label);
send_notification(&agent_label, &kind, &context);
if self.sounds {
play_sound(&kind);
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_matches_herdr() {
assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked)
.is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none());
assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked)
.is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle)
.is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle)
.is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
// Unknown → Idle is a first observation, not a completion.
assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none());
assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none());
assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none());
}
}
|