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
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
|
//! dingger: agent state watching for tmux panes.
mod hooks;
mod importance;
mod integrations;
mod manifest_update;
mod names;
mod notify;
mod platform;
mod restore;
mod snapshot;
mod terminals;
mod tmux;
mod watcher;
use clap::{Parser, Subcommand};
use std::io::IsTerminal;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "dingger", about = "Agent state watching for tmux panes")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum TerminalAction {
/// The client gained focus: learn its terminal (tmux's client-focus-in).
Focused {
#[arg(long)]
client: String,
},
/// Raise the client's terminal.
Raise {
#[arg(long)]
client: String,
},
}
#[derive(Subcommand)]
enum Command {
/// Watch all tmux panes and notify on agent state transitions.
Watch {
/// Poll interval in seconds.
#[arg(long, default_value_t = 1)]
interval_secs: u64,
/// Disable notification sounds.
#[arg(long)]
no_sounds: bool,
/// Disable macOS notifications (sounds still play unless --no-sounds).
#[arg(long)]
no_notifications: bool,
},
/// Print the current agent state of every tmux pane.
Status,
/// Refresh detection manifests from herdr's published catalog.
Manifests {
#[command(subcommand)]
command: ManifestCommand,
},
/// Show an agent pane and mark it attended. Without --pane, jumps to the
/// agent that has waited longest (FIFO), skipping the window the client
/// already shows. Used by the tmux status-line click and the menu bar.
Focus {
/// tmux client to switch (default: the most recently active one).
#[arg(long)]
client: Option<String>,
/// Pane to show (default: the head of the ready queue).
#[arg(long)]
pane: Option<String>,
},
/// Wake the watcher so focus changes show up immediately (tmux hooks).
Poke,
/// Set an agent's importance: it orders agents within a status (the
/// list, and the jump), and a low agent makes no sound. In pi, use
/// /importance instead: pi saves it with the session.
Importance {
/// `high`, `normal`, or `low`; without it, print the current one.
level: Option<String>,
/// The agent's pane (default: the pane this runs in).
#[arg(long)]
pane: Option<String>,
},
/// Mark an idle agent unread: it shows as done again, last among done
/// agents of its importance, until you come back to it. In pi, /unread
/// does the same.
Unread {
/// The agent's pane (default: the pane this runs in).
#[arg(long)]
pane: Option<String>,
},
/// Reload the pi agents that run an old dingger extension: those idle
/// and off screen now, the others once they are (a reload while busy,
/// or with a draft in the input box, waits). Until all are reloaded or
/// the daemon restarts.
ReloadAgents,
/// Restore tmux sessions saved before tmux last stopped (a crash,
/// shutdown, or kill-server): windows, panes, layouts, directories.
/// Only adds what is missing; a bare session of the same name (what a
/// terminal creates on startup) is replaced, its clients moved over.
Restore {
/// Show what would happen without changing anything.
#[arg(long)]
dry_run: bool,
/// List the restore candidate and saved snapshots.
#[arg(long)]
list: bool,
/// Restore from this snapshot file instead of the candidate.
#[arg(long)]
from: Option<String>,
/// Restore only these sessions.
#[arg(long = "session")]
sessions: Vec<String>,
/// Write the report to restore.log and notify a summary.
#[arg(long)]
report: bool,
/// Stop offering the saved layout (it can still be restored).
#[arg(long)]
dismiss: bool,
},
/// Install integrations: an agent's hook extension (state and session
/// reports), or `zsh` (command lines for restore to retype).
Install {
/// `pi`, `claude`, or `zsh`.
agent: String,
},
/// The terminal window of a tmux client (tmux runs these; see
/// `terminals`).
#[command(hide = true)]
Terminal {
#[command(subcommand)]
action: TerminalAction,
},
/// An agent's hook, run by the agent (`dingger install` sets it up).
#[command(hide = true)]
Hook {
/// `claude`.
agent: String,
},
}
#[derive(Subcommand)]
enum ManifestCommand {
/// Download newer manifests from herdr's published catalog into the
/// local remote store; bundled manifests are the offline fallback.
/// Restart `dingger watch` to pick them up.
Update,
}
fn main() {
platform::default_sigpipe();
let cli = Cli::parse();
// The daemon logs its work; commands print their own output and only
// log problems.
// tmux shows whatever its hooks print, and a non-zero exit, as a
// message: the commands tmux runs are silent unless run by hand.
let by_tmux = matches!(cli.command, Command::Terminal { .. }) && !std::io::stderr().is_terminal();
let default_level = match cli.command {
Command::Watch { .. } => "info",
_ if by_tmux => "off",
_ => "warn",
};
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default_level.into()))
.init();
match cli.command {
Command::Watch {
interval_secs,
no_sounds,
no_notifications,
} => {
integrations::update_pi_extension();
if let Err(err) = hooks::spawn_listener() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds, !no_notifications);
if let Err(err) = watcher.run() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Status => {
if let Err(err) = status() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Manifests { command } => match command {
ManifestCommand::Update => {
if let Err(err) = manifest_update::update() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
},
Command::Focus { client, pane } => {
if let Err(err) = focus(client, pane) {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Importance { level, pane } => {
if let Err(err) = set_importance(level.as_deref(), pane) {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Unread { pane } => {
if let Err(err) = mark_unread(pane) {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::ReloadAgents => {
if let Err(err) = reload_agents() {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Poke => {
// Best effort: a stopped watcher just means nothing to wake.
let _ = hooks::poke();
}
Command::Restore { dry_run, list, from, sessions, report, dismiss } => {
let result = if list {
restore::list()
} else if dismiss {
restore::dismiss()
} else {
restore::run(from.as_deref(), &sessions, dry_run, report)
};
if let Err(err) = result {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Install { agent } => {
if let Err(err) = integrations::install(&agent) {
eprintln!("dingger: {err}");
std::process::exit(1);
}
}
Command::Terminal { action } => {
let result = match &action {
TerminalAction::Focused { client } => terminals::learn(client),
TerminalAction::Raise { client } => terminals::raise(client),
};
// Best effort, run by tmux (the focus hook, a raise): an outcome
// like "focus moved on" or "terminal gone" is not a failure.
if let Err(err) = result {
if !by_tmux {
eprintln!("dingger: {err}");
}
}
}
// Never fails the agent: exits 0, prints nothing.
Command::Hook { agent } => integrations::run_hook(&agent),
}
}
/// Show a pane in a client and mark it attended (`@dingger_ack`, consumed
/// by the watcher on its next tick), then raise that client's terminal
/// window. The single code path behind every click: the tmux status line,
/// the menu bar rows, the panel, search, and the shortcuts.
///
/// The client: the named one (a status-line click names the clicking
/// client); otherwise one already on a session holding the pane's window
/// (several terminal windows can show one session), else any; the focused
/// one first, then the most recently used (see `terminals::choose`).
fn focus(client: Option<String>, pane: Option<String>) -> Result<(), String> {
let clients = terminals::clients()?;
if clients.is_empty() {
return Err("no tmux client attached".to_string());
}
let named = client.and_then(|name| clients.iter().find(|c| c.name == name));
let pane_id = match pane {
Some(pane) => pane,
None => {
// The queue head, skipping the window the user looks at.
let looking = named.or_else(|| terminals::choose(&clients, &[]).map(|(c, _)| c));
let current_window = looking.and_then(|c| tmux::client_format(&c.name, "#{window_id}"));
let queue = tmux::show_global("@dingger_queue").unwrap_or_default();
let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| {
tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref()
});
match next {
Some(id) => id.to_string(),
None => return Ok(()), // nothing waiting elsewhere
}
}
};
let showing = tmux::sessions_with_pane(&pane_id);
let (client, keeps_session) = match named {
Some(client) => (client, showing.contains(&client.session_id)),
None => terminals::choose(&clients, &showing).ok_or("no tmux client attached")?,
};
tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?;
tmux::show_pane(&client.name, &pane_id, keeps_session.then_some(client.session_id.as_str()))?;
let _ = hooks::poke();
raise(client);
Ok(())
}
/// Bring a client's terminal forward: its exact window, tab, and split if
/// dingger learned which it is (raised by the tmux server, see
/// `terminals`); otherwise the terminal app.
fn raise(client: &terminals::Client) {
if terminals::pairing(&client.tty).is_some() {
if let Ok(exe) = std::env::current_exe() {
let command = format!(
"{} terminal raise --client {} >/dev/null 2>&1",
snapshot::shell_quote(&exe.to_string_lossy()),
snapshot::shell_quote(&client.name)
);
if tmux::run(&["run-shell", "-b", &command]).is_ok() {
return;
}
}
}
if let Some(app) = terminals::terminal_app(client.pid) {
terminals::open_app(&app);
}
}
/// `dingger importance`: report the pane's agent importance to the daemon,
/// which writes it (the daemon is the one writer of pane state).
fn set_importance(level: Option<&str>, pane: Option<String>) -> Result<(), String> {
let target = pane
.or_else(|| std::env::var("TMUX_PANE").ok())
.ok_or("not in a tmux pane; pass --pane")?;
let info = tmux::run(&[
"display-message",
"-p",
"-t",
&target,
"#{pane_id}\t#{@agent}\t#{@dingger_hook}\t#{@dingger_importance}",
])
.map_err(|_| format!("no pane {target}"))?;
let fields: Vec<&str> = info.trim_end_matches('\n').split('\t').collect();
let [pane_id, agent, hook, written] = fields[..] else {
return Err(format!("no pane {target}"));
};
if agent.is_empty() {
return Err(format!("no agent in pane {target}"));
}
let Some(level) = level else {
println!("{}", importance::Importance::from_option(written).label());
return Ok(());
};
let level = importance::Importance::parse(level).ok_or("importance is high, normal, or low")?;
// pi's extension reports the importance saved in the session; a value
// set here would last only until its next report.
if agent == "pi" && !hook.is_empty() {
return Err("this pi reports to dingger: use /importance in it (pi saves it with the session)".into());
}
let message = serde_json::json!({
"method": "pane.report_agent",
"params": { "pane_id": pane_id, "importance": level.label() },
});
hooks::send(&message.to_string())?;
println!("importance: {}", level.label());
Ok(())
}
/// `dingger reload-agents`: ask the daemon to reload the agents on an old
/// extension; report what happened within a few seconds.
fn reload_agents() -> Result<(), String> {
let count = |name: &str| tmux::show_global(name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(0);
let by_hand = count("@dingger_outdated_by_hand");
let by_hand_note = || {
if by_hand > 0 {
let agents = if by_hand == 1 { "agent runs" } else { "agents run" };
println!("{by_hand} {agents} an extension too old to reload this way: type /reload in each");
}
};
let before = count("@dingger_outdated");
if before == 0 {
if by_hand == 0 {
println!("every pi agent runs the current dingger extension");
}
by_hand_note();
return Ok(());
}
if !integrations::pi_extension_installed_is_current() {
return Err("the installed pi extension is not this dingger's, so a reload would load the old one again: run `dingger install pi` first".into());
}
hooks::send(r#"{"method":"agents.reload_outdated"}"#)?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut left = before;
while left > 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(200));
left = count("@dingger_outdated");
}
let agents = |n: usize| if n == 1 { "agent" } else { "agents" };
println!("reloaded {} of {before} {} on an old dingger extension", before.saturating_sub(left), agents(before));
if left > 0 {
println!("the other {left} reload once idle and off screen, with an empty input box");
}
by_hand_note();
Ok(())
}
/// `dingger unread`: ask the daemon to mark the pane's idle agent unread,
/// and wait until it shows as done.
fn mark_unread(pane: Option<String>) -> Result<(), String> {
let target = pane
.or_else(|| std::env::var("TMUX_PANE").ok())
.ok_or("not in a tmux pane; pass --pane")?;
let read = || -> Result<(String, String, String), String> {
let info = tmux::run(&["display-message", "-p", "-t", &target, "#{pane_id}\t#{@agent}\t#{@agent_state}"])
.map_err(|_| format!("no pane {target}"))?;
match info.trim_end_matches('\n').split('\t').collect::<Vec<_>>()[..] {
[id, agent, state] => Ok((id.to_string(), agent.to_string(), state.to_string())),
_ => Err(format!("no pane {target}")),
}
};
let (pane_id, agent, state) = read()?;
match (agent.as_str(), state.as_str()) {
("", _) => return Err(format!("no agent in pane {target}")),
(_, "idle") => {}
(_, "done") => return Err("it is unread already (done)".into()),
(_, state) => return Err(format!("only an idle agent can be marked unread; this one is {state}")),
}
let message = serde_json::json!({ "method": "pane.mark_unread", "params": { "pane_id": pane_id } });
hooks::send(&message.to_string())?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while std::time::Instant::now() < deadline {
match read()?.2.as_str() {
"done" => {
println!("marked unread");
return Ok(());
}
"idle" => std::thread::sleep(std::time::Duration::from_millis(50)),
other => return Err(format!("not marked: it turned {other}")),
}
}
Err("not marked: the daemon did not apply it".into())
}
/// Every agent pane in herdr's priority order (blocked, done, working,
/// idle), oldest-waiting first within a status. ✎ marks a draft left in
/// the agent's input box.
fn status() -> Result<(), String> {
let mut panes: Vec<_> = tmux::list_panes()?
.into_iter()
.filter(|pane| pane.agent.is_some())
.collect();
if panes.is_empty() {
println!("no agent panes");
return Ok(());
}
panes.sort_by_key(|pane| {
let status = watcher::Status::from_label(&pane.written_state);
let importance = importance::Importance::from_option(&pane.written_importance);
let since = pane.written_since.parse().ok();
(watcher::list_order(status, watcher::forgotten_draft(pane), importance, since), pane.display_target.clone())
});
let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1);
let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
println!("{:<8} {:<width$} {:<7} NAME", "STATUS", "WHERE", "AGENT");
for pane in &panes {
let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
let draft = if watcher::forgotten_draft(pane) { "✎ " } else { "" };
let name = match importance::Importance::from_option(&pane.written_importance) {
importance::Importance::High => format!("! {}", pane.written_name),
importance::Importance::Low => format!("{} (low)", pane.written_name),
importance::Importance::Normal => pane.written_name.clone(),
};
println!("{state:<8} {:<width$} {agent:<7} {draft}{}", place(pane), name.trim());
}
Ok(())
}
|