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
|
//! 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>,
},
/// 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,
} => {
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::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(())
}
/// Every agent pane in herdr's priority order (blocked, done, working,
/// idle), oldest-waiting first within a status.
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);
(watcher::list_order(status, importance, pane.written_since.parse().ok()), 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 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} {}", place(pane), name.trim());
}
Ok(())
}
|