aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md36
-rw-r--r--ROADMAP.md8
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts109
-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
-rw-r--r--menubar/main.swift24
-rw-r--r--menubar/model.swift31
-rwxr-xr-xtests/e2e.sh54
-rwxr-xr-xtests/kill-race.sh2
-rwxr-xr-xtests/pi-live.sh68
13 files changed, 709 insertions, 34 deletions
diff --git a/README.md b/README.md
index c12eebe..56c5a11 100644
--- a/README.md
+++ b/README.md
@@ -124,7 +124,8 @@ than a poll is still a completion.
1. **Agent hooks** (authoritative): `dingger install pi` installs a pi
extension that reports working/blocked/idle over
- `~/.local/state/dingger/agent.sock`. Restart running pi agents to load it.
+ `~/.local/state/dingger/agent.sock`. Running pi agents load it when
+ reloaded (see "Keeping pi agents on the current extension" below).
`dingger install claude` adds a SessionStart hook to
`~/.claude/settings.json` (keeping everything else; the original is
backed up to `settings.json.bak-dingger`) that reports only Claude's
@@ -133,8 +134,33 @@ than a poll is still a completion.
manifests, for agents without hooks.
All per-pane state lives in tmux pane options (`@agent_state`,
-`@dingger_since`, `@dingger_hook`, `@dingger_importance`), so restarting
-dingger loses nothing.
+`@dingger_since`, `@dingger_hook`, `@dingger_importance`,
+`@dingger_extension`, `@dingger_control`), so restarting dingger loses
+nothing.
+
+### Keeping pi agents on the current extension
+
+When the daemon starts, it updates the installed pi extension if this
+dingger's differs (only its own file, which says so on its first line).
+Running agents keep the one they loaded, and report its version. While
+some run an old one, the menu bar menu offers **Reload N Agents on an Old
+Extension**; `dingger reload-agents` does the same. Each agent reloads
+(pi's `/reload`) when:
+
+- it is idle (done included: it stays done),
+- it is not on screen, where you may be typing into it,
+- and its input box is empty: the extension checks this itself, at the
+ moment it reloads, so nothing you type is lost or sent.
+
+The others reload once they are; the request stays until all are, or the
+daemon restarts. dingger sends no keystrokes: it asks the extension over a
+socket of its own (`~/.local/state/dingger/pi-<pid>-<id>.sock`), and the
+extension runs the reload as a command (`/dingger-reload`, which you can
+also type). A reload also resets pi's other extensions: a pending alarm
+is lost, MCP servers reconnect.
+
+An agent on an extension from before this (it reports no version) takes
+no requests: the menu says how many need `/reload` by hand, once.
The pi extension's reports carry the agent's importance. Other pi
extensions can follow it on pi's event bus: `dingger:importance` with
@@ -149,6 +175,7 @@ dingger status # agents in priority order
dingger focus [--pane ID] # jump to an agent (default: the top waiter)
dingger importance [LEVEL] # an agent's importance: high, normal, low
dingger unread [--pane ID] # mark an idle agent unread (done again)
+dingger reload-agents # reload pi agents on an old extension
dingger install pi # install the pi state-reporting extension
dingger install claude # report Claude Code sessions, for restore
dingger install zsh # record typed command lines, for restore
@@ -215,7 +242,8 @@ daemon of its own, with a fake local model (`tests/fake-model.py`) and its
own pi agent directory: no network, no credentials, your pi untouched. It
covers identification, the extension's state and session reports, a turn,
`/reload`, an extension's blocked event, screen detection without the
-extension, restore, and exit. To try a release without installing it:
+extension, restore, reloading an agent on an old extension (and refusing
+while busy or with a draft), and exit. To try a release without installing it:
`npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>`
(through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`.
diff --git a/ROADMAP.md b/ROADMAP.md
index aeffb52..d4d125f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -81,9 +81,11 @@ Ideas, not yet prioritized:
session file. Show today's spend per agent (panel, menu, search) and
a total in the menu; optionally warn when one agent passes a limit.
One day's sample: $37.44 across 9 agents, 77% from two of them.
-16. **Keep agents on the current extension.** The extension reports its
- version; dingger shows which agents run an old one and reloads the
- idle ones without a draft in one action (restore's check).
+16. ~~**Keep agents on the current extension.**~~ Done (see the README):
+ the daemon updates the installed extension, and reloads agents on an
+ old one over a control socket, from the menu or `dingger
+ reload-agents`. The socket is also the way to items 11 (closing an
+ agent) and 12 (replying to one).
17. **Find an agent by what it discussed.** Search also matches the
conversation text in session files (needs an index).
18. **Warn about uncommitted work.** Mark agents whose directory has
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());
diff --git a/menubar/main.swift b/menubar/main.swift
index 2230817..82931ab 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -236,6 +236,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
refresh()
}
+ /// Reload the agents on an old extension: those that can now, the
+ /// others as they become idle and leave the screen (the daemon's job).
+ @objc private func reloadOutdated() {
+ log("reload agents: requested")
+ DispatchQueue.global().async {
+ let result = runReporting(dinggerBinary, ["reload-agents"])
+ log("reload agents: \(result.ok ? "" : "failed: ")\(result.output.replacingOccurrences(of: "\n", with: "; "))")
+ }
+ }
+
@objc private func restoreFromMenu(_ sender: NSMenuItem) {
restore(sessions: (sender.representedObject as? String).map { [$0] } ?? [])
}
@@ -304,6 +314,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.addItem(item)
menu.addItem(.separator())
}
+ let outdated = fetchOutdated()
+ if !outdated.isEmpty {
+ let item = NSMenuItem(title: "⟳ \(outdated.title)", action: outdated.canReload ? #selector(reloadOutdated) : nil,
+ keyEquivalent: "")
+ item.target = self
+ item.isEnabled = outdated.canReload
+ item.subtitle = outdated.detail
+ menu.addItem(item)
+ menu.addItem(.separator())
+ }
if rows.isEmpty {
let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
@@ -443,6 +463,10 @@ if arguments.contains("--selftest") {
if arguments.contains("--print") {
let rows = fetchRows()
print("title: \(plainTitle(for: rows))")
+ let outdated = fetchOutdated()
+ if !outdated.isEmpty {
+ print("⟳ \(outdated.title)\t\(outdated.detail)\(outdated.canReload ? "" : "\t(disabled)")")
+ }
for row in rows {
print("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
}
diff --git a/menubar/model.swift b/menubar/model.swift
index f0f977e..e4d179c 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -230,6 +230,37 @@ func fetchRestoreOffer() -> RestoreOffer? {
return try? JSONDecoder().decode(RestoreOffer.self, from: data)
}
+// MARK: agents on an old extension
+
+/// pi agents on an old dingger extension, as the daemon counts them:
+/// `@dingger_outdated` it can reload, `@dingger_outdated_by_hand` it cannot
+/// (an extension from before reloads).
+struct OutdatedAgents: Equatable {
+ let reloadable: Int
+ let byHand: Int
+
+ var isEmpty: Bool { reloadable == 0 && byHand == 0 }
+ var canReload: Bool { reloadable > 0 }
+
+ private func agents(_ n: Int) -> String { "\(n) \(n == 1 ? "Agent" : "Agents")" }
+
+ /// "Reload 3 Agents on an Old Extension", or "2 Agents Need /reload".
+ var title: String {
+ canReload ? "Reload \(agents(reloadable)) on an Old Extension" : "\(agents(byHand)) \(byHand == 1 ? "Needs" : "Need") /reload"
+ }
+
+ var detail: String {
+ guard canReload else { return "their dingger extension is too old to reload them" }
+ let when = "each when idle and off screen"
+ return byHand > 0 ? "\(when) · \(byHand) more need /reload" : when
+ }
+}
+
+func fetchOutdated() -> OutdatedAgents {
+ let count = { (name: String) in Int(tmux(["show-options", "-gv", name])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 }
+ return OutdatedAgents(reloadable: count("@dingger_outdated"), byHand: count("@dingger_outdated_by_hand"))
+}
+
/// Run `dingger <args>` through the user's login shell, the way a terminal
/// starts tmux: restored panes inherit this environment (PATH, LANG), and
/// launchd's is bare. DINGGER_RESTORE_DIRECT runs it directly (tests).
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 4b60316..7f72e3c 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -30,6 +30,8 @@ PASSES=0
# Every dingger invocation below (daemon, focus, poke, menu bar) targets the
# isolated server and the test daemon's hook socket.
export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state"
+# The daemon updates an installed pi extension when it starts: not the user's.
+export PI_CODING_AGENT_DIR="$DIR/pi-agent"
t() { tmux -u -L "$SOCK" "$@"; }
@@ -38,7 +40,7 @@ cleanup() {
[ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null
t kill-server 2>/dev/null
tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null
- rm -rf "$DIR"
+ if [ -n "${E2E_KEEP:-}" ]; then echo "kept $DIR"; else rm -rf "$DIR"; fi
}
trap cleanup EXIT
@@ -258,14 +260,14 @@ if [ -x "$MENUBAR" ]; then
expect "A1 done" "opt $A1 @agent_state" done
expect "menu title" "mb --print | head -1" "title: ×1 ✓1"
expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \
- "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;"
+ "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;"
# Wait for working: B1 was idle before, so idle alone would not show the turn.
hook "$B1" working
expect "B1 working" "opt $B1 @agent_state" working
hook "$B1" idle
expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle
expect "and is now the most recent idle agent: listed first" \
- "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"
+ "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"
expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now"
expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi"
mb --click "$A1"
@@ -319,9 +321,12 @@ for (const arg of process.argv.slice(2)) {
await new Promise((r) => setTimeout(r, 300));
}
JS
+# The extension as installed: stamped with its version.
+mkdir -p "$DIR/pi-harness"
+PI_CODING_AGENT_DIR="$DIR/pi-harness" "$DINGGER" install pi >/dev/null
ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there)
local pane=$1; shift
- TMUX_PANE="$pane" EXTENSION="$ROOT/crates/dinggerd/assets/pi/pi-agent-state.ts" \
+ TMUX_PANE="$pane" EXTENSION="$DIR/pi-harness/extensions/dingger-agent-state.ts" \
node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@"
}
if command -v node >/dev/null; then
@@ -981,13 +986,52 @@ if command -v node >/dev/null; then
: >"$EXTLOG"; iext "$U1" session_start "/unread"
expect "/unread marks it" "opt $U1 @agent_state" done
expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1
- look "$AWAY"; look "$U1"
+ # A held mark clears only once a tick sees the user away (a tick here
+ # takes ~0.3 s; two looks within one would never be seen apart).
+ look "$AWAY"; sleep 1; look "$U1"
expect "unr: seen again" "opt $U1 @agent_state" idle
: >"$EXTLOG"; iext "$U1" session_start agent_start "/unread"
expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1
expect "and changes nothing" "opt $U1 @agent_state" working
fi
+# --- 23. agents on an old extension ------------------------------------------
+# pi's extension reports its version and control socket; the daemon counts
+# the agents on another version, which the menu offers to reload. (The
+# reload itself, against a real pi: tests/pi-live.sh.)
+echo "23. agents on an old extension"
+ext_report() { # pane version control
+ python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
+import json, socket, sys
+params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "extension": sys.argv[3], "control": sys.argv[4]}
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
+s.close()
+PY
+}
+mkdir -p "$DIR/pi-current"
+PI_CODING_AGENT_DIR="$DIR/pi-current" $DINGGER install pi >/dev/null
+CURRENT=$(grep -o 'extensionVersion = "[0-9a-f]*"' "$DIR/pi-current/extensions/dingger-agent-state.ts" | cut -d'"' -f2)
+t new-session -d -s ext -n e1 -c "$DIR" "$DIR/bin/pi"
+E1=$(t display -p -t ext:e1 '#{pane_id}')
+# The agents reported so far sent no version: an extension from before
+# versions, which only a /reload by hand updates.
+expect "ext: the others are by hand" "[ \"\$(gopt @dingger_outdated_by_hand)\" -gt 0 ] && echo yes" yes
+BY_HAND=$(gopt @dingger_outdated_by_hand)
+ext_report "$E1" 0000000000000000 "$DIR/pi-0.sock"
+expect "ext: the version is on the pane" "opt $E1 @dingger_extension" 0000000000000000
+expect "and its control socket" "opt $E1 @dingger_control" "$DIR/pi-0.sock"
+expect "another version: reloadable" "gopt @dingger_outdated" 1
+expect "the menu offers to reload it" "mb --print | grep '^⟳'" \
+ "$(printf '⟳ Reload 1 Agent on an Old Extension\teach when idle and off screen · %s more need /reload' "$BY_HAND")"
+expect "reload-agents refuses without the current extension installed" "$DINGGER reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1
+ext_report "$E1" "$CURRENT" "$DIR/pi-1.sock"
+expect "the current version: not counted" "gopt @dingger_outdated" 0
+expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" \
+ "$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")"
+t kill-session -t ext
+
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
diff --git a/tests/kill-race.sh b/tests/kill-race.sh
index 388c297..d85f654 100755
--- a/tests/kill-race.sh
+++ b/tests/kill-race.sh
@@ -8,7 +8,7 @@ r() { tmux -u -L $R "$@"; }
mkdir -p $D/bin; printf '#!/bin/sh\nexec sleep 100000\n' > $D/bin/pi; chmod +x $D/bin/pi
r -f /dev/null new-session -d -s a -n w1 $D/bin/pi; r new-session -d -s b -n w2 $D/bin/pi
P=$(r display -p -t a:w1 '#{pane_id}')
-start() { DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; }
+start() { PI_CODING_AGENT_DIR=$D/pi-agent DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; }
hook() { python3 -c "
import json,socket; s=socket.socket(socket.AF_UNIX); s.connect('$D/hook.sock')
s.sendall((json.dumps({'method':'pane.report_agent','params':{'pane_id':'$P','agent':'pi','state':'$1'}})+'\n').encode())"; }
diff --git a/tests/pi-live.sh b/tests/pi-live.sh
index 059a92b..4ea4caf 100755
--- a/tests/pi-live.sh
+++ b/tests/pi-live.sh
@@ -9,7 +9,8 @@
# Covers: identification, the extension's state and session reports, a turn
# (working, then done), /reload, an extension's blocked event, /importance, /unread,
# screen detection for a pi without the extension, restore (`pi --session`,
-# importance included), exit.
+# importance included), reloading an agent on an old extension (refused
+# while busy or with a draft), exit.
set -u
PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
ROOT=$(cd "$(dirname "$0")/.." && pwd)
@@ -35,7 +36,7 @@ cleanup() {
t kill-server 2>/dev/null
[ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
[ -n "${FAKE_PID:-}" ] && kill "$FAKE_PID" 2>/dev/null
- rm -rf "$D"
+ if [ -n "${PI_LIVE_KEEP:-}" ]; then echo "kept $D"; else rm -rf "$D"; fi
}
trap cleanup EXIT
@@ -72,6 +73,12 @@ export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_V
export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf"
t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work"
t new-window -d -t main: -n plain -c "$D/work2"
+EXT="$D/agent/extensions/dingger-agent-state.ts"
+stamp() { grep -o 'extensionVersion = "[0-9a-f]*"' "$EXT" | cut -d'"' -f2; }
+CURRENT=$(stamp)
+# An installed extension of another version: the daemon updates it as it
+# starts (a pi started after that loads the current one).
+sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT"
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >"$D/daemon.log" 2>&1 &
DAEMON_PID=$!
t send-keys -t main:agent "command -v pi > $D/which-pi" Enter
@@ -84,6 +91,10 @@ NO_MCP=$("$PI_BIN" --help 2>&1 | grep -q -- --no-mcp && echo --no-mcp)
st() { t show -pv -t "main:$1" @agent_state 2>/dev/null; }
type_line() { t send-keys -t "main:$1" -l "$2"; sleep 0.3; t send-keys -t "main:$1" Enter; }
+echo "0. the daemon updates the installed extension"
+expect "to its own version" "stamp" "$CURRENT" 30
+[ ${#CURRENT} -eq 16 ] && pass "a 16-digit version" || fail "version [$CURRENT]"
+
echo "1. the extension's reports"
t send-keys -t main:agent "clear; PI_CODING_AGENT_DIR=$D/agent pi $NO_MCP" Enter
expect "identified as pi" "t show -pv -t main:agent @agent 2>/dev/null" pi 150
@@ -123,6 +134,59 @@ expect "/unread: done again" "st agent" done 50
expect "by a mark" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'mark unread target=main:agent applied=true'" 1 30
expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'marked unread: dingger shows it as done'" 1 30
+echo "1b. reloading an agent on an old extension"
+ctl() { t show -pv -t main:agent @dingger_control 2>/dev/null; }
+ask_reload() { python3 -c '
+import socket, sys
+s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1])
+s.sendall(b"{\"method\":\"reload\"}\n"); print(s.makefile().readline().strip())' "$(ctl)"; }
+reloads() { sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c "reload: $1"; }
+expect "the agent reports the current version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT"
+expect "and a control socket" "[ -S \"\$(ctl)\" ] && echo yes" yes
+expect "nothing to reload" "\"$DINGGER\" reload-agents" "every pi agent runs the current dingger extension" 1
+# Run an old one: change the installed file, /reload by hand, then install
+# the current one again (what a dingger update does).
+sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT"
+type_line agent "/reload"
+expect "after /reload, it reports the old version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" 0000000000000000 80
+expect "its new instance has a socket of its own" "[ -S \"\$(ctl)\" ] && echo yes" yes
+PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null
+expect "the daemon counts it outdated" "t show -gv @dingger_outdated 2>/dev/null" 1 30
+SOCK1=$(ctl)
+# Reloading cannot help while the installed file is not the current one.
+cp "$EXT" "$D/current.ts"; echo "// edited" >>"$EXT"
+expect "reload-agents refuses: install first" "\"$DINGGER\" reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1 1
+python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(b"{\"method\":\"agents.reload_outdated\"}\n"); s.recv(64)' "$D/hook.sock"
+expect "and so does the daemon, asked directly" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload agents: the installed pi extension is not'" 1 30
+sleep 1.5
+expect "without reloading it" "reloads reloading" 0 1
+cp "$D/current.ts" "$EXT"
+# The extension's own checks, asked directly.
+type_line agent "slow 4 busy turn"
+expect "working" "st agent" working 50
+expect "busy: it refuses" "ask_reload" '{"ok":false,"reason":"busy"}'
+expect "and the turn ends" "st agent" done 80
+t send-keys -t main:agent -l "half typed"
+sleep 0.5
+expect "a draft: it refuses" "ask_reload" '{"ok":false,"reason":"draft"}'
+# Through dingger: refused for the draft, retried until it can.
+"$DINGGER" reload-agents >"$D/reload.out"
+expect "reload-agents: not yet" "head -1 $D/reload.out" "reloaded 0 of 1 agent on an old dingger extension"
+expect "it says why it waits" "sed -n 2p $D/reload.out" "the other 1 reload once idle and off screen, with an empty input box"
+expect "the daemon was told no: a draft" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload: not now target=main:agent reason=draft'" 1 30
+expect "the draft is untouched" "t capture-pane -p -t main:agent | grep -c 'half typed'" 1
+for _ in $(seq 10); do t send-keys -t main:agent BSpace; done
+expect "once the input box is empty: reloaded" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT" 150
+expect "by the daemon" "reloads reloading" 1 30
+expect "a new instance (a new socket)" "[ \"\$(ctl)\" != \"$SOCK1\" ] && [ -S \"\$(ctl)\" ] && echo yes" yes
+expect "the old socket is gone" "[ -e \"$SOCK1\" ] && echo left || echo gone" gone
+expect "nothing left to reload" "t show -gv @dingger_outdated 2>/dev/null" 0 30
+expect "still done (unseen)" "st agent" done
+expect "no message reached the model" "grep -c dingger-reload \"\$(t show -pv -t main:agent @agent_session)\"" 0
+type_line agent "slow 1 after"
+expect "it still works: working" "st agent" working 50
+expect "and done" "st agent" done 60
+
echo "2. screen detection (pi without the extension)"
t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter
expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150