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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
|
//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's
//! status model → notifications and pane-option write-back.
//!
//! Status model (herdr's, verbatim): five display statuses over two inputs —
//! the raw agent state (working/blocked/idle, from hooks or scraping) and a
//! `seen` flag (attended since the last state change):
//!
//! | raw | seen | display | priority |
//! |----------|--------|----------|----------|
//! | working | — | working | 2 |
//! | blocked | — | blocked | 4 |
//! | idle | false | done | 3 |
//! | idle | true | idle | 1 |
//! | none | — | unknown | 0 |
//!
//! "Seen" is maintained three ways: the pane is fully focused in an attached
//! client (the daemon watches tmux focus), the user jumped to it via
//! `dingger focus` / menu click, or its next transition.
use std::collections::HashMap;
use detect_core::{detect_agent_with_osc, AgentState};
use tracing::{debug, info, warn};
use crate::hooks;
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
/// herdr's display statuses, in priority order (highest first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Status {
Unknown,
Idle,
Working,
Done,
Blocked,
}
impl Status {
#[allow(dead_code)] // herdr parity: the full vocabulary is part of the model
pub fn label(&self) -> &'static str {
match self {
Status::Unknown => "unknown",
Status::Idle => "idle",
Status::Working => "working",
Status::Done => "done",
Status::Blocked => "blocked",
}
}
#[allow(dead_code)] // herdr parity
pub fn parse(label: &str) -> Option<Status> {
match label {
"working" => Some(Status::Working),
"blocked" => Some(Status::Blocked),
"done" => Some(Status::Done),
"idle" => Some(Status::Idle),
"unknown" => Some(Status::Unknown),
_ => None,
}
}
/// herdr's priority: blocked > done > working > idle > unknown.
#[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon
pub fn priority(&self) -> u8 {
match self {
Status::Blocked => 4,
Status::Done => 3,
Status::Working => 2,
Status::Idle => 1,
Status::Unknown => 0,
}
}
}
/// herdr's notification policy (`notification_toast_for_state_change`),
/// driven by raw state transitions.
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,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PaneTrack {
state: AgentState,
seen: bool,
/// Unix second the pane entered its current done/blocked episode; drives
/// the FIFO ready queue.
ready_since: Option<u64>,
}
pub struct Watcher {
tracks: HashMap<String, PaneTrack>,
/// Last published (queue, counts); avoids rewriting options every tick.
last_published: Option<(String, (String, String, String, String))>,
interval_secs: u64,
sounds: bool,
}
impl Watcher {
pub fn new(interval_secs: u64, sounds: bool) -> Self {
Self {
tracks: HashMap::new(),
last_published: None,
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.tracks
.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
hooks::forget(
&panes
.iter()
.map(|pane| pane.pane_id.clone())
.collect::<Vec<_>>(),
);
for pane in &panes {
let prev_track = self.tracks.get(&pane.pane_id).copied();
let (prev_state, prev_seen) = prev_track
.map(|track| (track.state, track.seen))
.unwrap_or((AgentState::Unknown, true));
// Raw state: hook reports are authoritative while the pane runs
// the agent; screen detection is the fallback for panes that
// never report. 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 raw = if pane.dead {
Some(AgentState::Idle)
} else if let Some(agent) = pane.agent {
if let Some(state) = hooks::hook_state(&pane.pane_id) {
Some(state)
} else {
let screen = match tmux::capture_pane(&pane.pane_id) {
Ok(screen) => screen,
Err(err) => {
warn!(target = %pane.display_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; a stale hook report
// from the dead agent is not authoritative.
Some(AgentState::Unknown)
};
let Some(new_state) = raw else {
debug!(target = %pane.display_target, "observation skipped (viewer screen)");
continue;
};
// Seen: attended since the last state change. Attended means the
// pane is fully focused in an attached client, the user jumped to
// it (`@dingger_ack`), or it was just first observed. A first
// observation of idle is seen (nothing has happened yet); a
// working/blocked → idle transition is an unseen completion.
let transition = new_state != prev_state;
// Attended also via `dingger focus` / menu click (@dingger_ack).
let mut seen = if transition {
!(new_state == AgentState::Idle && prev_state == AgentState::Unknown)
} else {
prev_seen || pane.written_ack == "1"
};
if !seen && pane.attended() {
seen = true;
}
if transition || seen != prev_seen {
let ready_since = if is_ready_to_attend(new_state, seen) {
Some(unix_now())
} else {
None
};
self.tracks.insert(
pane.pane_id.clone(),
PaneTrack {
state: new_state,
seen,
ready_since,
},
);
}
if transition {
debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change");
let _ = tmux::clear_ack(&pane.pane_id);
} else if seen != prev_seen {
let _ = tmux::ack(&pane.pane_id);
}
let display = display_status(
self.tracks
.get(&pane.pane_id)
.unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }),
);
// @agent_state mirrors the display status on both transitions and
// attendance changes (done → idle when the user focuses the pane).
if pane.written_state != display.label() {
if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) {
warn!(target = %pane.display_target, %err, "failed to write @agent_state");
}
}
if new_state == AgentState::Unknown {
// The agent is gone; its last hook report must not outlive it.
hooks::drop_report(&pane.pane_id);
}
// Keep the pane's agent label current even when the state is
// unchanged (e.g. an agent swap between two idle panes).
let agent_label = pane
.agent
.map(detect_core::agent_label)
.unwrap_or("")
.to_string();
if pane.written_agent != agent_label {
if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) {
warn!(target = %pane.display_target, %err, "failed to write @agent");
}
}
if transition {
if let Some(kind) = notification_kind_for_transition(prev_state, new_state) {
// herdr: a blocked pane plays the alert sound even when
// the user is watching; a finished pane stays silent when
// they are already looking at it.
let suppressed = pane.suppresses_notifications();
if !suppressed {
let context = format!("{} ({})", pane.display_target, agent_label);
send_notification(&agent_label, &kind, &context);
}
if self.sounds {
play_sound(&kind);
}
}
}
}
self.publish(&panes);
Ok(())
}
/// Publish the FIFO queue (done + blocked panes, oldest transition first)
/// and counts as server-global options for the tmux status line and
/// `dingger focus`.
fn publish(&mut self, panes: &[tmux::Pane]) {
let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since)
let mut blocked = 0usize;
let mut done = 0usize;
let mut working = 0usize;
for pane in panes {
let Some(track) = self.tracks.get(&pane.pane_id) else {
continue;
};
let status = display_status(track);
match status {
Status::Working => working += 1,
Status::Done | Status::Blocked => {
if track.ready_since.is_some() {
if status == Status::Blocked {
blocked += 1;
} else {
done += 1;
}
if let Some(since) = track.ready_since {
ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since));
}
}
}
Status::Idle | Status::Unknown => {}
}
}
ready.sort_by_key(|(_, _, since)| *since);
let queue = ready
.iter()
.map(|(pane_id, _, since)| format!("{pane_id}:{since}"))
.collect::<Vec<_>>()
.join(",");
let counts = (
ready.len().to_string(),
blocked.to_string(),
working.to_string(),
done.to_string(),
);
let next = (queue.clone(), counts.clone());
if self.last_published.as_ref() != Some(&next) {
self.last_published = Some(next);
for (name, value) in [
("@dingger_queue", queue.as_str()),
("@dingger_ready", counts.0.as_str()),
("@dingger_blocked", counts.1.as_str()),
("@dingger_working", counts.2.as_str()),
("@dingger_done", counts.3.as_str()),
] {
if let Err(err) = tmux::set_global(name, value) {
warn!(%err, "failed to write {name}");
}
}
}
}
}
/// When a pane became ready for the user, as unix seconds. Ready means the
/// agent is waiting on the user: done (finished, unseen) or blocked.
fn is_ready_to_attend(state: AgentState, seen: bool) -> bool {
match state {
AgentState::Blocked => true,
AgentState::Idle => !seen,
_ => false,
}
}
fn display_status(track: &PaneTrack) -> Status {
match track.state {
AgentState::Working => Status::Working,
AgentState::Blocked => Status::Blocked,
AgentState::Idle => {
if track.seen {
Status::Idle
} else {
Status::Done
}
}
AgentState::Unknown => Status::Unknown,
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[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());
}
#[test]
fn display_status_maps_herdr() {
let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) };
let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None };
assert_eq!(display_status(&unseen), Status::Done);
assert_eq!(display_status(&seen), Status::Idle);
assert_eq!(
display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }),
Status::Working
);
assert_eq!(
display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }),
Status::Blocked
);
}
#[test]
fn priority_matches_herdr() {
assert!(Status::Blocked.priority() > Status::Done.priority());
assert!(Status::Done.priority() > Status::Working.priority());
assert!(Status::Working.priority() > Status::Idle.priority());
assert!(Status::Idle.priority() > Status::Unknown.priority());
}
}
|