diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 13:20:11 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 13:20:11 +0300 |
| commit | c082f5044eedd2294d72f920d1018e40d73aab33 (patch) | |
| tree | 54420e9c0fcb366e8db776c9463c4530737704d7 /crates/dinggerd | |
| parent | 0f06b0b15d6b064703fa1166fb8e7ceda209aaa8 (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/dinggerd')
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 16 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 18 | ||||
| -rw-r--r-- | crates/dinggerd/src/integrations.rs | 41 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 6 | ||||
| -rw-r--r-- | crates/dinggerd/src/platform.rs | 68 | ||||
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 193 | ||||
| -rw-r--r-- | crates/dinggerd/src/snapshot.rs | 172 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 7 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 11 |
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) { |
