diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-30 17:27:21 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-30 17:27:21 +0200 |
| commit | b0cfd10b80443d3f67d263ff4db177bb53cc90ed (patch) | |
| tree | 957951350434f38da83457f5467b6c000e892867 /crates/dinggerd | |
| parent | 91436536b69c10aa333c2b2978284b57cca3ed64 (diff) | |
reload pi agents on an old extension: versioned extension, control socket, menu item and reload-agents
Diffstat (limited to 'crates/dinggerd')
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 109 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 141 | ||||
| -rw-r--r-- | crates/dinggerd/src/integrations.rs | 62 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 50 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 10 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 148 |
6 files changed, 501 insertions, 19 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index fd13b55..aae40cc 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -2,7 +2,9 @@ // managed by dingger; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. +import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; // The dingger hook receiver listens here; override with DINGGER_AGENT_SOCKET. const socketPath = @@ -15,6 +17,18 @@ const socketPath = const paneId = process.env.DINGGER_PANE_ID || process.env.TMUX_PANE; const source = "dingger:pi"; +// Filled in by `dingger install pi`: the daemon reloads an agent that +// reports another version (`dingger reload-agents`). +const extensionVersion = "__DINGGER_EXTENSION_VERSION__"; + +// Where the daemon asks this extension to reload: one socket per extension +// instance (a reload starts a new one beside the old), next to the daemon's. +const controlPath = path.join( + path.dirname(socketPath), + `pi-${process.pid}-${Math.random().toString(36).slice(2, 8)}.sock`, +); +let controlListening = false; + function enabled() { return !!paneId; } @@ -112,6 +126,8 @@ async function report(state: AgentState | undefined, message?: string): Promise< ...(state ? { state } : {}), message, importance, + extension: extensionVersion, + ...(controlListening ? { control: controlPath } : {}), seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -253,11 +269,102 @@ export default function (pi) { }, }); + // The latest context, for the control socket's checks. + let latestCtx: any; + let control: net.Server | undefined; + + // Run only by the control socket below, which checked that pi is idle + // with an empty input box: extensions can reload pi only from a command. + pi.registerCommand("dingger-reload", { + description: "Reload pi (dingger uses it to update its extension; same as /reload)", + handler: async (_args: string, ctx: any) => { + await ctx.reload(); + }, + }); + + // Why this pi should not reload now, or undefined if it can. + function reloadRefusal(): string | undefined { + try { + if (agentActive || blockedCount > 0 || latestCtx?.isIdle?.() !== true || latestCtx?.hasPendingMessages?.()) { + return "busy"; + } + if ((latestCtx?.ui?.getEditorText?.() ?? "").trim() !== "") { + return "draft"; + } + } catch { + return "no context"; + } + return undefined; + } + + function startControl() { + if (control) { + return; + } + control = net.createServer((socket) => { + let buffer = ""; + socket.on("error", () => socket.destroy()); + socket.on("data", (data) => { + buffer += data.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) { + return; + } + let method: unknown; + try { + method = JSON.parse(buffer.slice(0, newline))?.method; + } catch { + method = undefined; + } + if (method !== "reload") { + socket.end(`${JSON.stringify({ ok: false, reason: "unknown method" })}\n`); + return; + } + const refusal = reloadRefusal(); + socket.end(`${JSON.stringify(refusal ? { ok: false, reason: refusal } : { ok: true })}\n`); + if (!refusal) { + // Checked and started in one go: no keystroke comes in between. + pi.sendUserMessage("/dingger-reload", { expandPromptTemplates: true }); + } + }); + }); + control.on("error", () => { + controlListening = false; + }); + try { + fs.unlinkSync(controlPath); + } catch { + // None left over. + } + control.listen(controlPath, () => { + controlListening = true; + void report(undefined); + }); + // The socket alone does not keep pi running. + control.unref(); + } + + pi.on("session_shutdown", () => { + if (!control) { + return; + } + control.close(); + control = undefined; + controlListening = false; + try { + fs.unlinkSync(controlPath); + } catch { + // Gone already. + } + }); + pi.on("session_start", async (event, ctx) => { // TUI only: RPC/JSON/print modes are headless. if (ctx?.mode !== "tui") { return; } + latestCtx = ctx; + startControl(); // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; updateSessionFile(ctx); @@ -268,12 +375,14 @@ export default function (pi) { }); pi.on("agent_start", (_event, ctx) => { + latestCtx = ctx; agentActive = true; updateSessionFile(ctx); publishState(); }); pi.on("agent_settled", (_event, ctx) => { + latestCtx = ctx; if (ctx?.isIdle?.() !== true) { return; } 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()); |
