aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 13:20:11 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 13:20:11 +0300
commitc082f5044eedd2294d72f920d1018e40d73aab33 (patch)
tree54420e9c0fcb366e8db776c9463c4530737704d7 /crates
parent0f06b0b15d6b064703fa1166fb8e7ceda209aaa8 (diff)
restore, step 2: resume agents, retype command lines
- the pi extension reports its session file; the daemon keeps it in the pane option @agent_session, and snapshots carry it. restore runs `pi --session <file>` in the rebuilt pane, one agent every 0.5s, then reports which agents run and which were interrupted mid-turn. - other panes: the command line running in them is typed at the new prompt but not run. exactly as typed with the zsh hook (dingger install zsh: preexec/precmd keep @dingger_cmd), else the foreground program's exact argv from the kernel (KERN_PROCARGS2), shell-quoted. - "at a prompt" is the pane's own shell owning the terminal's foreground process group, not a name check: `sh -c ...` is a running program. - an agent or command that exits keeps its place in snapshots for 60s, so a shutdown that stops programs before tmux still restores them. - typing waits for each new prompt (text typed earlier echoes twice). e2e step 16 covers resume with a session path containing a space, the interrupted report, an agent that exits just before the server dies, a quoted command retyped not run, and the hook's text; dropping the grace period, judging prompts by name, running retyped commands, or not storing the session reference each fail it. the test's fake pi is guarded so a real pi never starts.
Diffstat (limited to 'crates')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts16
-rw-r--r--crates/dinggerd/src/hooks.rs18
-rw-r--r--crates/dinggerd/src/integrations.rs41
-rw-r--r--crates/dinggerd/src/main.rs6
-rw-r--r--crates/dinggerd/src/platform.rs68
-rw-r--r--crates/dinggerd/src/restore.rs193
-rw-r--r--crates/dinggerd/src/snapshot.rs172
-rw-r--r--crates/dinggerd/src/tmux.rs7
-rw-r--r--crates/dinggerd/src/watcher.rs11
9 files changed, 498 insertions, 34 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts
index df9e4e2..ff893e7 100644
--- a/crates/dinggerd/assets/pi/pi-agent-state.ts
+++ b/crates/dinggerd/assets/pi/pi-agent-state.ts
@@ -56,6 +56,19 @@ async function sendRequest(request: unknown): Promise<void> {
type AgentState = "working" | "blocked" | "idle";
+// The session file, so `dingger restore` can resume this session with
+// `pi --session <file>`. It changes with /new, /resume, and forks.
+let sessionFile: string | undefined;
+
+function updateSessionFile(ctx: any): void {
+ try {
+ const file = ctx?.sessionManager?.getSessionFile?.();
+ sessionFile = typeof file === "string" && file.length > 0 ? file : undefined;
+ } catch {
+ sessionFile = undefined;
+ }
+}
+
function report(state: AgentState, message?: string): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
@@ -67,6 +80,7 @@ function report(state: AgentState, message?: string): Promise<void> {
state,
message,
seq: Date.now() * 1000,
+ ...(sessionFile ? { agent_session_path: sessionFile } : {}),
},
});
}
@@ -154,11 +168,13 @@ export default function (pi) {
}
// A reload can replace this extension mid-run without emitting another agent_start.
agentActive = ctx?.isIdle?.() === false;
+ updateSessionFile(ctx);
publishState(true);
});
pi.on("agent_start", (_event, ctx) => {
agentActive = true;
+ updateSessionFile(ctx);
publishState();
});
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index 46077cf..191309a 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -6,6 +6,9 @@
//! {"method":"pane.report_agent","params":{"pane_id":"%3","agent":"pi","state":"working"}}
//! ```
//!
+//! A report may carry the agent's session reference (`agent_session_path`
+//! or `agent_session_id`, herdr's names), which restore uses to resume it.
+//!
//! Reports are state-change *events*: the watcher applies every one in
//! order, so a turn that starts and finishes between two polls is still a
//! completion, and each report wakes the watcher immediately.
@@ -40,6 +43,8 @@ struct PaneReports {
last: Option<AgentState>,
/// Reports not yet applied by the watcher, oldest first.
pending: Vec<AgentState>,
+ /// The agent's latest session reference.
+ session: Option<String>,
}
fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> {
@@ -92,6 +97,10 @@ struct HookMessage {
struct HookParams {
pane_id: String,
state: String,
+ #[serde(default)]
+ agent_session_path: Option<String>,
+ #[serde(default)]
+ agent_session_id: Option<String>,
}
fn handle_stream(stream: UnixStream) {
@@ -123,6 +132,10 @@ fn handle_stream(stream: UnixStream) {
let pane = reports.entry(params.pane_id).or_default();
pane.last = Some(state);
pane.pending.push(state);
+ let session = params.agent_session_path.or(params.agent_session_id);
+ if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) {
+ pane.session = Some(session);
+ }
}
wake();
}
@@ -168,6 +181,11 @@ pub fn last_state(pane_id: &str) -> Option<AgentState> {
reports().get(pane_id).and_then(|pane| pane.last)
}
+/// The pane's agent session reference, if its extension reported one.
+pub fn session(pane_id: &str) -> Option<String> {
+ reports().get(pane_id).and_then(|pane| pane.session.clone())
+}
+
/// Drop a pane's reports (the agent exited).
pub fn drop_reports(pane_id: &str) {
reports().remove(pane_id);
diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs
index a6378d1..d7cbd34 100644
--- a/crates/dinggerd/src/integrations.rs
+++ b/crates/dinggerd/src/integrations.rs
@@ -18,8 +18,47 @@ fn pi_extension_dir() -> Result<PathBuf, String> {
Ok(base.join("extensions"))
}
-/// Install the integration for one agent. Only pi has an integration today.
+/// zsh hook: records the command line running in each tmux pane exactly as
+/// typed (aliases, pipes, quoting), for restore to retype.
+const ZSH_HOOK: &str = r#"# installed by dingger; `dingger install zsh` overwrites this file.
+#
+# Records the command line running in this tmux pane, as typed, in the pane
+# option @dingger_cmd, so `dingger restore` can type it again (without
+# running it) after a crash or shutdown.
+if [[ -n $TMUX_PANE ]]; then
+ _dingger_preexec() { command tmux set-option -p -t "$TMUX_PANE" @dingger_cmd "$1" 2>/dev/null &! }
+ _dingger_precmd() { command tmux set-option -p -u -t "$TMUX_PANE" @dingger_cmd 2>/dev/null &! }
+ autoload -Uz add-zsh-hook
+ add-zsh-hook preexec _dingger_preexec
+ add-zsh-hook precmd _dingger_precmd
+fi
+"#;
+
+fn config_dir() -> Result<PathBuf, String> {
+ std::env::var_os("DINGGER_CONFIG_DIR")
+ .map(PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("dingger")))
+ .ok_or_else(|| "cannot determine the config dir (no HOME)".to_string())
+}
+
+fn install_zsh() -> Result<(), String> {
+ let dir = config_dir()?;
+ std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?;
+ let path = dir.join("dingger.zsh");
+ std::fs::write(&path, ZSH_HOOK).map_err(|err| format!("write failed: {err}"))?;
+ println!(
+ "installed {}\nadd this line to your .zshrc (dingger does not edit it):\n\n [[ -r {0} ]] && source {0}\n",
+ path.display()
+ );
+ Ok(())
+}
+
+/// Install an integration: an agent's state hooks, or `zsh` for retyping
+/// command lines on restore.
pub fn install(agent: &str) -> Result<(), String> {
+ if agent == "zsh" {
+ return install_zsh();
+ }
let parsed = detect_core::parse_agent_label(agent)
.ok_or_else(|| format!("unknown agent: {agent}"))?;
match parsed {
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 2129bd2..7cf3513 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -5,6 +5,7 @@ mod integrations;
mod manifest_update;
mod names;
mod notify;
+mod platform;
mod restore;
mod snapshot;
mod tmux;
@@ -72,9 +73,10 @@ enum Command {
#[arg(long = "session")]
sessions: Vec<String>,
},
- /// Install agent integrations (hook extensions that report state).
+ /// Install integrations: an agent's hook extension (state and session
+ /// reports), or `zsh` (command lines for restore to retype).
Install {
- /// Agent to install the integration for.
+ /// `pi`, or `zsh`.
agent: String,
},
}
diff --git a/crates/dinggerd/src/platform.rs b/crates/dinggerd/src/platform.rs
new file mode 100644
index 0000000..8504a83
--- /dev/null
+++ b/crates/dinggerd/src/platform.rs
@@ -0,0 +1,68 @@
+//! macOS process inspection.
+
+/// A process's exact argument vector, from the kernel (`KERN_PROCARGS2`):
+/// unlike `ps`, it keeps arguments that contain spaces intact.
+#[cfg(target_os = "macos")]
+pub fn process_argv(pid: u32) -> Option<Vec<String>> {
+ use std::os::raw::{c_int, c_uint, c_void};
+ extern "C" {
+ fn sysctl(
+ name: *mut c_int,
+ namelen: c_uint,
+ oldp: *mut c_void,
+ oldlenp: *mut usize,
+ newp: *mut c_void,
+ newlen: usize,
+ ) -> c_int;
+ }
+ const CTL_KERN: c_int = 1;
+ const KERN_PROCARGS2: c_int = 49;
+ let mut mib = [CTL_KERN, KERN_PROCARGS2, pid as c_int];
+ let mut buffer = vec![0u8; 256 * 1024];
+ let mut size = buffer.len();
+ // SAFETY: mib has 3 entries; buffer is writable for `size` bytes, and the
+ // kernel writes at most that many, updating `size`.
+ let status = unsafe {
+ sysctl(mib.as_mut_ptr(), 3, buffer.as_mut_ptr().cast(), &mut size, std::ptr::null_mut(), 0)
+ };
+ if status != 0 || size < 4 {
+ return None;
+ }
+ let data = &buffer[..size];
+ let argc = i32::from_ne_bytes(data[..4].try_into().ok()?).max(0) as usize;
+ // Layout: argc, the executable path, NUL padding, then argv strings.
+ let rest = &data[4..];
+ let after_path = rest.iter().position(|&b| b == 0)?;
+ let args_start = after_path + rest[after_path..].iter().position(|&b| b != 0)?;
+ let argv: Vec<String> = rest[args_start..]
+ .split(|&b| b == 0)
+ .take(argc)
+ .map(|arg| String::from_utf8_lossy(arg).into_owned())
+ .collect();
+ (argv.len() == argc).then_some(argv)
+}
+
+#[cfg(not(target_os = "macos"))]
+pub fn process_argv(_pid: u32) -> Option<Vec<String>> {
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn reads_a_processs_exact_arguments() {
+ // A compound command keeps sh alive (it would exec a lone command).
+ let mut child = std::process::Command::new("/bin/sh")
+ .args(["-c", "sleep 30; :", "two words"])
+ .spawn()
+ .unwrap_or_else(|err| panic!("spawn: {err}"));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ let argv = super::process_argv(child.id());
+ let _ = child.kill();
+ let _ = child.wait();
+ assert_eq!(
+ argv,
+ Some(vec!["/bin/sh".into(), "-c".into(), "sleep 30; :".into(), "two words".into()])
+ );
+ }
+}
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs
index 6ed19a7..2332865 100644
--- a/crates/dinggerd/src/restore.rs
+++ b/crates/dinggerd/src/restore.rs
@@ -8,10 +8,51 @@
//! Each session is rebuilt with its windows (index, name, automatic-rename),
//! panes (directory, in order), exact layout, zoom, and active window and
//! pane. Everything is addressed by tmux id, so names with `.` or `:` work.
+//!
+//! Then, once each new shell shows its prompt: agents with a session
+//! reference are resumed (`pi --session <file>`, run), and every other
+//! saved command line is typed but not run, for the user to press Enter.
+
+use std::time::{Duration, Instant};
-use crate::snapshot::{self, SessionSnap, Snapshot, WindowSnap};
+use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap};
use crate::tmux;
+/// What restore types into a rebuilt pane.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Typed {
+ /// Resume the agent's session: typed and run.
+ Resume(String),
+ /// The saved command line: typed, not run.
+ Retype(String),
+}
+
+/// The command that resumes an agent's session.
+fn resume_command(agent: &str, session: &str) -> Option<String> {
+ match agent {
+ "pi" => Some(format!("pi --session {}", shell_quote(session))),
+ _ => None,
+ }
+}
+
+pub fn typed_for(pane: &PaneSnap) -> Option<Typed> {
+ let typeable = |text: &String| !text.trim().is_empty() && !text.chars().any(char::is_control);
+ if let Some(agent) = &pane.agent {
+ let resume = agent.session.as_deref().and_then(|session| resume_command(&agent.agent, session));
+ if let Some(command) = resume.filter(typeable) {
+ return Some(Typed::Resume(command));
+ }
+ }
+ pane.command.clone().filter(typeable).map(Typed::Retype)
+}
+
+/// A rebuilt pane and what it held.
+struct Rebuilt {
+ pane_id: String,
+ label: String,
+ pane: PaneSnap,
+}
+
/// What restore does with a saved session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Plan {
@@ -59,12 +100,7 @@ fn saved_bare(session: &SessionSnap) -> bool {
matches!(&session.windows[..], [window] if matches!(&window.panes[..], [pane] if pane.agent.is_none()))
}
-fn is_shell(command: &str) -> bool {
- matches!(
- command.trim_start_matches('-'),
- "zsh" | "bash" | "fish" | "sh" | "dash" | "ksh" | "tcsh" | "csh" | "nu" | "elvish" | "xonsh"
- )
-}
+use snapshot::is_shell;
/// The running server's sessions; empty when tmux is not running.
pub fn existing_sessions() -> Vec<Existing> {
@@ -95,7 +131,13 @@ fn first_line(out: String) -> String {
}
/// Build one window's panes, layout, and options. `pane` is its first pane.
-fn build_window(window_id: &str, first_pane: String, window: &WindowSnap) -> Result<(), String> {
+fn build_window(
+ window_id: &str,
+ first_pane: String,
+ window: &WindowSnap,
+ session: &str,
+ rebuilt: &mut Vec<Rebuilt>,
+) -> Result<(), String> {
let width = window.width.max(10).to_string();
let height = window.height.max(5).to_string();
// The saved size, so the layout applies exactly; unset afterwards so the
@@ -129,11 +171,19 @@ fn build_window(window_id: &str, first_pane: String, window: &WindowSnap) -> Res
}
}
}
+ for (pane_id, pane) in panes.into_iter().zip(&window.panes) {
+ let label = if window.panes.len() > 1 {
+ format!("{session}:{}.{}", window.name, pane.index)
+ } else {
+ format!("{session}:{}", window.name)
+ };
+ rebuilt.push(Rebuilt { pane_id, label, pane: pane.clone() });
+ }
Ok(())
}
/// Create `session` under `name`; returns its session id.
-fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> {
+fn build_session(session: &SessionSnap, name: &str, rebuilt: &mut Vec<Rebuilt>) -> Result<String, String> {
let Some(first) = session.windows.first() else {
return Err(format!("{}: no windows saved", session.name));
};
@@ -151,7 +201,7 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> {
if first_index != first.index.to_string() {
tmux::run(&["move-window", "-s", first_window, "-t", &format!("{session_id}:{}", first.index)])?;
}
- build_window(first_window, first_pane.to_string(), first)?;
+ build_window(first_window, first_pane.to_string(), first, &session.name, rebuilt)?;
for window in session.windows.iter().skip(1) {
let cwd = window.panes.first().map(|pane| pane.cwd.as_str()).unwrap_or("~");
let created = first_line(tmux::run(&[
@@ -161,7 +211,7 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> {
let Some((window_id, pane_id)) = created.split_once('\t') else {
return Err(format!("unexpected new-window output: {created}"));
};
- build_window(window_id, pane_id.to_string(), window)?;
+ build_window(window_id, pane_id.to_string(), window, &session.name, rebuilt)?;
}
if let Some(active) = session.windows.iter().find(|window| window.active) {
tmux::run(&["select-window", "-t", &format!("{session_id}:{}", active.index)])?;
@@ -171,9 +221,9 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> {
/// Replace a bare session: build the saved one beside it, move its clients
/// over, then remove it and take its name.
-fn replace_session(session: &SessionSnap) -> Result<(), String> {
+fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result<(), String> {
let temporary = format!("{}-restoring-{}", session.name, std::process::id());
- let new_id = build_session(session, &temporary)?;
+ let new_id = build_session(session, &temporary, rebuilt)?;
let clients = tmux::run(&["list-clients", "-F", "#{client_name}\t#{session_name}"]).unwrap_or_default();
for line in clients.lines() {
if let Some((client, attached)) = line.split_once('\t') {
@@ -186,6 +236,73 @@ fn replace_session(session: &SessionSnap) -> Result<(), String> {
tmux::run(&["rename-session", "-t", &new_id, &session.name]).map(|_| ())
}
+/// 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]) {
+ let work: Vec<(&Rebuilt, Typed)> =
+ rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect();
+ if work.is_empty() {
+ return;
+ }
+ // Text typed before the prompt is drawn is echoed twice; wait for it
+ // (the cursor leaves the top-left corner).
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ let cursors = tmux::run(&["list-panes", "-a", "-F", "#{pane_id} #{cursor_x} #{cursor_y}"]).unwrap_or_default();
+ let waiting = work.iter().any(|(r, _)| {
+ cursors.lines().any(|line| line == format!("{} 0 0", r.pane_id))
+ });
+ if !waiting || Instant::now() > deadline {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ std::thread::sleep(Duration::from_millis(200));
+
+ let mut resumed = Vec::new();
+ for (r, typed) in &work {
+ match typed {
+ 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);
+ 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);
+ }
+ }
+ }
+ 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);
+ }
+ }
+ if resumed.is_empty() {
+ return;
+ }
+ // An agent that started has replaced the shell in the foreground.
+ let deadline = Instant::now() + Duration::from_secs(30);
+ let mut not_running: Vec<&Rebuilt> = resumed.clone();
+ while !not_running.is_empty() && Instant::now() < deadline {
+ std::thread::sleep(Duration::from_millis(500));
+ not_running.retain(|r| {
+ tmux::run(&["display-message", "-p", "-t", &r.pane_id, "#{pane_current_command}"])
+ .map(|command| is_shell(command.trim()))
+ .unwrap_or(true)
+ });
+ }
+ println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len());
+ for r in not_running {
+ println!("not running {}: check the pane for the error", r.label);
+ }
+}
+
fn windows_and_panes(session: &SessionSnap) -> String {
let panes: usize = session.windows.iter().map(|window| window.panes.len()).sum();
format!("{} windows, {panes} panes", session.windows.len())
@@ -236,6 +353,7 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
let snapshot = source(from)?;
let existing = existing_sessions();
let mut failures = 0;
+ let mut rebuilt = Vec::new();
for (name, plan) in plan(&snapshot, &existing) {
if !only.is_empty() && !only.contains(&name) {
continue;
@@ -254,11 +372,20 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
};
if dry_run {
println!("{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),
+ None => {}
+ }
+ }
+ }
continue;
}
let result = match plan {
- Plan::Replace => replace_session(session),
- _ => build_session(session, &name).map(|_| ()),
+ Plan::Replace => replace_session(session, &mut rebuilt),
+ _ => build_session(session, &name, &mut rebuilt).map(|_| ()),
};
match result {
Ok(()) => println!("{verb:<8} {name}: {what}"),
@@ -268,6 +395,9 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
}
}
}
+ if !rebuilt.is_empty() {
+ type_commands(&rebuilt);
+ }
if dry_run || from.is_some() {
return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) };
}
@@ -280,7 +410,7 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
#[cfg(test)]
mod tests {
use super::*;
- use crate::snapshot::{PaneSnap, ServerId, SessionSnap};
+ use crate::snapshot::{AgentSnap, PaneSnap, ServerId, SessionSnap};
fn window(panes: usize) -> WindowSnap {
WindowSnap {
@@ -293,7 +423,14 @@ mod tests {
zoomed: false,
automatic_rename: false,
panes: (0..panes)
- .map(|i| PaneSnap { index: i as u32, cwd: "/tmp".into(), active: i == 0, agent: None })
+ .map(|i| PaneSnap {
+ id: String::new(),
+ index: i as u32,
+ cwd: "/tmp".into(),
+ active: i == 0,
+ agent: None,
+ command: None,
+ })
.collect(),
}
}
@@ -335,6 +472,28 @@ mod tests {
}
#[test]
+ fn resumes_agents_by_session_and_retypes_the_rest() {
+ let mut pane = window(1).panes.remove(0);
+ assert_eq!(typed_for(&pane), None, "a shell prompt types nothing");
+ pane.command = Some("nvim 'my file.sh'".into());
+ assert_eq!(typed_for(&pane), Some(Typed::Retype("nvim 'my file.sh'".into())));
+ pane.command = Some("echo one\necho two".into());
+ assert_eq!(typed_for(&pane), None, "a newline would run it");
+ pane.agent = Some(AgentSnap {
+ agent: "pi".into(),
+ name: "x".into(),
+ state: "idle".into(),
+ session: Some("/s/a b.jsonl".into()),
+ });
+ assert_eq!(typed_for(&pane), Some(Typed::Resume("pi --session '/s/a b.jsonl'".into())));
+ if let Some(agent) = pane.agent.as_mut() {
+ agent.session = None;
+ }
+ pane.command = Some("pi --model x".into());
+ assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session: retyped");
+ }
+
+ #[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/snapshot.rs b/crates/dinggerd/src/snapshot.rs
index db5ba35..07316e8 100644
--- a/crates/dinggerd/src/snapshot.rs
+++ b/crates/dinggerd/src/snapshot.rs
@@ -9,6 +9,11 @@
//! last [`HISTORY`] files are kept.
//! - Nothing is saved while tmux is not running or has no sessions, so a
//! dying server cannot overwrite the last good snapshot.
+//! - Agents carry their session reference (reported by their extension),
+//! other panes the command line running in them: the zsh hook's text
+//! (`@dingger_cmd`), else the foreground program's arguments. When an
+//! agent or program exits, the snapshot keeps it for [`GRACE_MS`], so a
+//! shutdown that stops programs before tmux still restores them.
//! - `restore.json`: when a different tmux server is running (or none), the
//! previous server's last snapshot becomes the restore candidate, until it
//! is restored or dismissed. It is pinned before the new server's own
@@ -24,6 +29,9 @@ use crate::tmux;
/// Snapshot files kept.
const HISTORY: usize = 50;
+/// How long a pane's agent or command outlives its exit in snapshots.
+const GRACE_MS: u64 = 60_000;
+
pub fn state_dir() -> PathBuf {
std::env::var_os("DINGGER_STATE_DIR")
.map(PathBuf::from)
@@ -80,12 +88,19 @@ pub struct WindowSnap {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSnap {
+ /// tmux pane id on the saved server.
+ #[serde(default)]
+ pub id: String,
pub index: u32,
pub cwd: String,
pub active: bool,
/// The agent running in the pane, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentSnap>,
+ /// The command line running in the pane (not for agents with a
+ /// session): retyped on restore, not run.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -97,6 +112,9 @@ pub struct AgentSnap {
/// Display status when saved (`working` means a turn was interrupted).
#[serde(default)]
pub state: String,
+ /// The agent's own session reference (pi: its session file).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub session: Option<String>,
}
/// One window's layout: session, index, name, layout, and pane directories.
@@ -173,15 +191,29 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> {
"#{@agent}",
"#{@agent_name}",
"#{@agent_state}",
+ "#{pane_id}",
+ "#{pane_pid}",
+ "#{pane_current_command}",
+ "#{@agent_session}",
+ "#{@dingger_cmd}",
+ "#{pane_dead}",
]
.join("\t");
let out = tmux::run(&["list-panes", "-a", "-F", &format]).ok()?;
+ let lines: Vec<Vec<&str>> = out
+ .lines()
+ .map(|line| line.split('\t').collect::<Vec<_>>())
+ .filter(|f| f.len() >= 21)
+ .collect();
+ // What runs in the foreground of each pane's terminal.
+ let pane_pids: Vec<u32> = lines
+ .iter()
+ .filter(|f| f[20] != "1")
+ .filter_map(|f| f[16].parse().ok())
+ .collect();
+ let foreground = foreground_groups(&pane_pids);
let mut sessions: Vec<SessionSnap> = Vec::new();
- for line in out.lines() {
- let f: Vec<&str> = line.split('\t').collect();
- if f.len() < 15 {
- continue;
- }
+ for f in lines {
let number = |s: &str| s.parse::<u32>().unwrap_or(0);
if sessions.last().map(|s| s.name != f[0]).unwrap_or(true) {
sessions.push(SessionSnap { name: f[0].to_string(), windows: Vec::new() });
@@ -202,20 +234,82 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> {
});
}
let window = session.windows.last_mut()?;
+ let agent = (!f[12].is_empty()).then(|| AgentSnap {
+ agent: f[12].to_string(),
+ name: f[13].to_string(),
+ state: f[14].to_string(),
+ session: (!f[18].is_empty()).then(|| f[18].to_string()),
+ });
+ let hook_command = (!f[19].is_empty()).then(|| f[19].to_string());
+ let pane_pid: Option<u32> = f[16].parse().ok();
+ let leader = pane_pid.and_then(|pid| foreground.get(&pid).copied());
+ // At a prompt: the pane's own shell is in the foreground. Anything
+ // else is a running program, whatever its name.
+ let at_prompt = leader.is_some() && leader == pane_pid && is_shell(f[17]);
+ let command = if f[20] == "1" || leader.is_none() || at_prompt {
+ None // the hook's text may be stale at a prompt
+ } else if let Some(agent) = &agent {
+ // Resumed by session; otherwise retyped as it was started.
+ agent.session.is_none().then(|| hook_command.unwrap_or_else(|| agent.agent.clone()))
+ } else {
+ hook_command.or_else(|| leader.and_then(command_line))
+ };
window.panes.push(PaneSnap {
+ id: f[15].to_string(),
index: number(f[9]),
cwd: f[11].to_string(),
active: f[10] == "1",
- agent: (!f[12].is_empty()).then(|| AgentSnap {
- agent: f[12].to_string(),
- name: f[13].to_string(),
- state: f[14].to_string(),
- }),
+ agent,
+ command,
});
}
(!sessions.is_empty()).then_some(Snapshot { version: 1, saved_at: now_ms, server, sessions })
}
+pub fn is_shell(command: &str) -> bool {
+ matches!(
+ command.trim_start_matches('-'),
+ "zsh" | "bash" | "fish" | "sh" | "dash" | "ksh" | "tcsh" | "csh" | "nu" | "elvish" | "xonsh"
+ )
+}
+
+/// Quote for a POSIX shell, only when needed.
+pub fn shell_quote(word: &str) -> String {
+ let plain = !word.is_empty()
+ && word.chars().all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-~".contains(c));
+ if plain {
+ word.to_string()
+ } else {
+ format!("'{}'", word.replace('\'', "'\\''"))
+ }
+}
+
+/// For each pane process: the leader of its terminal's foreground process
+/// group (itself, for a shell at its prompt). One `ps` call.
+fn foreground_groups(pane_pids: &[u32]) -> std::collections::HashMap<u32, u32> {
+ let mut groups = std::collections::HashMap::new();
+ if pane_pids.is_empty() {
+ return groups;
+ }
+ let list = pane_pids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
+ let Ok(out) = std::process::Command::new("ps").args(["-o", "pid=,tpgid=", "-p", &list]).output() else {
+ return groups;
+ };
+ for line in String::from_utf8_lossy(&out.stdout).lines() {
+ let mut fields = line.split_whitespace().filter_map(|f| f.parse::<i64>().ok());
+ if let (Some(pid), Some(group)) = (fields.next(), fields.next()) {
+ groups.insert(pid as u32, if group > 0 { group as u32 } else { pid as u32 });
+ }
+ }
+ groups
+}
+
+/// A process's command line, quoted for retyping.
+fn command_line(pid: u32) -> Option<String> {
+ let argv = crate::platform::process_argv(pid).filter(|argv| !argv.is_empty())?;
+ Some(argv.iter().map(|word| shell_quote(word)).collect::<Vec<_>>().join(" "))
+}
+
fn write_atomic(path: &Path, value: &impl Serialize) -> Result<(), String> {
let json = serde_json::to_string_pretty(value).map_err(|err| err.to_string())?;
if let Some(parent) = path.parent() {
@@ -304,6 +398,8 @@ pub fn should_pin(latest: &Snapshot, running: Option<&ServerId>, current: Option
pub struct Recorder {
latest: Option<(PathBuf, Snapshot)>,
loaded: bool,
+ /// Per pane id: its last agent or command, and when it was last seen.
+ recent: std::collections::HashMap<String, (Option<AgentSnap>, Option<String>, u64)>,
}
impl Recorder {
@@ -324,9 +420,10 @@ impl Recorder {
}
}
}
- let Some(fresh) = running.and_then(|_| capture(now_ms)) else {
+ let Some(mut fresh) = running.and_then(|_| capture(now_ms)) else {
return;
};
+ self.remember_recent(&mut fresh, now_ms);
let action = save_action(self.latest.as_ref().map(|(_, s)| s), &fresh);
let path = match (&action, &self.latest) {
(SaveAction::Nothing, _) => return,
@@ -348,10 +445,54 @@ impl Recorder {
}
}
+impl Recorder {
+ /// Keep a pane's agent or command for [`GRACE_MS`] after it exits.
+ fn remember_recent(&mut self, snapshot: &mut Snapshot, now_ms: u64) {
+ let mut seen = std::collections::HashSet::new();
+ for pane in snapshot.sessions.iter_mut().flat_map(|s| &mut s.windows).flat_map(|w| &mut w.panes) {
+ seen.insert(pane.id.clone());
+ if pane.agent.is_some() || pane.command.is_some() {
+ self.recent.insert(pane.id.clone(), (pane.agent.clone(), pane.command.clone(), now_ms));
+ } else if let Some((agent, command, at)) = self.recent.get(&pane.id) {
+ if now_ms.saturating_sub(*at) < GRACE_MS {
+ pane.agent = agent.clone();
+ pane.command = command.clone();
+ }
+ }
+ }
+ self.recent.retain(|id, (_, _, at)| seen.contains(id) && now_ms.saturating_sub(*at) < GRACE_MS);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn quotes_only_when_needed() {
+ assert_eq!(shell_quote("nvim"), "nvim");
+ assert_eq!(shell_quote("~/src/a.sh"), "~/src/a.sh");
+ assert_eq!(shell_quote("my file"), "'my file'");
+ assert_eq!(shell_quote("it's"), "'it'\\''s'");
+ assert_eq!(shell_quote(""), "''");
+ }
+
+ #[test]
+ fn an_exited_agent_is_kept_for_the_grace_period() {
+ let mut recorder = Recorder::default();
+ let mut with_agent = snap(1, &[("a", "L")], "working");
+ with_agent.sessions[0].windows[0].panes[0].id = "%1".into();
+ recorder.remember_recent(&mut with_agent, 1_000);
+ let mut gone = with_agent.clone();
+ gone.sessions[0].windows[0].panes[0].agent = None;
+ let mut soon = gone.clone();
+ recorder.remember_recent(&mut soon, 1_000 + GRACE_MS - 1);
+ assert!(soon.sessions[0].windows[0].panes[0].agent.is_some(), "kept within the grace period");
+ let mut later = gone.clone();
+ recorder.remember_recent(&mut later, 1_000 + GRACE_MS);
+ assert!(later.sessions[0].windows[0].panes[0].agent.is_none(), "dropped after it");
+ }
+
fn snap(pid: u32, windows: &[(&str, &str)], state: &str) -> Snapshot {
Snapshot {
version: 1,
@@ -372,10 +513,17 @@ mod tests {
zoomed: false,
automatic_rename: false,
panes: vec![PaneSnap {
+ id: String::new(),
index: 0,
cwd: "/tmp".into(),
active: true,
- agent: Some(AgentSnap { agent: "pi".into(), name: String::new(), state: state.into() }),
+ agent: Some(AgentSnap {
+ agent: "pi".into(),
+ name: String::new(),
+ state: state.into(),
+ session: None,
+ }),
+ command: None,
}],
})
.collect(),
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 4e2b370..b1a2d60 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -27,6 +27,7 @@ pub struct Pane {
pub written_since: String,
pub written_hook: String,
pub written_name: String,
+ pub written_session: String,
}
impl Pane {
@@ -110,14 +111,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{window_panes}\t",
"#{pane_index}\t",
"#{pane_current_path}\t",
- "#{@agent_name}",
+ "#{@agent_name}\t",
+ "#{@agent_session}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes = Vec::new();
let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 18 {
+ if fields.len() < 19 {
continue;
}
let current_command = fields[1];
@@ -154,6 +156,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_since: fields[11].to_string(),
written_hook: fields[12].to_string(),
written_name: fields[17].to_string(),
+ written_session: fields[18].to_string(),
});
}
Ok(panes)
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index a2f5ef1..24a2ecd 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -23,6 +23,7 @@
//! - `@dingger_since` unix ms the pane started needing the user (FIFO)
//! - `@dingger_hook` last state an agent extension reported
//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus`
+//! - `@agent_session` the agent's session reference, for restore
use std::collections::HashMap;
@@ -328,6 +329,16 @@ impl Watcher {
if pane.agent.is_none() && !pane.written_hook.is_empty() {
let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_hook");
}
+ // The session reference, for restore; it goes with the agent.
+ match (pane.agent, hooks::session(&pane.pane_id)) {
+ (Some(_), Some(session)) if pane.written_session != session => {
+ let _ = tmux::set_pane_option(&pane.pane_id, "@agent_session", &session);
+ }
+ (None, _) if !pane.written_session.is_empty() => {
+ let _ = tmux::unset_pane_option(&pane.pane_id, "@agent_session");
+ }
+ _ => {}
+ }
}
fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) {