diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 13:59:59 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 13:59:59 +0300 |
| commit | 9c9c9dad93b16781ec2fc40bd7582db62240171c (patch) | |
| tree | cf85f3a53f8441eae36982d06244041cfe0ff6f3 | |
| parent | c082f5044eedd2294d72f920d1018e40d73aab33 (diff) | |
restore, step 3: offer it in the panel, menu, and status line
when tmux stops (and its sessions are missing from the running server, or
no server runs), the daemon publishes the restore offer: offer.json for the
menu bar app and @dingger_restore for the status line, withdrawn once
restored or dismissed.
- panel: a first row "↺ Restore 9 sessions · 22 agents" with when it was
saved and how many agents were mid-turn; clicking restores, "Not now"
dismisses. an offer shows the panel like a waiting agent; off stays off.
- menu: the offer on top; restore all, one session, or not now.
- status line: a clickable blue ↺N before the clock.
- restore runs through the user's login shell, as a terminal starts tmux,
so restored panes get a real environment, not launchd's; --report writes
restore.log and notifies a summary. restore --dismiss.
- commands log only warnings by default; SIGPIPE ends them quietly.
e2e: the offer through the real menu bar binary, the summary notification,
the offer clearing, a restore with no tmux running, and not now staying
dismissed when a terminal starts tmux. the second test daemon is started
directly (it leaked through a function's $! before, and dozens of leaked
daemons fought over the test socket and loaded the machine); each run uses
its own socket and checks its daemons stop.
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | ROADMAP.md | 6 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 25 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 13 | ||||
| -rw-r--r-- | crates/dinggerd/src/platform.rs | 19 | ||||
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 243 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 3 | ||||
| -rw-r--r-- | menubar/main.swift | 98 | ||||
| -rw-r--r-- | menubar/model.swift | 52 | ||||
| -rw-r--r-- | menubar/panel.swift | 128 | ||||
| -rw-r--r-- | packaging/tmux/dingger.tmux | 10 | ||||
| -rwxr-xr-x | tests/e2e.sh | 51 |
12 files changed, 610 insertions, 43 deletions
@@ -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 @@ -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; } |
