aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--ROADMAP.md6
-rw-r--r--crates/dinggerd/src/main.rs25
-rw-r--r--crates/dinggerd/src/notify.rs13
-rw-r--r--crates/dinggerd/src/platform.rs19
-rw-r--r--crates/dinggerd/src/restore.rs243
-rw-r--r--crates/dinggerd/src/watcher.rs3
-rw-r--r--menubar/main.swift98
-rw-r--r--menubar/model.swift52
-rw-r--r--menubar/panel.swift128
-rw-r--r--packaging/tmux/dingger.tmux10
-rwxr-xr-xtests/e2e.sh51
12 files changed, 610 insertions, 43 deletions
diff --git a/README.md b/README.md
index 07dabfd..475f659 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,11 @@ than a poll is still a completion.
zsh`, then source the file it names from your `.zshrc`), otherwise the
program's arguments. An agent or program that exits within a minute
before tmux stops (a shutdown) is still restored.
+ When tmux stops, the panel (first row: "↺ Restore 9 sessions · 22
+ agents", or "Not now"), the menu (all, or one session at a time), and the
+ status line (`↺9`, clickable) offer the restore; it runs through your
+ login shell, like a terminal starting tmux, and a notification
+ summarizes (full report: `~/.local/state/dingger/restore.log`).
- All shortcuts live in `~/.config/dingger/config.toml` (menu bar,
Settings…), reloaded on save.
- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after
diff --git a/ROADMAP.md b/ROADMAP.md
index 7afbab3..4aa76df 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,9 +2,9 @@
In priority order.
-1. **Restore after a crash or shutdown.** Bring back tmux sessions, windows,
- and panes (names, layout, directories) and resume each agent's own
- session (`pi --session <file>`, `claude --resume <id>`, ...).
+1. ~~**Restore after a crash or shutdown.**~~ Done for pi (see the README).
+ Next: other agents' session references (claude's session id through its
+ hooks, `claude --resume <id>`; codex, ...).
2. **Raise the right window.** Jumping to an agent activates the terminal
app; with several windows (several Ghostty windows, say) it should raise
the exact window that holds the tmux client, not whichever was last used.
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 7cf3513..fced7c2 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -72,6 +72,12 @@ enum Command {
/// Restore only these sessions.
#[arg(long = "session")]
sessions: Vec<String>,
+ /// Write the report to restore.log and notify a summary.
+ #[arg(long)]
+ report: bool,
+ /// Stop offering the saved layout (it can still be restored).
+ #[arg(long)]
+ dismiss: bool,
},
/// Install integrations: an agent's hook extension (state and session
/// reports), or `zsh` (command lines for restore to retype).
@@ -90,11 +96,16 @@ enum ManifestCommand {
}
fn main() {
+ platform::default_sigpipe();
+ let cli = Cli::parse();
+ // The daemon logs its work; commands print their own output and only
+ // log problems.
+ let default_level = if matches!(cli.command, Command::Watch { .. }) { "info" } else { "warn" };
tracing_subscriber::fmt()
- .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
+ .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default_level.into()))
.init();
- match Cli::parse().command {
+ match cli.command {
Command::Watch {
interval_secs,
no_sounds,
@@ -134,8 +145,14 @@ fn main() {
// Best effort: a stopped watcher just means nothing to wake.
let _ = hooks::poke();
}
- Command::Restore { dry_run, list, from, sessions } => {
- let result = if list { restore::list() } else { restore::run(from.as_deref(), &sessions, dry_run) };
+ Command::Restore { dry_run, list, from, sessions, report, dismiss } => {
+ let result = if list {
+ restore::list()
+ } else if dismiss {
+ restore::dismiss()
+ } else {
+ restore::run(from.as_deref(), &sessions, dry_run, report)
+ };
if let Err(err) = result {
eprintln!("dingger: {err}");
std::process::exit(1);
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
index e619d6d..555299d 100644
--- a/crates/dinggerd/src/notify.rs
+++ b/crates/dinggerd/src/notify.rs
@@ -63,6 +63,19 @@ pub fn play_sound(kind: &NotificationKind) {
}
}
+/// Deliver a notification and wait for it: for short-lived commands, whose
+/// exit would cut a background delivery short.
+pub fn send_message_blocking(title: &str, body: &str) {
+ info!(title, body, "notifying");
+ if let Ok(command) = std::env::var("DINGGER_NOTIFIER") {
+ let _ = std::process::Command::new("sh").args(["-c", &command, title, body]).status();
+ return;
+ }
+ if let Err(err) = mac_notification_sys::Notification::new().title(title).message(body).send() {
+ warn!(%err, "failed to deliver macOS notification");
+ }
+}
+
/// Deliver a macOS notification without blocking the caller: the delivery
/// call waits on Notification Center for seconds, and the watcher must keep
/// updating the UI meanwhile.
diff --git a/crates/dinggerd/src/platform.rs b/crates/dinggerd/src/platform.rs
index 8504a83..6c78817 100644
--- a/crates/dinggerd/src/platform.rs
+++ b/crates/dinggerd/src/platform.rs
@@ -1,4 +1,21 @@
-//! macOS process inspection.
+//! macOS process inspection and process setup.
+
+/// Exit quietly when stdout closes early (`dingger restore --list | head`),
+/// like other command-line tools, instead of panicking: Rust ignores
+/// SIGPIPE by default.
+#[cfg(unix)]
+pub fn default_sigpipe() {
+ extern "C" {
+ fn signal(signum: std::os::raw::c_int, handler: usize) -> usize;
+ }
+ const SIGPIPE: std::os::raw::c_int = 13;
+ const SIG_DFL: usize = 0;
+ // SAFETY: resets one signal to its default disposition before any
+ // threads start.
+ unsafe {
+ signal(SIGPIPE, SIG_DFL);
+ }
+}
/// A process's exact argument vector, from the kernel (`KERN_PROCARGS2`):
/// unlike `ps`, it keeps arguments that contain spaces intact.
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs
index 2332865..623cdcd 100644
--- a/crates/dinggerd/src/restore.rs
+++ b/crates/dinggerd/src/restore.rs
@@ -18,6 +18,27 @@ use std::time::{Duration, Instant};
use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap};
use crate::tmux;
+thread_local! {
+ static REPORT: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
+}
+
+/// Print a report line, and keep it for `--report`.
+fn say(line: String) {
+ println!("{line}");
+ REPORT.with(|report| report.borrow_mut().push(line));
+}
+
+/// What a restore did, for the summary notification.
+#[derive(Debug, Default)]
+pub struct Summary {
+ pub sessions: usize,
+ pub failed: usize,
+ pub agents: usize,
+ pub resumed: usize,
+ pub interrupted: usize,
+ pub typed: usize,
+}
+
/// What restore types into a rebuilt pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Typed {
@@ -239,7 +260,7 @@ fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result<
/// Wait for each new shell's prompt, then resume agents (spaced out) and
/// type the other command lines without running them; report which agents
/// did not start.
-fn type_commands(rebuilt: &[Rebuilt]) {
+fn type_commands(rebuilt: &[Rebuilt], summary: &mut Summary) {
let work: Vec<(&Rebuilt, Typed)> =
rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect();
if work.is_empty() {
@@ -266,21 +287,23 @@ fn type_commands(rebuilt: &[Rebuilt]) {
Typed::Resume(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "Enter"]);
- println!("resume {} {command}", r.label);
+ say(format!("resume {} {command}", r.label));
resumed.push(*r);
// Agents start one at a time.
std::thread::sleep(Duration::from_millis(500));
}
Typed::Retype(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
- println!("type {} {command} (press Enter to run)", r.label);
+ say(format!("type {} {command} (press Enter to run)", r.label));
+ summary.typed += 1;
}
}
}
for (r, _) in &work {
if let Some(agent) = r.pane.agent.as_ref().filter(|agent| agent.state == "working") {
let name = if agent.name.is_empty() { &agent.agent } else { &agent.name };
- println!("interrupted {} {name}: it was working, its turn was cut short", r.label);
+ say(format!("interrupted {} {name}: it was working, its turn was cut short", r.label));
+ summary.interrupted += 1;
}
}
if resumed.is_empty() {
@@ -297,9 +320,11 @@ fn type_commands(rebuilt: &[Rebuilt]) {
.unwrap_or(true)
});
}
- println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len());
+ summary.agents = resumed.len();
+ summary.resumed = resumed.len() - not_running.len();
+ say(format!("resumed {} of {} agents", summary.resumed, summary.agents));
for r in not_running {
- println!("not running {}: check the pane for the error", r.label);
+ say(format!("not running {}: check the pane for the error", r.label));
}
}
@@ -348,9 +373,59 @@ fn format_time(ms: u64) -> String {
.unwrap_or_else(|_| ms.to_string())
}
-/// Restore missing sessions (all, or only `only`).
-pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), String> {
+/// Restore missing sessions (all, or only `only`). With `report`, also
+/// write the report to `restore.log` and notify a summary.
+pub fn run(from: Option<&str>, only: &[String], dry_run: bool, report: bool) -> Result<(), String> {
+ let result = restore(from, only, dry_run);
+ if report && !dry_run {
+ let lines = REPORT.with(|report| report.borrow().join("\n"));
+ let _ = std::fs::write(snapshot::state_dir().join("restore.log"), format!("{lines}\n"));
+ let (title, body) = match &result {
+ Ok(summary) => summary_message(summary),
+ Err(err) => ("tmux restore failed".to_string(), err.clone()),
+ };
+ crate::notify::send_message_blocking(&title, &body);
+ }
+ let _ = crate::hooks::poke();
+ result.map(|_| ())
+}
+
+fn plural(n: usize, one: &str, many: &str) -> String {
+ format!("{n} {}", if n == 1 { one } else { many })
+}
+
+pub fn summary_message(summary: &Summary) -> (String, String) {
+ let title = format!("Restored {}", plural(summary.sessions, "tmux session", "tmux sessions"));
+ let mut parts = Vec::new();
+ if summary.agents > 0 {
+ parts.push(format!("resumed {} of {}", summary.resumed, plural(summary.agents, "agent", "agents")));
+ }
+ if summary.interrupted > 0 {
+ parts.push(format!("{} mid-turn", plural(summary.interrupted, "was", "were")));
+ }
+ if summary.typed > 0 {
+ parts.push(format!("{} typed, not run", plural(summary.typed, "command", "commands")));
+ }
+ if summary.failed > 0 {
+ parts.push(format!("{} failed", plural(summary.failed, "session", "sessions")));
+ }
+ if parts.is_empty() {
+ parts.push("nothing else to resume".into());
+ }
+ (title, parts.join(" · "))
+}
+
+/// Mark the restore candidate dismissed: no more offers for it.
+pub fn dismiss() -> Result<(), String> {
+ snapshot::set_candidate_status("dismissed")?;
+ let _ = crate::hooks::poke();
+ println!("dismissed; `dingger restore` still restores it");
+ Ok(())
+}
+
+fn restore(from: Option<&str>, only: &[String], dry_run: bool) -> Result<Summary, String> {
let snapshot = source(from)?;
+ let mut summary = Summary::default();
let existing = existing_sessions();
let mut failures = 0;
let mut rebuilt = Vec::new();
@@ -364,19 +439,19 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
let what = windows_and_panes(session);
let verb = match plan {
Plan::Keep => {
- println!("keep {name}: it exists");
+ say(format!("keep {name}: it exists"));
continue;
}
Plan::Create => "create",
Plan::Replace => "replace",
};
if dry_run {
- println!("{verb:<8} {name}: {what}");
+ say(format!("{verb:<8} {name}: {what}"));
for window in &session.windows {
for pane in &window.panes {
match typed_for(pane) {
- Some(Typed::Resume(command)) => println!(" resume {} {command}", window.name),
- Some(Typed::Retype(command)) => println!(" type {} {command}", window.name),
+ Some(Typed::Resume(command)) => say(format!(" resume {} {command}", window.name)),
+ Some(Typed::Retype(command)) => say(format!(" type {} {command}", window.name)),
None => {}
}
}
@@ -388,23 +463,124 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
_ => build_session(session, &name, &mut rebuilt).map(|_| ()),
};
match result {
- Ok(()) => println!("{verb:<8} {name}: {what}"),
+ Ok(()) => {
+ summary.sessions += 1;
+ say(format!("{verb:<8} {name}: {what}"));
+ }
Err(err) => {
failures += 1;
- println!("FAILED {name}: {err}");
+ say(format!("FAILED {name}: {err}"));
}
}
}
if !rebuilt.is_empty() {
- type_commands(&rebuilt);
+ type_commands(&rebuilt, &mut summary);
}
- if dry_run || from.is_some() {
- return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) };
- }
- if missing(&snapshot, &existing_sessions()).is_empty() {
+ summary.failed = failures;
+ if !dry_run && from.is_none() && missing(&snapshot, &existing_sessions()).is_empty() {
let _ = snapshot::set_candidate_status("restored");
}
- if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }
+ if failures == 0 { Ok(summary) } else { Err(format!("{failures} sessions failed")) }
+}
+
+/// One session restore would bring back, as offered to the user.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct OfferedSession {
+ pub name: String,
+ pub windows: usize,
+ pub panes: usize,
+ pub agents: usize,
+}
+
+/// The restore offer the menu bar app shows (`offer.json`).
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct Offer {
+ pub saved_at: u64,
+ pub sessions: Vec<OfferedSession>,
+ pub agents: usize,
+ /// Agents that were mid-turn when tmux stopped.
+ pub interrupted: usize,
+}
+
+pub fn offer(snapshot: &Snapshot, existing: &[Existing]) -> Option<Offer> {
+ let missing = missing(snapshot, existing);
+ let sessions: Vec<&SessionSnap> =
+ snapshot.sessions.iter().filter(|session| missing.contains(&session.name)).collect();
+ if sessions.is_empty() {
+ return None;
+ }
+ let panes = |session: &&SessionSnap| session.windows.iter().flat_map(|w| &w.panes).cloned().collect::<Vec<_>>();
+ let agents = sessions.iter().flat_map(panes).filter(|pane| pane.agent.is_some()).count();
+ let interrupted = sessions
+ .iter()
+ .flat_map(panes)
+ .filter(|pane| pane.agent.as_ref().is_some_and(|agent| agent.state == "working"))
+ .count();
+ Some(Offer {
+ saved_at: snapshot.saved_at,
+ sessions: sessions
+ .iter()
+ .map(|session| OfferedSession {
+ name: session.name.clone(),
+ windows: session.windows.len(),
+ panes: session.windows.iter().map(|w| w.panes.len()).sum(),
+ agents: panes(session).iter().filter(|pane| pane.agent.is_some()).count(),
+ })
+ .collect(),
+ agents,
+ interrupted,
+ })
+}
+
+/// Keeps `offer.json` and the `@dingger_restore` option current; called by
+/// the watcher every loop.
+#[derive(Default)]
+pub struct OfferPublisher {
+ candidate: Option<(std::time::SystemTime, Option<snapshot::Candidate>)>,
+ published: Option<Option<Offer>>,
+}
+
+impl OfferPublisher {
+ pub fn tick(&mut self) {
+ let path = snapshot::state_dir().join("restore.json");
+ let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
+ let stale = match (&self.candidate, modified) {
+ (Some((at, _)), Some(modified)) => *at != modified,
+ (None, _) => true,
+ (Some(_), None) => true,
+ };
+ if stale {
+ self.candidate = Some((modified.unwrap_or(std::time::UNIX_EPOCH), snapshot::candidate()));
+ }
+ let offered = self
+ .candidate
+ .as_ref()
+ .and_then(|(_, candidate)| candidate.as_ref())
+ .filter(|candidate| candidate.status == "offered");
+ let offer = offered.and_then(|candidate| offer(&candidate.snapshot, &existing_sessions()));
+ if self.published.as_ref() == Some(&offer) {
+ return;
+ }
+ let file = snapshot::state_dir().join("offer.json");
+ match &offer {
+ Some(offer) => {
+ if let Ok(json) = serde_json::to_string_pretty(offer) {
+ let tmp = file.with_extension("tmp");
+ if std::fs::write(&tmp, json).is_ok() {
+ let _ = std::fs::rename(&tmp, &file);
+ }
+ }
+ let set = tmux::run(&["set-option", "-g", "@dingger_restore", &offer.sessions.len().to_string()]);
+ tracing::info!(sessions = offer.sessions.len(), agents = offer.agents, ok = set.is_ok(), "restore offered");
+ }
+ None => {
+ let _ = std::fs::remove_file(&file);
+ let unset = tmux::run(&["set-option", "-g", "-u", "@dingger_restore"]);
+ tracing::info!(?unset, "restore offer withdrawn");
+ }
+ }
+ self.published = Some(offer);
+ }
}
#[cfg(test)]
@@ -494,6 +670,33 @@ mod tests {
}
#[test]
+ fn offers_only_missing_sessions_with_their_agents() {
+ let mut saved = snapshot(&["0", "work"]);
+ saved.sessions[1].windows[0].panes[0].agent = Some(AgentSnap {
+ agent: "pi".into(),
+ name: "x".into(),
+ state: "working".into(),
+ session: None,
+ });
+ let offer = offer(&saved, &[Existing { name: "0".into(), bare: false }]).expect("work is missing");
+ assert_eq!(offer.sessions.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["work"]);
+ assert_eq!((offer.agents, offer.interrupted, offer.sessions[0].panes), (1, 1, 2));
+ assert_eq!(super::offer(&saved, &[
+ Existing { name: "0".into(), bare: false },
+ Existing { name: "work".into(), bare: false },
+ ]), None, "nothing missing, no offer");
+ }
+
+ #[test]
+ fn summarizes_a_restore() {
+ let summary = Summary { sessions: 9, agents: 22, resumed: 21, interrupted: 1, typed: 3, failed: 0 };
+ assert_eq!(
+ summary_message(&summary),
+ ("Restored 9 tmux sessions".into(), "resumed 21 of 22 agents · 1 was mid-turn · 3 commands typed, not run".into())
+ );
+ }
+
+ #[test]
fn shells_are_recognized() {
assert!(is_shell("zsh") && is_shell("-zsh") && is_shell("bash") && is_shell("fish"));
assert!(!is_shell("nvim") && !is_shell("node") && !is_shell("tmux"));
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index 24a2ecd..a2d6dae 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -182,6 +182,7 @@ pub fn advance(
pub struct Watcher {
tracks: HashMap<String, PaneTrack>,
snapshots: crate::snapshot::Recorder,
+ offer: crate::restore::OfferPublisher,
/// Last published (queue, blocked, done, working); avoids rewriting the
/// global options every tick.
last_published: Option<(String, usize, usize, usize)>,
@@ -195,6 +196,7 @@ impl Watcher {
Self {
tracks: HashMap::new(),
snapshots: Default::default(),
+ offer: Default::default(),
last_published: None,
interval_secs: interval_secs.max(1),
sounds,
@@ -226,6 +228,7 @@ impl Watcher {
}
// After the tick, so snapshots carry the states it just wrote.
self.snapshots.tick(unix_now());
+ self.offer.tick();
debug!(ms = started.elapsed().as_millis() as u64, "tick");
hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs));
}
diff --git a/menubar/main.swift b/menubar/main.swift
index a00288c..0d367b8 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -25,6 +25,10 @@
// --snapshot-search <png> <query> [<down>]
// render agent search offscreen, then exit
// --click <pane> perform a row click for <pane>, then exit
+// --restore [<session>...]
+// restore saved tmux sessions as the panel and menu do
+// (through the login shell), then exit
+// --dismiss-offer dismiss the restore offer as "Not now" does, then exit
// --selftest check the panel and search rules, then exit
import AppKit
@@ -33,6 +37,17 @@ func log(_ message: String) {
FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8))
}
+/// Restore saved tmux sessions (all when `sessions` is empty) through the
+/// login shell, and log the report. Blocks until done.
+@discardableResult
+func performRestore(sessions: [String]) -> Bool {
+ let args = ["restore", "--report"] + sessions.flatMap { ["--session", $0] }
+ let result = runDinggerInLoginShell(args)
+ let lines = result.output.split(separator: "\n").filter { !$0.contains("can't change option: zle") }
+ log("restore: \(result.ok ? "done" : "failed")\n" + lines.map { " \($0)" }.joined(separator: "\n"))
+ return result.ok
+}
+
final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
@@ -49,6 +64,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var searchHotkeyRegistered = false
private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull")
private var rows: [Row] = []
+ private var restoreOffer: RestoreOffer?
+ private var restoring = false
/// The panel settings; the first run after the on/off split migrates the
/// old three-way mode.
@@ -71,7 +88,9 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
item.menu = menu
panel = PanelController(
onClick: { [weak self] paneId in self?.jump(to: paneId) },
- onToggleFull: { [weak self] in self?.toggleFull() })
+ onToggleFull: { [weak self] in self?.toggleFull() },
+ onRestore: { [weak self] in self?.restore(sessions: []) },
+ onDismissRestore: { [weak self] in self?.dismissRestore() })
searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) })
Settings.ensureFile()
reloadSettings()
@@ -124,6 +143,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
item.button?.setAccessibilityLabel("dingger, " + tipText)
visibility.anyWaiting = rows.contains(where: \.waiting)
+ restoreOffer = fetchRestoreOffer()
+ visibility.restoreOffered = restoreOffer != nil
applyPanel()
searchWindow.update(rows: rows)
}
@@ -141,7 +162,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var panelContent: PanelContent {
PanelContent(rows: rows,
full: panelShowsFull(policy: visibility.policy, prefersFull: prefersFull),
- canToggle: visibility.policy == .always)
+ canToggle: visibility.policy == .always,
+ offer: restoreOffer, restoring: restoring)
}
private func applyPanel() {
@@ -163,6 +185,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var panelState: String {
"\(visibility.enabled ? "on" : "off"), \(visibility.policy.rawValue), "
+ (visibility.anyWaiting ? "agents waiting" : "nothing waiting")
+ + (visibility.restoreOffered ? ", restore offered" : "")
}
/// The shortcut and the menu item: turn the panel on or off, persistently.
@@ -188,6 +211,33 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
+ /// Restore saved tmux sessions (all when `sessions` is empty).
+ func restore(sessions: [String], completion: (() -> Void)? = nil) {
+ guard !restoring else { return }
+ restoring = true
+ applyPanel()
+ log("restore: \(sessions.isEmpty ? "all sessions" : sessions.joined(separator: ", "))")
+ DispatchQueue.global().async {
+ performRestore(sessions: sessions)
+ DispatchQueue.main.async {
+ self.restoring = false
+ self.refresh()
+ completion?()
+ }
+ }
+ }
+
+ /// "Not now": stop offering this saved layout.
+ @objc private func dismissRestore() {
+ let result = runReporting(dinggerBinary, ["restore", "--dismiss"])
+ log("restore: dismissed (\(result.ok ? "ok" : result.output))")
+ refresh()
+ }
+
+ @objc private func restoreFromMenu(_ sender: NSMenuItem) {
+ restore(sessions: (sender.representedObject as? String).map { [$0] } ?? [])
+ }
+
@objc private func selectPolicy(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String, let policy = PanelPolicy(rawValue: raw) else { return }
visibility.policy = policy
@@ -213,6 +263,37 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
rows = fetchRows()
+ restoreOffer = fetchRestoreOffer()
+ if let offer = restoreOffer {
+ let item = NSMenuItem(title: restoring ? "Restoring tmux Sessions…" : "↺ \(offer.title)",
+ action: nil, keyEquivalent: "")
+ if !restoring {
+ let submenu = NSMenu()
+ let all = NSMenuItem(title: "Restore All", action: #selector(restoreFromMenu(_:)), keyEquivalent: "")
+ all.target = self
+ submenu.addItem(all)
+ submenu.addItem(.separator())
+ for session in offer.sessions {
+ var detail = "\(session.windows) \(session.windows == 1 ? "window" : "windows")"
+ if session.agents > 0 { detail += ", \(session.agents) \(session.agents == 1 ? "agent" : "agents")" }
+ let one = NSMenuItem(title: session.name, action: #selector(restoreFromMenu(_:)), keyEquivalent: "")
+ one.subtitle = detail
+ one.target = self
+ one.representedObject = session.name
+ submenu.addItem(one)
+ }
+ submenu.addItem(.separator())
+ let later = NSMenuItem(title: "Not Now", action: #selector(dismissRestore), keyEquivalent: "")
+ later.target = self
+ submenu.addItem(later)
+ item.submenu = submenu
+ } else {
+ item.isEnabled = false
+ }
+ item.subtitle = offer.detail
+ menu.addItem(item)
+ menu.addItem(.separator())
+ }
if rows.isEmpty {
let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
@@ -321,9 +402,18 @@ if arguments.contains("--print") {
}
if arguments.contains("--print-panel") {
let full = arguments.contains("--full")
- print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle")).plainText)
+ print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle"),
+ offer: fetchRestoreOffer()).plainText)
exit(0)
}
+if let index = arguments.firstIndex(of: "--restore") {
+ exit(performRestore(sessions: Array(arguments[(index + 1)...])) ? 0 : 1)
+}
+if arguments.contains("--dismiss-offer") {
+ let result = runReporting(dinggerBinary, ["restore", "--dismiss"])
+ print(result.output)
+ exit(result.ok ? 0 : 1)
+}
if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < arguments.count {
_ = NSApplication.shared
let controller = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {})
@@ -332,7 +422,7 @@ if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < argumen
paneId: "%stale", agent: "pi", name: "STALE ROW FROM A PREVIOUS RENDER", status: .blocked,
session: "old", window: "old", since: 0)]))
let full = arguments.contains("--full")
- controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full))
+ controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full, offer: fetchRestoreOffer()))
exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1)
}
if let index = arguments.firstIndex(of: "--print-search") {
diff --git a/menubar/model.swift b/menubar/model.swift
index e891697..da5ad15 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -165,6 +165,58 @@ func tmux(_ args: [String]) -> String? {
let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+// MARK: restore offer
+
+/// tmux sessions the daemon offers to restore after tmux stopped
+/// (`offer.json`, written by `dingger watch`).
+struct RestoreOffer: Decodable, Equatable {
+ struct Session: Decodable, Equatable {
+ let name: String
+ let windows: Int
+ let panes: Int
+ let agents: Int
+ }
+ let saved_at: Int
+ let sessions: [Session]
+ let agents: Int
+ let interrupted: Int
+
+ /// "Restore 9 sessions · 22 agents"
+ var title: String {
+ var text = "Restore \(sessions.count) \(sessions.count == 1 ? "session" : "sessions")"
+ if agents > 0 { text += " · \(agents) \(agents == 1 ? "agent" : "agents")" }
+ return text
+ }
+
+ /// "saved 13:01 · 1 was mid-turn"
+ var detail: String {
+ let formatter = DateFormatter()
+ let saved = Date(timeIntervalSince1970: TimeInterval(saved_at) / 1000)
+ formatter.dateFormat = Calendar.current.isDateInToday(saved) ? "HH:mm" : "MMM d, HH:mm"
+ var parts = ["tmux stopped · saved \(formatter.string(from: saved))"]
+ if interrupted > 0 { parts.append("\(interrupted) \(interrupted == 1 ? "was" : "were") mid-turn") }
+ return parts.joined(separator: " · ")
+ }
+}
+
+let stateDirectory = environment["DINGGER_STATE_DIR"] ?? (NSHomeDirectory() + "/.local/state/dingger")
+
+func fetchRestoreOffer() -> RestoreOffer? {
+ guard let data = FileManager.default.contents(atPath: stateDirectory + "/offer.json") else { return nil }
+ return try? JSONDecoder().decode(RestoreOffer.self, from: data)
+}
+
+/// 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).
+func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) {
+ if environment["DINGGER_RESTORE_DIRECT"] != nil {
+ return runReporting(dinggerBinary, args)
+ }
+ let shell = getpwuid(getuid()).flatMap { String(validatingUTF8: $0.pointee.pw_shell) } ?? "/bin/zsh"
+ return runReporting(shell, ["--login", "-i", "-c", "exec \"$0\" \"$@\"", dinggerBinary] + args)
+}
+
private let home = NSHomeDirectory()
/// Every agent pane, in herdr's priority order, oldest waiting first.
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 003a061..6ab85f2 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -36,13 +36,15 @@ struct PanelVisibility: Equatable {
var enabled = true
var policy: PanelPolicy = .whenWaiting
var anyWaiting = false
+ /// tmux stopped and its sessions can be restored.
+ var restoreOffered = false
/// Turning the panel on with nothing to show shows it briefly, so the
/// shortcut visibly did something.
var peekUntil: Date?
func visible(at now: Date) -> Bool {
guard enabled else { return false }
- if policy == .always || anyWaiting { return true }
+ if policy == .always || anyWaiting || restoreOffered { return true }
return peekUntil.map { now < $0 } ?? false
}
@@ -84,7 +86,15 @@ struct PanelContent {
/// Whether the panel offers the compact/full toggle (shown always).
let canToggle: Bool
- init(rows all: [Row], full: Bool = false, canToggle: Bool = false) {
+ /// Sessions to restore, shown first.
+ let offer: RestoreOffer?
+ /// A restore is running.
+ let restoring: Bool
+
+ init(rows all: [Row], full: Bool = false, canToggle: Bool = false,
+ offer: RestoreOffer? = nil, restoring: Bool = false) {
+ self.offer = offer
+ self.restoring = restoring
let waiting = all.filter(\.waiting)
if full {
rows = all
@@ -127,14 +137,28 @@ struct PanelContent {
var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" }
+ /// The offer row's title: what restore will do, or that it runs.
+ var offerTitle: String? {
+ guard let offer else { return nil }
+ return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title
+ }
+
+ /// The summary line is noise when tmux is gone and only the offer shows.
+ var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 }
+
var plainText: String {
var lines: [String] = []
+ if let offer, let offerTitle {
+ lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]"))
+ }
for (index, row) in rows.enumerated() {
if index == separatorBefore { lines.append("---") }
lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
- lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
+ if showsSummary {
+ lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
+ }
return lines.joined(separator: "\n")
}
}
@@ -272,6 +296,43 @@ final class PanelRowView: ClickView {
}
}
+/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was.
+private final class OfferView: ClickView {
+ init(title: String, detail: String, onClick: @escaping () -> Void) {
+ super.init(frame: .zero)
+ self.onClick = onClick
+ let font = NSFont.menuFont(ofSize: 0)
+ let text = NSMutableAttributedString(string: "↺ ", attributes: [
+ .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue,
+ ])
+ text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText]))
+ let titleLabel = singleLine(NSTextField(labelWithAttributedString: text))
+ let detailLabel = NSTextField(labelWithString: detail)
+ detailLabel.font = .systemFont(ofSize: 11)
+ detailLabel.textColor = mochaSubtext
+ let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 1
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stack)
+ NSLayoutConstraint.activate([
+ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
+ stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
+ stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
+ ])
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard hovered else { return }
+ NSColor.white.withAlphaComponent(0.08).setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
+ }
+}
+
/// "Show all ▾" / "Show less ▴".
private final class ToggleView: ClickView {
private let label: NSTextField
@@ -302,11 +363,16 @@ final class PanelController {
private let stack = NSStackView()
private let onClick: (String) -> Void
private let onToggleFull: () -> Void
+ private let onRestore: () -> Void
+ private let onDismissRestore: () -> Void
private var rendered: String?
- init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) {
+ init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void,
+ onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) {
self.onClick = onClick
self.onToggleFull = onToggleFull
+ self.onRestore = onRestore
+ self.onDismissRestore = onDismissRestore
panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered, defer: false)
@@ -356,6 +422,10 @@ final class PanelController {
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
+ if let offer = content.offer, let title = content.offerTitle {
+ add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true)
+ if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) }
+ }
for (index, row) in content.rows.enumerated() {
if index == content.separatorBefore { add(separatorLine()) }
add(PanelRowView(row: row, onClick: onClick), fullWidth: true)
@@ -365,7 +435,7 @@ final class PanelController {
string: "+\(content.overflow) more waiting, see the menu bar",
attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext])))
}
- add(summaryLine(content), fullWidth: true)
+ if content.showsSummary { add(summaryLine(content), fullWidth: true) }
// Grow or shrink downward from the top edge.
stack.layoutSubtreeIfNeeded()
@@ -413,6 +483,34 @@ final class PanelController {
return box
}
+ /// The offer row, with "Not now" beside it (a row takes every click
+ /// inside it, so the link cannot be inside).
+ private func offerLine(title: String, detail: String, restoring: Bool) -> NSView {
+ let box = NSView()
+ let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore)
+ row.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(row)
+ var constraints = [
+ row.leadingAnchor.constraint(equalTo: box.leadingAnchor),
+ row.topAnchor.constraint(equalTo: box.topAnchor),
+ row.bottomAnchor.constraint(equalTo: box.bottomAnchor),
+ ]
+ if restoring {
+ constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor))
+ } else {
+ let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore)
+ dismiss.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(dismiss)
+ constraints += [
+ dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8),
+ dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor),
+ row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8),
+ ]
+ }
+ NSLayoutConstraint.activate(constraints)
+ return box
+ }
+
/// Summary on the left, the compact/full toggle on the right.
private func summaryLine(_ content: PanelContent) -> NSView {
let box = caption(content.summary)
@@ -511,6 +609,12 @@ func panelSelftest() -> [String] {
check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting),
"migration: when waiting")
+ var offered = PanelVisibility(enabled: true, policy: .whenWaiting)
+ offered.restoreOffered = true
+ check(offered.visible(at: t0), "a restore offer shows the panel")
+ offered.toggle(at: t0)
+ check(!offered.visible(at: at(10)), "off: a restore offer does not show it")
+
check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting")
check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen")
check(!panelShowsFull(policy: .always, prefersFull: false), "layout: compact in always by default")
@@ -529,5 +633,19 @@ func panelSelftest() -> [String] {
"full: no separator without waiting agents")
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+
+ let offer = RestoreOffer(saved_at: 0, sessions: [
+ .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0),
+ ], agents: 2, interrupted: 1)
+ let afterReboot = PanelContent(rows: [], offer: offer)
+ check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t")
+ && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"),
+ "offer: first, with what it brings back and a way to dismiss it")
+ check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone")
+ let running = PanelContent(rows: [], offer: offer, restoring: true)
+ check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"),
+ "offer: while restoring, no dismiss")
+ check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"),
+ "offer: the summary stays when there are agents")
return failures
}
diff --git a/packaging/tmux/dingger.tmux b/packaging/tmux/dingger.tmux
index 64c5bff..f1529f1 100644
--- a/packaging/tmux/dingger.tmux
+++ b/packaging/tmux/dingger.tmux
@@ -4,9 +4,15 @@
# of the menu bar: blocked first, then the longest-done (FIFO), skipping the
# window you are already looking at.
#
+# After tmux restarts, `↺N` (blue) offers to restore N saved sessions:
+# clicking it runs `dingger restore`; a notification summarizes, the full
+# report is in ~/.local/state/dingger/restore.log.
+#
# Source this from your tmux config: source-file /path/to/dingger.tmux
-set -g status-right '%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}'
-bind -n MouseDown1StatusRight run-shell "$HOME/.local/bin/dingger focus --client '#{client_name}'"
+set -g status-right '#{?@dingger_restore,#[range=user|restore]#[fg=#89b4fa#,bold]↺#{@dingger_restore}#[norange default] ,}%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}'
+bind -n MouseDown1StatusRight if -F '#{==:#{mouse_status_range},restore}' \
+ 'run-shell -b "$HOME/.local/bin/dingger restore --report"' \
+ "run-shell \"$HOME/.local/bin/dingger focus --client '#{client_name}'\""
# Tell dingger the moment you look somewhere else (it marks what you see as
# attended). Index 77 keeps your own hooks and makes reloading idempotent.
set-hook -g 'client-session-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"'
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 29d900b..b79c748 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -37,7 +37,7 @@ cleanup() {
[ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
[ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null
t kill-server 2>/dev/null
- tmux -L "$SOCK-restore" kill-server 2>/dev/null
+ tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null
rm -rf "$DIR"
}
trap cleanup EXIT
@@ -226,6 +226,11 @@ t source-file "$ROOT/packaging/tmux/dingger.tmux"
segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac
expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1
+expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1
+t set -g @dingger_restore 3
+segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
+case "$segment" in "↺3 "*"×1"*) pass "segment offers to restore 3 sessions ($segment)";; *) fail "restore segment: [$segment]";; esac
+t set -gu @dingger_restore
# same run-shell + #{client_name} expansion as the mouse binding, via a key
t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'"
t send-keys -t viewer: F12
@@ -351,7 +356,7 @@ fi
# A second server and daemon. "viewer" hosts a client the way a terminal
# does: Ghostty runs `tmux new-session -As 0`.
echo "16. restore"
-R="$SOCK-restore"
+R="$SOCK-restore-$$"
STATE2="$DIR/state2"
# tmux gives new windows the environment of the client that creates them:
# both the test and restore run with the fake pi first on PATH.
@@ -410,7 +415,10 @@ fingerprint() {
| grep -E '^(0|work)\|'
}
BEFORE=$(fingerprint)
-renv "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
+# Started directly, not through a function: $! must be the daemon itself.
+env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
+ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null \
+ "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80
@@ -452,7 +460,16 @@ expect "the candidate survives the new server's snapshots" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80
expect "dry run plans: replace 0, keep keep and viewer, create work" \
"renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;"
-renv "$DINGGER" restore >"$DIR/restore.out" 2>&1
+mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \
+ DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; }
+expect "the daemon offers to restore 0 and work" \
+ "python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \
+ "['0', 'work'] 2 1" 80
+expect "and tells the status line" "r show -gv @dingger_restore" 2
+expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]"
+expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn"
+mbr --restore
+cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null
expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0
expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl
--session $W/sessions/gone.jsonl"
@@ -463,6 +480,8 @@ expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $D
typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; }
expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'"
expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat"
+expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run"
+expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0"
expect "neither runs: the shell waits for Enter" \
"r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash
bash"
@@ -480,6 +499,30 @@ expect "restoring again changes nothing" "fingerprint" "$BEFORE"
expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0
[ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; }
+# Again, now with no tmux running at all (after a reboot, before a terminal
+# opens): restoring starts the server. Then "Not now" stays dismissed.
+names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; }
+WORK_BEFORE=$(names work)
+r kill-server
+expect "a new offer: all four sessions, tmux is not running" \
+ "python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80
+mbr --restore work
+expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE"
+expect "the offer shrinks to what is still missing" \
+ "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \
+ "['0', 'keep', 'viewer']" 80
+mbr --dismiss-offer >/dev/null
+expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
+expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" ""
+start_viewer
+sleep 2
+expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0
+expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1
+
+# 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
+
echo
echo "$PASSES passed, $FAILS failed"
[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }