aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/hooks.rs141
-rw-r--r--crates/dinggerd/src/integrations.rs62
-rw-r--r--crates/dinggerd/src/main.rs50
-rw-r--r--crates/dinggerd/src/tmux.rs10
-rw-r--r--crates/dinggerd/src/watcher.rs148
5 files changed, 392 insertions, 19 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index b2f9e97..0074544 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -21,6 +21,12 @@
//! `{"method":"pane.mark_unread","params":{"pane_id":"%3"}}` marks an idle
//! agent unread (`dingger unread`, pi's /unread): it shows as done again.
//!
+//! A pi report also carries the extension's `extension` version and its
+//! `control` socket, where the daemon asks it to reload (`{"method":
+//! "reload"}`; it answers whether it does): `dingger reload-agents` sends
+//! `{"method":"agents.reload_outdated"}`, and the watcher reloads each agent
+//! on an old extension once it is idle and off screen.
+//!
//! Each line is answered with `{"ok":true}`.
//!
//! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's
@@ -33,6 +39,7 @@ use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
@@ -59,6 +66,9 @@ struct PaneReports {
session: Option<String>,
/// The agent's importance, if reported.
importance: Option<Importance>,
+ /// The version of the extension that reported, and its control socket.
+ extension: Option<String>,
+ control: Option<String>,
/// When the last report arrived.
at: Option<Instant>,
}
@@ -85,6 +95,18 @@ pub fn take_unread(pane_id: &str) -> bool {
unread_marks().remove(pane_id)
}
+/// Whether agents on an old extension should be reloaded (`dingger
+/// reload-agents`); the watcher clears it when none is left.
+static RELOAD_WANTED: AtomicBool = AtomicBool::new(false);
+
+pub fn reload_wanted() -> bool {
+ RELOAD_WANTED.load(Ordering::Relaxed)
+}
+
+pub fn reload_done() {
+ RELOAD_WANTED.store(false, Ordering::Relaxed);
+}
+
fn wake_signal() -> &'static (Mutex<bool>, Condvar) {
static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new();
WAKE.get_or_init(|| (Mutex::new(false), Condvar::new()))
@@ -139,6 +161,10 @@ struct HookParams {
agent_session_id: Option<String>,
#[serde(default)]
importance: Option<String>,
+ #[serde(default)]
+ extension: Option<String>,
+ #[serde(default)]
+ control: Option<String>,
}
fn handle_stream(stream: UnixStream) {
@@ -162,6 +188,12 @@ fn handle_stream(stream: UnixStream) {
wake();
continue;
}
+ if message.method == "agents.reload_outdated" {
+ info!("reload agents on an old extension: requested");
+ RELOAD_WANTED.store(true, Ordering::Relaxed);
+ wake();
+ continue;
+ }
if message.method == "pane.mark_unread" {
if let Some(params) = message.params {
info!(pane = %params.pane_id, "marked unread");
@@ -194,12 +226,32 @@ fn handle_stream(stream: UnixStream) {
Some(state) => info!(pane = %params.pane_id, ?state, ?importance, "hook report"),
None => info!(pane = %params.pane_id, ?importance, "hook report without state"),
}
- record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id), importance);
+ let reported = Reported {
+ session: params.agent_session_path.or(params.agent_session_id),
+ importance,
+ extension: params.extension,
+ control: params.control,
+ };
+ record(params.pane_id, state, reported);
wake();
}
}
-fn record(pane_id: String, state: Option<AgentState>, session: Option<String>, importance: Option<Importance>) {
+/// What a report carries besides its state; each kept until reported again.
+#[derive(Debug, Default)]
+struct Reported {
+ session: Option<String>,
+ importance: Option<Importance>,
+ extension: Option<String>,
+ control: Option<String>,
+}
+
+/// A value fit for a tmux option: not empty, no control characters.
+fn option_value(value: Option<String>) -> Option<String> {
+ value.filter(|v| !v.is_empty() && !v.chars().any(char::is_control))
+}
+
+fn record(pane_id: String, state: Option<AgentState>, reported: Reported) {
let mut reports = reports();
let pane = reports.entry(pane_id).or_default();
pane.at = Some(Instant::now());
@@ -207,11 +259,17 @@ fn record(pane_id: String, state: Option<AgentState>, session: Option<String>, i
pane.last = Some(state);
pane.pending.push(state);
}
- if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) {
+ if let Some(session) = option_value(reported.session) {
pane.session = Some(session);
}
- if importance.is_some() {
- pane.importance = importance;
+ if reported.importance.is_some() {
+ pane.importance = reported.importance;
+ }
+ if let Some(extension) = option_value(reported.extension) {
+ pane.extension = Some(extension);
+ }
+ if let Some(control) = option_value(reported.control) {
+ pane.control = Some(control);
}
}
@@ -265,6 +323,33 @@ pub fn importance(pane_id: &str) -> Option<Importance> {
reports().get(pane_id).and_then(|pane| pane.importance)
}
+/// The version of the pane's extension and its control socket, if reported.
+pub fn extension(pane_id: &str) -> (Option<String>, Option<String>) {
+ reports().get(pane_id).map(|pane| (pane.extension.clone(), pane.control.clone())).unwrap_or_default()
+}
+
+/// Ask the extension behind `control` to reload; its answer: `Ok(())` when
+/// it does, else why not (busy, a draft, or no answer).
+pub fn request_reload(control: &str) -> Result<(), String> {
+ use std::io::Write;
+ let mut stream = UnixStream::connect(control).map_err(|err| format!("connect: {err}"))?;
+ stream.set_read_timeout(Some(Duration::from_secs(2))).map_err(|err| err.to_string())?;
+ stream.write_all(b"{\"method\":\"reload\"}\n").map_err(|err| format!("send: {err}"))?;
+ let mut answer = String::new();
+ BufReader::new(stream).read_line(&mut answer).map_err(|err| format!("no answer: {err}"))?;
+ #[derive(Deserialize)]
+ struct Answer {
+ ok: bool,
+ #[serde(default)]
+ reason: Option<String>,
+ }
+ match serde_json::from_str::<Answer>(&answer) {
+ Ok(Answer { ok: true, .. }) => Ok(()),
+ Ok(Answer { reason, .. }) => Err(reason.unwrap_or_else(|| "refused".into())),
+ Err(_) => Err(format!("unexpected answer: {}", answer.trim())),
+ }
+}
+
/// Drop a pane's reports: the agent was not running when the panes were
/// listed at `listed`. A report since then is kept: the agent started
/// after the listing (Claude reports its session once, as it starts).
@@ -286,7 +371,7 @@ mod tests {
#[test]
fn a_session_report_records_only_the_session() {
- record("%t1".into(), None, Some("/p/s.jsonl".into()), None);
+ record("%t1".into(), None, Reported { session: Some("/p/s.jsonl".into()), ..Default::default() });
assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl"));
assert_eq!(take_observations("%t1"), None, "state stays with the screen");
assert_eq!(last_state("%t1"), None);
@@ -295,7 +380,7 @@ mod tests {
#[test]
fn a_report_after_the_pane_listing_is_not_dropped() {
let listed = Instant::now();
- record("%t2".into(), None, Some("/p/s.jsonl".into()), None);
+ record("%t2".into(), None, Reported { session: Some("/p/s.jsonl".into()), ..Default::default() });
drop_reports("%t2", listed);
assert!(session("%t2").is_some(), "the agent started after the listing");
drop_reports("%t2", Instant::now());
@@ -304,11 +389,47 @@ mod tests {
#[test]
fn importance_is_kept_until_reported_again() {
- record("%t3".into(), Some(AgentState::Working), None, Some(Importance::High));
- record("%t3".into(), Some(AgentState::Idle), None, None);
+ record("%t3".into(), Some(AgentState::Working), Reported { importance: Some(Importance::High), ..Default::default() });
+ record("%t3".into(), Some(AgentState::Idle), Reported::default());
assert_eq!(importance("%t3"), Some(Importance::High), "a report without it keeps it");
- record("%t3".into(), None, None, Some(Importance::Normal));
+ record("%t3".into(), None, Reported { importance: Some(Importance::Normal), ..Default::default() });
assert_eq!(importance("%t3"), Some(Importance::Normal));
assert_eq!(last_state("%t3"), Some(AgentState::Idle), "a report without state keeps the state");
}
+
+ #[test]
+ fn the_extension_and_its_control_socket_are_kept_until_reported_again() {
+ let reported = || Reported { extension: Some("v1".into()), control: Some("/s/pi-1.sock".into()), ..Default::default() };
+ record("%t4".into(), Some(AgentState::Idle), reported());
+ record("%t4".into(), None, Reported { importance: Some(Importance::Low), ..Default::default() });
+ assert_eq!(extension("%t4"), (Some("v1".into()), Some("/s/pi-1.sock".into())));
+ record("%t4".into(), None, Reported { extension: Some("v2".into()), ..Default::default() });
+ assert_eq!(extension("%t4").0.as_deref(), Some("v2"));
+ }
+
+ #[test]
+ fn a_reload_answer_says_whether_it_reloads() {
+ let dir = std::env::temp_dir().join(format!("dingger-reload-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for (answer, want) in [
+ ("{\"ok\":true}", Ok(())),
+ ("{\"ok\":false,\"reason\":\"draft\"}", Err("draft".to_string())),
+ ] {
+ let path = dir.join("pi.sock");
+ let _ = std::fs::remove_file(&path);
+ let listener = UnixListener::bind(&path).unwrap();
+ let server = std::thread::spawn(move || {
+ use std::io::Write;
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut line = String::new();
+ BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
+ assert_eq!(line, "{\"method\":\"reload\"}\n");
+ stream.write_all(format!("{answer}\n").as_bytes()).unwrap();
+ });
+ assert_eq!(request_reload(path.to_str().unwrap()), want);
+ server.join().unwrap();
+ }
+ assert!(request_reload(dir.join("none.sock").to_str().unwrap()).is_err(), "no extension there");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs
index b3daf1d..5d9cf7a 100644
--- a/crates/dinggerd/src/integrations.rs
+++ b/crates/dinggerd/src/integrations.rs
@@ -10,7 +10,28 @@ use serde_json::{json, Value};
/// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts`
/// (Apache-2.0, Copyright herdr contributors).
+/// It carries a placeholder for its version, which install fills in.
const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts");
+const VERSION_PLACEHOLDER: &str = "__DINGGER_EXTENSION_VERSION__";
+
+/// The extension's version: a fingerprint of its text (FNV-1a, so it does
+/// not change with the Rust release). A running pi reports the version it
+/// loaded; one that differs from this runs an old extension.
+pub fn pi_extension_version() -> String {
+ let hash = PI_EXTENSION.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
+ (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
+ });
+ format!("{hash:016x}")
+}
+
+/// The extension as installed: stamped with its version.
+fn pi_extension_text() -> String {
+ PI_EXTENSION.replace(VERSION_PLACEHOLDER, &pi_extension_version())
+}
+
+fn pi_extension_path() -> Result<PathBuf, String> {
+ Ok(pi_extension_dir()?.join("dingger-agent-state.ts"))
+}
fn pi_extension_dir() -> Result<PathBuf, String> {
let base = std::env::var_os("PI_CODING_AGENT_DIR")
@@ -76,16 +97,40 @@ pub fn install(agent: &str) -> Result<(), String> {
fn install_pi() -> Result<(), String> {
let dir = pi_extension_dir()?;
std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?;
- let path = dir.join("dingger-agent-state.ts");
- std::fs::write(&path, PI_EXTENSION).map_err(|err| format!("write failed: {err}"))?;
+ let path = pi_extension_path()?;
+ std::fs::write(&path, pi_extension_text()).map_err(|err| format!("write failed: {err}"))?;
tracing::info!(path = %path.display(), "installed pi extension");
println!(
- "installed {} — new pi sessions report their state directly;\nrestart running pi agents (or reload them) to pick it up",
+ "installed {} — new pi sessions report their state directly;\nrunning pi agents pick it up when reloaded (`dingger reload-agents`)",
path.display()
);
Ok(())
}
+/// Whether the installed pi extension is this dingger's: only then does a
+/// reload bring an agent up to date (otherwise it loads the old one again).
+pub fn pi_extension_installed_is_current() -> bool {
+ pi_extension_path()
+ .and_then(|path| std::fs::read_to_string(path).map_err(|err| err.to_string()))
+ .is_ok_and(|installed| installed == pi_extension_text())
+}
+
+/// At the daemon's start: if the pi extension is installed and differs
+/// from this dingger's, update it, so a dingger update needs no reinstall.
+/// Only dingger's own file (it says so on its first line).
+pub fn update_pi_extension() {
+ let Ok(path) = pi_extension_path() else { return };
+ let Ok(installed) = std::fs::read_to_string(&path) else { return };
+ let current = pi_extension_text();
+ if installed == current || !installed.starts_with("// installed by dingger") {
+ return;
+ }
+ match std::fs::write(&path, current) {
+ Ok(()) => tracing::info!(path = %path.display(), version = %pi_extension_version(), "updated pi extension"),
+ Err(err) => tracing::warn!(path = %path.display(), %err, "failed to update pi extension"),
+ }
+}
+
/// Claude Code's user settings, where its hooks are configured.
fn claude_settings_path() -> Result<PathBuf, String> {
std::env::var_os("CLAUDE_CONFIG_DIR")
@@ -217,6 +262,17 @@ mod tests {
use super::*;
#[test]
+ fn the_installed_extension_carries_its_version_once() {
+ let version = pi_extension_version();
+ assert_eq!(version.len(), 16);
+ assert_eq!(PI_EXTENSION.matches(VERSION_PLACEHOLDER).count(), 1, "the asset has one placeholder");
+ let text = pi_extension_text();
+ assert!(!text.contains(VERSION_PLACEHOLDER));
+ assert!(text.contains(&format!("\"{version}\"")), "stamped as a string literal");
+ assert!(text.starts_with("// installed by dingger"), "update_pi_extension recognizes its own file");
+ }
+
+ #[test]
fn claude_hook_is_added_beside_other_hooks_and_keys_in_order() {
let settings: Value = serde_json::from_str(
r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]}],"Stop":[]},"theme":"auto"}"#,
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 2c36198..1fc5cd2 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -90,6 +90,11 @@ enum Command {
#[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
@@ -166,6 +171,7 @@ fn main() {
no_sounds,
no_notifications,
} => {
+ integrations::update_pi_extension();
if let Err(err) = hooks::spawn_listener() {
eprintln!("dingger: {err}");
std::process::exit(1);
@@ -208,6 +214,12 @@ fn main() {
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();
@@ -355,6 +367,44 @@ fn set_importance(level: Option<&str>, pane: Option<String>) -> Result<(), Strin
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> {
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 9fcccf3..b743256 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -30,6 +30,8 @@ pub struct Pane {
pub written_session: String,
/// `@dingger_importance`: unset for normal.
pub written_importance: String,
+ pub written_extension: String,
+ pub written_control: String,
}
@@ -111,14 +113,16 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{pane_current_path}\t",
"#{@agent_name}\t",
"#{@agent_session}\t",
- "#{@dingger_importance}",
+ "#{@dingger_importance}\t",
+ "#{@dingger_extension}\t",
+ "#{@dingger_control}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes: Vec<Pane> = Vec::new();
let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 20 {
+ if fields.len() < 22 {
continue;
}
let attended = fields[4] == "1" && fields[5] != "0";
@@ -161,6 +165,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_name: fields[17].to_string(),
written_session: fields[18].to_string(),
written_importance: fields[19].to_string(),
+ written_extension: fields[20].to_string(),
+ written_control: fields[21].to_string(),
});
}
Ok(panes)
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());