aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/watcher.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-30 17:27:21 +0200
committerIgor Soarez <igor@soarez.org>2026-10-30 17:27:21 +0200
commitb0cfd10b80443d3f67d263ff4db177bb53cc90ed (patch)
tree957951350434f38da83457f5467b6c000e892867 /crates/dinggerd/src/watcher.rs
parent91436536b69c10aa333c2b2978284b57cca3ed64 (diff)
reload pi agents on an old extension: versioned extension, control socket, menu item and reload-agents
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
-rw-r--r--crates/dinggerd/src/watcher.rs148
1 files changed, 144 insertions, 4 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index b76f53c..1c57609 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -24,10 +24,12 @@
//! - `@dingger_hook` last state an agent extension reported
//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus`
//! - `@agent_session` the agent's session reference, for restore
+//! - `@dingger_extension`, `@dingger_control` the version of pi's extension
+//! and its control socket, for reloading agents on an old one
use std::collections::HashMap;
-use detect_core::{detect_agent_with_osc, AgentState};
+use detect_core::{detect_agent_with_osc, Agent, AgentState};
use tracing::{debug, info, warn};
use crate::hooks;
@@ -218,13 +220,66 @@ pub fn importance_of(pane: &tmux::Pane) -> Importance {
hooks::importance(&pane.pane_id).unwrap_or_else(|| Importance::from_option(&pane.written_importance))
}
+/// An agent on an old dingger extension: dingger can reload it (its
+/// extension takes requests), or only the user can (`/reload`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Outdated {
+ Reloadable,
+ ByHand,
+}
+
+/// Whether a pane runs an old extension: a pi that reports through one
+/// (its hook state) with a version other than `current`, or none (an
+/// extension from before versions, which takes no requests either).
+pub fn outdated(agent: Option<Agent>, reports: bool, extension: &str, control: &str, current: &str) -> Option<Outdated> {
+ if agent != Some(Agent::Pi) || !reports || extension == current {
+ return None;
+ }
+ Some(if control.is_empty() { Outdated::ByHand } else { Outdated::Reloadable })
+}
+
+/// Whether to ask an outdated agent to reload now: it is idle (done
+/// included: a reload keeps it done) and not on screen, where the user may
+/// be typing into it. The extension itself refuses while busy or with a
+/// draft in its input box.
+pub fn reload_now(track: PaneTrack, attended: bool) -> bool {
+ track.state == AgentState::Idle && !attended
+}
+
+/// How long before asking an agent that refused (or did not reload) again.
+const RELOAD_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
+
+/// The pane's extension version and control socket: as last reported,
+/// else as written (they survive a daemon restart).
+fn extension_of(pane: &tmux::Pane) -> (String, String) {
+ let (extension, control) = hooks::extension(&pane.pane_id);
+ (
+ extension.unwrap_or_else(|| pane.written_extension.clone()),
+ control.unwrap_or_else(|| pane.written_control.clone()),
+ )
+}
+
+fn outdated_of(pane: &tmux::Pane) -> Option<Outdated> {
+ let reports = hooks::last_state(&pane.pane_id).is_some() || !pane.written_hook.is_empty();
+ let (extension, control) = extension_of(pane);
+ outdated(pane.agent, reports, &extension, &control, current_extension())
+}
+
+fn current_extension() -> &'static str {
+ static CURRENT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
+ CURRENT.get_or_init(crate::integrations::pi_extension_version)
+}
+
pub struct Watcher {
tracks: HashMap<String, PaneTrack>,
+ /// When each outdated agent was last asked to reload.
+ reload_asked: HashMap<String, std::time::Instant>,
snapshots: crate::snapshot::Recorder,
offer: crate::restore::OfferPublisher,
- /// Last published (queue, blocked, done, working); avoids rewriting the
+ /// Last published (queue, blocked, done, working, outdated reloadable,
+ /// outdated by hand); avoids rewriting the
/// global options every tick.
- last_published: Option<(String, usize, usize, usize)>,
+ last_published: Option<(String, usize, usize, usize, usize, usize)>,
interval_secs: u64,
sounds: bool,
notifications: bool,
@@ -234,6 +289,7 @@ impl Watcher {
pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self {
Self {
tracks: HashMap::new(),
+ reload_asked: HashMap::new(),
snapshots: Default::default(),
offer: Default::default(),
last_published: None,
@@ -340,10 +396,49 @@ impl Watcher {
}
}
+ self.reload_outdated(&panes);
self.publish(&panes);
Ok(())
}
+ /// While reloading is wanted: ask each outdated agent that can reload
+ /// now, at most every `RELOAD_RETRY`; done when none is left.
+ fn reload_outdated(&mut self, panes: &[tmux::Pane]) {
+ self.reload_asked.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
+ if !hooks::reload_wanted() {
+ return;
+ }
+ if !crate::integrations::pi_extension_installed_is_current() {
+ warn!("reload agents: the installed pi extension is not this dingger's; `dingger install pi` first");
+ hooks::reload_done();
+ return;
+ }
+ let mut left = 0;
+ for pane in panes {
+ if outdated_of(pane) != Some(Outdated::Reloadable) {
+ continue;
+ }
+ left += 1;
+ let Some(track) = self.tracks.get(&pane.pane_id).copied() else { continue };
+ let asked = self.reload_asked.get(&pane.pane_id).is_some_and(|at| at.elapsed() < RELOAD_RETRY);
+ if asked || !reload_now(track, pane.attended) {
+ continue;
+ }
+ self.reload_asked.insert(pane.pane_id.clone(), std::time::Instant::now());
+ let (_, control) = extension_of(pane);
+ let target = pane.display_target.clone();
+ // The extension answers at once; off the tick all the same.
+ std::thread::spawn(move || match hooks::request_reload(&control) {
+ Ok(()) => info!(%target, "reload: reloading"),
+ Err(reason) => info!(%target, %reason, "reload: not now"),
+ });
+ }
+ if left == 0 {
+ info!("reload agents on an old extension: none left");
+ hooks::reload_done();
+ }
+ }
+
/// Write the pane's options that changed, in one tmux command list:
/// tmux applies it as a unit, so a daemon killed mid-write never leaves
/// a display state that disagrees with the hook state it came from.
@@ -383,6 +478,21 @@ impl Watcher {
(None, _) => change("@dingger_importance", &pane.written_importance, String::new()),
_ => {}
}
+ // The extension's version and control socket go with the agent too.
+ match (pane.agent, hooks::extension(&pane.pane_id)) {
+ (Some(_), (extension, control)) => {
+ if let Some(extension) = extension {
+ change("@dingger_extension", &pane.written_extension, extension);
+ }
+ if let Some(control) = control {
+ change("@dingger_control", &pane.written_control, control);
+ }
+ }
+ (None, _) => {
+ change("@dingger_extension", &pane.written_extension, String::new());
+ change("@dingger_control", &pane.written_control, String::new());
+ }
+ }
if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) {
warn!(target = %pane.display_target, %err, "failed to write pane options");
}
@@ -411,7 +521,13 @@ impl Watcher {
fn publish(&mut self, panes: &[tmux::Pane]) {
let mut queue = Vec::new();
let (mut blocked, mut done, mut working) = (0, 0, 0);
+ let (mut reloadable, mut by_hand) = (0, 0);
for pane in panes {
+ match outdated_of(pane) {
+ Some(Outdated::Reloadable) => reloadable += 1,
+ Some(Outdated::ByHand) => by_hand += 1,
+ None => {}
+ }
let Some(track) = self.tracks.get(&pane.pane_id) else {
continue;
};
@@ -433,7 +549,7 @@ impl Watcher {
.collect::<Vec<_>>()
.join(",");
- let next = (queue, blocked, done, working);
+ let next = (queue, blocked, done, working, reloadable, by_hand);
if self.last_published.as_ref() == Some(&next) {
return;
}
@@ -442,6 +558,8 @@ impl Watcher {
("@dingger_blocked", next.1.to_string()),
("@dingger_done", next.2.to_string()),
("@dingger_working", next.3.to_string()),
+ ("@dingger_outdated", next.4.to_string()),
+ ("@dingger_outdated_by_hand", next.5.to_string()),
] {
if let Err(err) = tmux::set_global(name, &value) {
warn!(%err, "failed to write {name}");
@@ -730,6 +848,28 @@ mod tests {
}
#[test]
+ fn an_old_extension_is_a_pi_reporting_another_version_or_none() {
+ let pi = Some(Agent::Pi);
+ assert_eq!(outdated(pi, true, "v2", "/s.sock", "v2"), None, "current");
+ assert_eq!(outdated(pi, true, "v1", "/s.sock", "v2"), Some(Outdated::Reloadable));
+ assert_eq!(outdated(pi, true, "", "", "v2"), Some(Outdated::ByHand), "from before versions");
+ assert_eq!(outdated(pi, false, "", "", "v2"), None, "no extension: nothing to reload");
+ assert_eq!(outdated(Some(Agent::Claude), true, "", "", "v2"), None, "Claude's hooks run dingger itself");
+ assert_eq!(outdated(None, true, "v1", "/s.sock", "v2"), None);
+ }
+
+ #[test]
+ fn an_agent_reloads_when_idle_and_off_screen() {
+ let track = |state, seen| PaneTrack { state, seen, since: Some(1), held: false };
+ assert!(reload_now(track(Idle, true), false));
+ assert!(reload_now(track(Idle, false), false), "done too: it stays done");
+ assert!(!reload_now(track(Idle, true), true), "on screen: the user may be typing");
+ assert!(!reload_now(track(Working, true), false));
+ assert!(!reload_now(track(Blocked, true), false));
+ assert!(!reload_now(PaneTrack::NEW, false));
+ }
+
+ #[test]
fn priority_matches_herdr() {
assert!(Status::Blocked.priority() > Status::Done.priority());
assert!(Status::Done.priority() > Status::Working.priority());