From 36a4608603a82dbf32ec8a013dd47047cf02c6a3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 1 Jun 2026 23:31:53 +0300 Subject: [PATCH] fix: restore agents through pane shells --- docs/next/CHANGELOG.md | 2 +- docs/next/README.md | 2 +- .../src/content/docs/configuration.mdx | 4 +- .../website/src/content/docs/integrations.mdx | 2 +- .../src/content/docs/session-state.mdx | 6 +- src/agent_resume.rs | 6 - src/app/agent_resume.rs | 587 ++++++++++++++++++ src/app/input/terminal.rs | 2 +- src/app/mod.rs | 13 +- src/app/runtime.rs | 98 ++- src/config/model.rs | 20 +- src/main.rs | 2 +- src/pane.rs | 155 ----- src/persist/restore.rs | 153 +++-- src/server/headless.rs | 233 ++++++- src/terminal/runtime.rs | 27 - src/terminal/state.rs | 33 + 17 files changed, 1052 insertions(+), 293 deletions(-) create mode 100644 src/app/agent_resume.rs diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 86afa1f5..130143cc 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -7,7 +7,7 @@ - Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270) ### Changed -- Removed the automatic GitHub star prompt from `herdr update`. +- Native agent session restore is now enabled by default for supported panes with current official integrations. Set `[session] resume_agents_on_restore = false` to disable it. ### Fixed - Pane input no longer waits behind the PTY actor's idle read poll, restoring responsive typing at quiet shell prompts. (#379) diff --git a/docs/next/README.md b/docs/next/README.md index 34010388..9b1cfa2d 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -53,7 +53,7 @@ Press `ctrl+b q` to detach the client. The server and pane processes keep runnin **Copy.** Herdr copies pane text, not the sidebar. Drag-select inside a pane, double-click a word or token, or press `prefix+[` for keyboard copy mode. In copy mode, move with `h/j/k/l`, `w/b/e`, and `{`/`}`, start selection with `v` or Space, copy with `y` or Enter, and leave with `q` or Esc. In PuTTY and some SSH terminals, hold `Shift` while dragging to use the terminal's own selection, and `Shift` + right click to paste. -**Update and restore.** `herdr update` installs a new binary, but a running server keeps using the old process until it is stopped or handed off. Stop the old server to use the new version. Stopping exits pane processes. Run `herdr server stop`, then run `herdr` again for the default session. For a named session, run `herdr session stop `, then run `herdr session attach ` again. `herdr update --handoff` is experimental and tries to move live panes, including foreground processes such as dev servers, from the old server to the new one. If `[session] resume_agents_on_restore = true` is enabled and current official integrations are installed, supported agent panes can restart from their native agent sessions after a server restart or update. +**Update and restore.** `herdr update` installs a new binary, but a running server keeps using the old process until it is stopped or handed off. Stop the old server to use the new version. Stopping exits pane processes. Run `herdr server stop`, then run `herdr` again for the default session. For a named session, run `herdr session stop `, then run `herdr session attach ` again. `herdr update --handoff` is experimental and tries to move live panes, including foreground processes such as dev servers, from the old server to the new one. With current official integrations installed, supported agent panes can restart from their native agent sessions after a server restart or update. **Keybindings.** Herdr uses explicit keybinding strings. `prefix+n` means press the configured prefix, then `n`. `ctrl+alt+n`, `cmd+k`, `alt+1`, and function-key chords are direct terminal-mode shortcuts and do not need the prefix. Plain direct printable keys such as `n` steal normal typing, so use `prefix+n` unless you intentionally want a modifier-gated direct binding. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index b060b9c5..1a47a6c1 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -377,10 +377,10 @@ Herdr can restart supported agent panes in their native conversation sessions af ```toml [session] -resume_agents_on_restore = false +resume_agents_on_restore = true ``` -When enabled, Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory. +This is enabled by default. Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory. Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index aba9b046..f1d7d83e 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -45,7 +45,7 @@ Herdr combines three signals: Integrations enrich state reporting. They do not replace process detection. -Some integrations also report native agent session references. If `[session] resume_agents_on_restore = true` is enabled, Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart. +Some integrations also report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `4`, Codex version `4`, OpenCode version `2`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`. diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index f47dc638..bfb62ef5 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -40,14 +40,14 @@ When enabled, Herdr stores saved pane history in `session-history.json` next to Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart. -Enable it with: +This is enabled by default. Disable it with: ```toml [session] -resume_agents_on_restore = true +resume_agents_on_restore = false ``` -When enabled, Herdr only resumes panes that reported a native session reference through a current official Herdr integration. +Herdr only resumes panes that reported a native session reference through a current official Herdr integration. Native session restore requires these Herdr integration versions or newer: diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 7728baf4..8f450088 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -25,12 +25,6 @@ pub struct AgentResumePlan { pub dedupe_key: String, } -#[derive(Debug, Clone, Copy)] -pub struct AgentResumeLaunch<'a> { - pub plan: &'a AgentResumePlan, - pub initial_history_ansi: Option<&'a str>, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PersistedAgentSession { pub source: String, diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs new file mode 100644 index 00000000..3523da05 --- /dev/null +++ b/src/app/agent_resume.rs @@ -0,0 +1,587 @@ +use std::time::Instant; + +use bytes::Bytes; + +use super::App; + +struct PendingAgentResumeCandidate { + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, +} + +impl App { + pub(crate) fn has_pending_agent_resumes(&self) -> bool { + self.state + .terminals + .values() + .any(|terminal| terminal.pending_agent_resume_plan.is_some()) + } + + pub(crate) fn sync_pending_agent_resume_deadline(&mut self, now: Instant) { + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + return; + } + if self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + return; + } + self.pending_agent_resume_deadline + .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT); + } + + pub(crate) fn pending_agent_resume_due(&self, now: Instant) -> bool { + self.pending_agent_resume_deadline + .is_some_and(|deadline| now >= deadline) + } + + pub(crate) fn start_pending_agent_resumes(&mut self, allow_empty_theme: bool) -> bool { + let pending = self.pending_agent_resume_candidates(); + let mut changed = false; + for PendingAgentResumeCandidate { + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + } in pending + { + if self.terminal_runtimes.get(&terminal_id).is_some() { + continue; + } + changed |= self.start_pending_agent_resume( + pane_id, + terminal_id, + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + } + + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() || self.pending_agent_resume_candidates().is_empty() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn pending_agent_resume_candidates(&self) -> Vec { + let Some(ws_idx) = self.state.active else { + return Vec::new(); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return Vec::new(); + }; + let Some(tab) = ws.tabs.get(ws.active_tab) else { + return Vec::new(); + }; + + let mut pending = Vec::new(); + for pane_id in tab.layout.pane_ids() { + let Some(pane) = tab.panes.get(&pane_id) else { + continue; + }; + if self + .terminal_runtimes + .get(&pane.attached_terminal_id) + .is_some() + { + continue; + } + let Some(info) = self + .state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + else { + continue; + }; + let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else { + continue; + }; + let Some(plan) = terminal.pending_agent_resume_plan.clone() else { + continue; + }; + pending.push(PendingAgentResumeCandidate { + pane_id, + terminal_id: pane.attached_terminal_id.clone(), + cwd: terminal.cwd.clone(), + plan, + rows: info.inner_rect.height, + cols: info.inner_rect.width, + }); + } + pending + } + + pub(crate) fn start_pending_agent_resume_for_terminal( + &mut self, + terminal_id: &crate::terminal::TerminalId, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + if self.terminal_runtimes.get(terminal_id).is_some() { + return false; + } + let Some((pane_id, cwd, plan)) = self.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.layout.pane_ids().into_iter().find_map(|pane_id| { + let pane = tab.panes.get(&pane_id)?; + if &pane.attached_terminal_id != terminal_id { + return None; + } + let terminal = self.state.terminals.get(terminal_id)?; + Some(( + pane_id, + terminal.cwd.clone(), + terminal.pending_agent_resume_plan.clone()?, + )) + }) + }) + }) else { + return false; + }; + + let changed = self.start_pending_agent_resume( + pane_id, + terminal_id.clone(), + cwd, + plan, + rows, + cols, + allow_empty_theme, + ); + if changed { + self.schedule_session_save(); + } + if !self.has_pending_agent_resumes() { + self.pending_agent_resume_deadline = None; + } + changed + } + + fn start_pending_agent_resume( + &mut self, + pane_id: crate::layout::PaneId, + terminal_id: crate::terminal::TerminalId, + cwd: std::path::PathBuf, + plan: crate::agent_resume::AgentResumePlan, + rows: u16, + cols: u16, + allow_empty_theme: bool, + ) -> bool { + let host_terminal_theme = self.state.host_terminal_theme; + if host_terminal_theme.is_empty() && !allow_empty_theme { + return false; + } + + let Some(resume_command) = shell_command_from_argv(&plan.argv) else { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + "failed to start deferred agent resume with empty argv" + ); + return false; + }; + + let runtime = match crate::terminal::TerminalRuntime::spawn( + pane_id, + rows, + cols, + cwd, + self.state.pane_scrollback_limit_bytes, + host_terminal_theme, + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) { + Ok(runtime) => runtime, + Err(err) => { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to start shell for deferred agent resume" + ); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.clear_agent_runtime_identity_after_respawn(); + } + return false; + } + }; + + let mut input = resume_command; + input.push('\r'); + if let Err(err) = runtime.try_send_bytes(Bytes::from(input)) { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + agent = %plan.agent, + err = %err, + "failed to send deferred agent resume command to shell" + ); + runtime.shutdown(); + return false; + } + + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.pending_agent_resume_plan = None; + terminal.respawn_shell_on_exit = false; + } + true + } +} + +fn shell_command_from_argv(argv: &[String]) -> Option { + let mut parts = argv.iter(); + let first = shell_quote(parts.next()?); + let mut command = first; + for part in parts { + command.push(' '); + command.push_str(&shell_quote(part)); + } + Some(command) +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' + ) + }) { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + #[tokio::test] + async fn pending_agent_resume_waits_for_host_theme_before_launch() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + let pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.view.pane_infos = pane_infos; + let terminal = app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist"); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(), + ], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + let terminal = app + .state + .terminals + .get(&terminal_id) + .expect("terminal should survive launch"); + assert!(terminal.pending_agent_resume_plan.is_none()); + assert!(!terminal.respawn_shell_on_exit); + + let runtime = app + .terminal_runtimes + .get(&terminal_id) + .expect("pending resume should leave a shell runtime"); + let marker = "restored agent: shell quoted | marker"; + for _ in 0..20 { + if runtime + .snapshot_history() + .is_some_and(|text| text.contains(marker)) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + runtime + .snapshot_history() + .expect("runtime should expose terminal history") + .contains(marker), + "deferred restore should inject the resume argv into the restored shell" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_can_launch_after_theme_wait_expires() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.start_pending_agent_resumes(true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() { + let mut app = test_app(); + let active_workspace = crate::workspace::Workspace::test_new("active"); + let active_pane = active_workspace.tabs[0].root_pane; + let active_terminal = active_workspace.terminal_id(active_pane).cloned().unwrap(); + let hidden_workspace = crate::workspace::Workspace::test_new("hidden"); + let hidden_pane = hidden_workspace.tabs[0].root_pane; + let hidden_terminal = hidden_workspace.terminal_id(hidden_pane).cloned().unwrap(); + app.state.view.pane_infos = active_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![active_workspace, hidden_workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + for terminal_id in [&active_terminal, &hidden_terminal] { + app.state + .terminals + .get_mut(terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"), + }); + } + app.pending_agent_resume_deadline = + Some(std::time::Instant::now() - std::time::Duration::from_millis(1)); + + assert!(app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&active_terminal).is_some()); + assert!(app.terminal_runtimes.get(&hidden_terminal).is_none()); + assert!( + app.pending_agent_resume_deadline.is_none(), + "hidden-only pending resumes should not keep an expired wakeup deadline active" + ); + assert!( + app.state + .terminals + .get(&hidden_terminal) + .expect("hidden terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "hidden restored panes should wait until their tab has computed geometry" + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[tokio::test] + async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() { + let mut app = test_app(); + let previous_workspace = crate::workspace::Workspace::test_new("previous"); + let previous_pane = previous_workspace.tabs[0].root_pane; + let previous_terminal = previous_workspace + .terminal_id(previous_pane) + .cloned() + .unwrap(); + let current_workspace = crate::workspace::Workspace::test_new("current"); + app.state.view.pane_infos = previous_workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.workspaces = vec![previous_workspace, current_workspace]; + app.state.active = Some(1); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&previous_terminal) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + app.sync_pending_agent_resume_deadline(std::time::Instant::now()); + assert!(app.pending_agent_resume_deadline.is_none()); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&previous_terminal).is_none()); + assert!( + app.state + .terminals + .get(&previous_terminal) + .expect("previous terminal should still exist") + .pending_agent_resume_plan + .is_some(), + "a pane hidden by navigation should wait for a fresh visible geometry snapshot" + ); + } + + #[tokio::test] + async fn pending_agent_resume_launches_with_inner_rect_size() { + let mut app = test_app(); + let mut workspace = crate::workspace::Workspace::test_new("split"); + let pane_id = workspace.test_split(ratatui::layout::Direction::Horizontal); + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = vec![crate::layout::PaneInfo { + id: pane_id, + rect: ratatui::layout::Rect::new(0, 0, 100, 30), + inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28), + scrollbar_rect: None, + is_focused: true, + }]; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!(app.start_pending_agent_resumes(false)); + assert_eq!( + app.terminal_runtimes + .get(&terminal_id) + .expect("pending resume should launch") + .current_size(), + (28, 98) + ); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + } + + #[test] + fn shell_command_from_argv_quotes_resume_arguments() { + let argv = vec![ + "claude".to_string(), + "--resume".to_string(), + "session with ' quote".to_string(), + ]; + + assert_eq!( + shell_command_from_argv(&argv).as_deref(), + Some("claude --resume 'session with '\\'' quote'") + ); + assert_eq!(shell_command_from_argv(&[]), None); + } +} diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 401a981d..99ae3bf1 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -519,7 +519,7 @@ mod tests { let deadline = app .selection_highlight_clear_deadline .expect("highlight clear deadline"); - assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1))); + assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false)); assert!(app.state.selection.is_none()); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0dcd0372..5a39b8e2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ //! - `input.rs` — key/mouse → action translation pub(crate) mod actions; +mod agent_resume; mod agents; mod api; mod api_helpers; @@ -34,6 +35,7 @@ pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); +const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); @@ -105,6 +107,7 @@ pub struct App { pub(crate) next_animation_tick: Option, pub(crate) next_auto_update_check: Option, pub(crate) agent_metadata_deadline: Option, + pub(crate) pending_agent_resume_deadline: Option, pub(crate) selection_autoscroll_deadline: Option, pub(crate) selection_highlight_clear_deadline: Option, pub(crate) session_save_deadline: Option, @@ -576,6 +579,7 @@ impl App { next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), agent_metadata_deadline: None, + pending_agent_resume_deadline: None, session_save_deadline: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, @@ -698,7 +702,7 @@ impl App { self.sync_session_save_schedule(); let now = Instant::now(); - if self.handle_scheduled_tasks(now) { + if self.handle_scheduled_tasks(now, needs_render) { needs_render = true; } @@ -813,6 +817,11 @@ impl App { cell_size, )?; } + self.sync_pending_agent_resume_deadline(now); + if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) { + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + } self.last_render_at = Some(now); needs_render = false; continue; @@ -3000,7 +3009,7 @@ mod tests { let mut app = test_app(); app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1)); - app.handle_scheduled_tasks(Instant::now()); + app.handle_scheduled_tasks(Instant::now(), false); assert!(app.session_save_deadline.is_none()); } diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ad90a267..4ddcde6c 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -165,13 +165,15 @@ impl App { false } - pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { + pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; + let mut resized = false; self.sync_animation_timer(now); if now >= self.next_resize_poll { - changed |= self.handle_resize_poll(); + resized = self.handle_resize_poll(); + changed |= resized; self.next_resize_poll = now + RESIZE_POLL_INTERVAL; } @@ -247,6 +249,12 @@ impl App { changed = true; } + if geometry_dirty || resized { + self.pending_agent_resume_deadline = None; + } else { + self.sync_pending_agent_resume_deadline(now); + changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now)); + } self.sync_animation_timer(now); changed } @@ -496,6 +504,7 @@ impl App { .flatten(), self.next_auto_update_check, self.agent_metadata_deadline, + self.pending_agent_resume_deadline, self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, @@ -914,4 +923,89 @@ mod tests { assert!(app.state.selection_autoscroll.is_none()); assert!(app.selection_autoscroll_deadline.is_none()); } + + #[tokio::test] + async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + + assert!( + app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor { + kind: crate::terminal_theme::DefaultColorKind::Foreground, + color: crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }, + }) + .await + ); + assert!( + app.terminal_runtimes.get(&terminal_id).is_none(), + "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos" + ); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + } + + #[tokio::test] + async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { + let (mut app, pane_id) = test_app_with_pane(); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .cloned() + .expect("test pane should have a terminal"); + app.state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!app.handle_scheduled_tasks(Instant::now(), true)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(app.pending_agent_resume_deadline.is_none()); + } } diff --git a/src/config/model.rs b/src/config/model.rs index dd247d83..f815fb8d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -137,14 +137,22 @@ pub struct TerminalConfig { pub new_cwd: NewTerminalCwdConfig, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Deserialize)] #[serde(default)] pub struct SessionConfig { /// Resume supported AI-agent panes into their native conversation sessions - /// when restoring a Herdr session. Default: false. + /// when restoring a Herdr session. Default: true. pub resume_agents_on_restore: bool, } +impl Default for SessionConfig { + fn default() -> Self { + Self { + resume_agents_on_restore: true, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ConfigReloadStatus { @@ -627,16 +635,16 @@ new_cwd = "~/Projects" } #[test] - fn resume_agents_on_restore_defaults_off_and_parses() { + fn resume_agents_on_restore_defaults_on_and_parses() { let default_config = Config::default(); - assert!(!default_config.session.resume_agents_on_restore); + assert!(default_config.session.resume_agents_on_restore); let toml = r#" [session] -resume_agents_on_restore = true +resume_agents_on_restore = false "#; let config: Config = toml::from_str(toml).unwrap(); - assert!(config.session.resume_agents_on_restore); + assert!(!config.session.resume_agents_on_restore); } #[test] diff --git a/src/main.rs b/src/main.rs index 02dabda6..c37e870a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -246,7 +246,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration [session] # Resume supported AI-agent panes into their native conversation sessions after # a Herdr server restart. Requires official integrations that report session refs. -# resume_agents_on_restore = false +# resume_agents_on_restore = true [remote] # Whether herdr manages the ssh config used for the `herdr --remote` bridge. diff --git a/src/pane.rs b/src/pane.rs index 94daebc0..35b2318e 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1149,51 +1149,6 @@ impl PaneRuntime { ) } - pub fn spawn_agent_restore( - pane_id: PaneId, - rows: u16, - cols: u16, - cwd: std::path::PathBuf, - launch: crate::agent_resume::AgentResumeLaunch<'_>, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result { - let Some((program, args)) = launch.plan.argv.split_first() else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "restore argv must not be empty", - )); - }; - - let mut cmd = CommandBuilder::new(program); - for arg in args { - cmd.arg(arg); - } - cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); - apply_pane_terminal_env(&mut cmd); - crate::integration::apply_pane_env(&mut cmd, pane_id); - Self::spawn_command_builder( - pane_id, - rows, - cols, - scrollback_limit_bytes, - host_terminal_theme, - events, - render_notify, - render_dirty, - cmd, - "failed to spawn agent restore pane", - SpawnInitialState { - detected_agent: crate::detect::parse_agent_label(&launch.plan.agent), - history_ansi: launch.initial_history_ansi, - }, - ) - } - #[cfg(unix)] pub fn from_handoff_fd( import: crate::handoff_runtime::ImportedHandoffRuntime, @@ -2359,116 +2314,6 @@ mod tests { assert!(truncated.is_empty()); } - fn process_command_name(pid: u32) -> Option { - let output = std::process::Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let command = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (!command.is_empty()).then_some(command) - } - - async fn wait_for_child_pid(runtime: &PaneRuntime) -> u32 { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - while tokio::time::Instant::now() < deadline { - let pid = runtime.child_pid.load(Ordering::Acquire); - if pid != 0 { - return pid; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("child pid was not published"); - } - - #[tokio::test] - async fn spawn_agent_restore_uses_restore_command_as_pane_child() { - let (events, _event_rx) = mpsc::channel(4); - let plan = crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/cat".into()], - dedupe_key: "test".into(), - }; - let runtime = PaneRuntime::spawn_agent_restore( - PaneId::from_raw(7), - 24, - 80, - std::env::current_dir().unwrap(), - crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: None, - }, - 0, - crate::terminal_theme::TerminalTheme::default(), - events, - Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), - ) - .unwrap(); - - let pid = wait_for_child_pid(&runtime).await; - let command = process_command_name(pid).expect("child process should be visible to ps"); - - assert!( - command.ends_with("cat"), - "restore command should be the pane child, got {command:?}" - ); - assert!( - !command.ends_with("sh"), - "restore must not keep a shell wrapper as the pane child" - ); - - runtime.shutdown(); - } - - #[tokio::test] - async fn spawn_agent_restore_reports_pane_death_after_early_failure() { - let (events, mut event_rx) = mpsc::channel(8); - let plan = crate::agent_resume::AgentResumePlan { - agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], - dedupe_key: "test".into(), - }; - let runtime = PaneRuntime::spawn_agent_restore( - PaneId::from_raw(7), - 24, - 80, - std::env::current_dir().unwrap(), - crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: None, - }, - 0, - crate::terminal_theme::TerminalTheme::default(), - events, - Arc::new(Notify::new()), - Arc::new(AtomicBool::new(false)), - ) - .unwrap(); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); - let mut died = false; - while tokio::time::Instant::now() < deadline { - let Some(event) = tokio::time::timeout( - deadline.saturating_duration_since(tokio::time::Instant::now()), - event_rx.recv(), - ) - .await - .expect("pane death event should arrive") else { - break; - }; - if matches!(event, AppEvent::PaneDied { pane_id } if pane_id == PaneId::from_raw(7)) { - died = true; - break; - } - } - - assert!(died, "failed direct agent restore should report pane death"); - runtime.shutdown(); - } - #[tokio::test] async fn focus_events_are_forwarded_when_enabled() { let (tx, mut rx) = mpsc::channel(4); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index dc48efe6..93041017 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -108,7 +108,7 @@ pub fn restore_handoff( 80, scrollback_limit_bytes, crate::pane::PaneShellConfig::new(default_shell, shell_mode), - false, + true, imports, events, render_notify, @@ -418,7 +418,43 @@ fn restore_tab( let old_pane_id = reverse_id_map.get(id).copied(); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); - let was_native_agent_restore = !was_imported && startup.restore_plan.is_some(); + let pending_native_agent_restore = if was_imported { + None + } else { + startup.restore_plan.clone() + }; + if let Some(plan) = pending_native_agent_restore { + let terminal_id = TerminalId::alloc(); + let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) + .with_pending_agent_resume_plan(plan); + if let Some(label) = saved_label { + terminal.set_manual_label(label); + } + if let Some(agent_name) = saved_agent_name { + terminal.set_agent_name(agent_name); + } + if let Some(agent) = initial_restore_agent { + let _ = terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Idle, + false, + false, + false, + false, + std::time::Instant::now(), + ); + } + if let Some(session) = restored_terminal_agent_session( + saved_agent_session, + startup.duplicate_agent_session, + ) { + terminal.set_persisted_agent_session(session); + } + panes.insert(*id, PaneState::new(terminal_id)); + terminals.push(terminal); + continue; + } + let runtime_result = if let Some(imported) = imported_runtime { TerminalRuntime::from_handoff_fd( crate::handoff_runtime::ImportedHandoffRuntime { @@ -431,23 +467,6 @@ fn restore_tab( runtime_context.render_notify.clone(), runtime_context.render_dirty.clone(), ) - } else if let Some(plan) = startup.restore_plan { - let launch = crate::agent_resume::AgentResumeLaunch { - plan: &plan, - initial_history_ansi: startup.initial_history_ansi, - }; - TerminalRuntime::spawn_agent_restore( - *id, - rows, - cols, - cwd.clone(), - launch, - runtime_context.scrollback_limit_bytes, - crate::terminal_theme::TerminalTheme::default(), - runtime_context.events.clone(), - runtime_context.render_notify.clone(), - runtime_context.render_dirty.clone(), - ) } else { TerminalRuntime::spawn_with_initial_history( *id, @@ -468,9 +487,7 @@ fn restore_tab( Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); - if was_native_agent_restore { - terminal = terminal.with_respawn_shell_on_exit(); - } else if was_imported { + if was_imported { if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); } @@ -753,30 +770,6 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec) { #[cfg(test)] mod tests { use super::*; - use std::ffi::OsString; - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: OsString) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } #[test] fn capture_and_restore_node_round_trip() { @@ -1081,33 +1074,8 @@ mod tests { #[tokio::test] #[cfg(unix)] - async fn native_agent_restore_marks_terminal_for_shell_respawn() { - use std::os::unix::fs::PermissionsExt; - - let _lock = crate::integration::integration_env_lock(); + async fn native_agent_restore_defers_runtime_launch() { let cwd = std::env::current_dir().unwrap(); - let base = std::env::temp_dir().join(format!( - "herdr-agent-restore-respawn-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let bin = base.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let codex = bin.join("codex"); - std::fs::write(&codex, "#!/bin/sh\nexit 0\n").unwrap(); - std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); - let path = match std::env::var_os("PATH") { - Some(path) => std::env::join_paths( - std::iter::once(bin.as_os_str().to_owned()) - .chain(std::env::split_paths(&path).map(|path| path.into_os_string())), - ) - .unwrap(), - None => bin.as_os_str().to_owned(), - }; - let _path_guard = EnvVarGuard::set("PATH", path); let snapshot = SessionSnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, workspaces: vec![WorkspaceSnapshot { @@ -1167,14 +1135,41 @@ mod tests { .next() .expect("native agent restore should create terminal state"); assert!( - terminal.respawn_shell_on_exit, - "restored native agent panes should keep the pane open after the resume process exits" + terminal.pending_agent_resume_plan.is_some(), + "restored native agent panes should defer resume until client terminal context is known" + ); + assert!( + !terminal.respawn_shell_on_exit, + "deferred agent resume should not use native restore lifecycle before launch" + ); + assert!( + runtimes.is_empty(), + "native agent restore should not spawn a fallback-size runtime during snapshot restore" + ); + let mut imports = HashMap::new(); + let (_handoff_workspaces, handoff_terminals, handoff_runtimes) = restore_handoff( + &snapshot, + 0, + "/bin/sh", + crate::config::ShellModeConfig::NonLogin, + &mut imports, + mpsc::channel(4).0, + Arc::new(Notify::new()), + Arc::new(AtomicBool::new(false)), + ) + .expect("handoff restore should preserve pending native agent resume"); + let handoff_terminal = handoff_terminals + .values() + .next() + .expect("handoff restore should create terminal state"); + assert!( + handoff_terminal.pending_agent_resume_plan.is_some(), + "handoff restore should preserve pending native agent resume intent" + ); + assert!( + handoff_runtimes.is_empty(), + "handoff restore should not replace pending native agent resume with a shell runtime" ); - - for runtime in runtimes.into_values() { - runtime.shutdown(); - } - let _ = std::fs::remove_dir_all(base); } #[tokio::test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 2bf8fa15..b405e887 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -336,7 +336,7 @@ impl HeadlessServer { // 6. Handle scheduled tasks. let now = Instant::now(); - if self.handle_scheduled_tasks_headless(now) { + if self.handle_scheduled_tasks_headless(now, needs_render) { needs_render = true; } @@ -490,6 +490,17 @@ impl HeadlessServer { } fn resize_shared_runtime_to_effective_size(&mut self) { + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); + } + + fn resize_shared_runtime_to_effective_size_before_input(&mut self) { + self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(false); + } + + fn resize_shared_runtime_to_effective_size_with_pending_agent_resumes( + &mut self, + start_pending_agent_resumes: bool, + ) { if self.foreground_client_id.is_none() { return; } @@ -522,6 +533,20 @@ impl HeadlessServer { for client in self.clients.values_mut() { client.request_full_redraw(); } + if !start_pending_agent_resumes { + self.app.pending_agent_resume_deadline = None; + return; + } + let now = Instant::now(); + self.app.sync_pending_agent_resume_deadline(now); + if self + .app + .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) + { + for client in self.clients.values_mut() { + client.request_full_redraw(); + } + } } fn sync_foreground_client_state(&mut self) { @@ -998,7 +1023,11 @@ impl HeadlessServer { } if self.foreground_client_id == Some(client_id) { - self.app.set_host_terminal_theme(client.host_terminal_theme) + let changed = self.app.set_host_terminal_theme(client.host_terminal_theme); + if changed { + self.resize_shared_runtime_to_effective_size_before_input(); + } + changed } else { false } @@ -1092,7 +1121,7 @@ impl HeadlessServer { let foreground_changed = self.promote_client_to_foreground(client_id); if foreground_changed { - self.resize_shared_runtime_to_effective_size(); + self.resize_shared_runtime_to_effective_size_before_input(); } if let Some(client) = self.clients.get_mut(&client_id) { client.request_semantic_redraw_after_input(); @@ -1650,6 +1679,8 @@ impl HeadlessServer { .state .direct_attach_resize_locks .insert(real_terminal_id.clone()); + self.app + .start_pending_agent_resume_for_terminal(&real_terminal_id, rows, cols, true); if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) { runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); } @@ -1798,7 +1829,7 @@ impl HeadlessServer { false }; if foreground_changed { - self.resize_shared_runtime_to_effective_size(); + self.resize_shared_runtime_to_effective_size_before_input(); } let theme_changed = self.update_client_host_theme_from_events(client_id, &events); self.app @@ -2446,7 +2477,7 @@ impl HeadlessServer { /// /// Similar to `App::handle_scheduled_tasks` but without resize polling /// (the server doesn't have a terminal to resize). - fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool { + fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { let mut changed = false; self.app.sync_headless_animation_timer(now); @@ -2544,6 +2575,14 @@ impl HeadlessServer { changed = true; } + if geometry_dirty || self.foreground_client_id.is_none() { + self.app.pending_agent_resume_deadline = None; + } else { + self.app.sync_pending_agent_resume_deadline(now); + changed |= self + .app + .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)); + } self.app.sync_headless_animation_timer(now); changed } @@ -3691,7 +3730,7 @@ next_tab = "" Some("short lived") ); - assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1))); + assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false)); assert_eq!(server.app.agent_metadata_deadline, None); assert_eq!( @@ -3721,6 +3760,188 @@ next_tab = "" })); } + #[tokio::test] + async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + server.app.state.host_terminal_theme, + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.effective_size = (100, 30); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + + #[tokio::test] + async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_without_foreground_client() + { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.foreground_client_id = None; + server.effective_size = (80, 24); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + + #[tokio::test] + async fn headless_pre_input_resize_does_not_start_pending_agent_resume() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.ensure_test_terminals(); + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + server.app.state.host_terminal_theme, + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.effective_size = (100, 30); + server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + }; + server + .app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist") + .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + agent: "codex".into(), + argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + }); + server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1)); + + server.resize_shared_runtime_to_effective_size_before_input(); + + assert!(server.app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(server + .app + .state + .terminals + .get(&terminal_id) + .expect("test terminal should still exist") + .pending_agent_resume_plan + .is_some()); + assert!(server.app.pending_agent_resume_deadline.is_none()); + } + #[test] fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index c7bb8810..0fefe037 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -189,33 +189,6 @@ impl TerminalRuntime { .map(Self) } - pub fn spawn_agent_restore( - pane_id: PaneId, - rows: u16, - cols: u16, - cwd: std::path::PathBuf, - launch: crate::agent_resume::AgentResumeLaunch<'_>, - scrollback_limit_bytes: usize, - host_terminal_theme: crate::terminal_theme::TerminalTheme, - events: mpsc::Sender, - render_notify: Arc, - render_dirty: Arc, - ) -> std::io::Result { - crate::pane::PaneRuntime::spawn_agent_restore( - pane_id, - rows, - cols, - cwd, - launch, - scrollback_limit_bytes, - host_terminal_theme, - events, - render_notify, - render_dirty, - ) - .map(Self) - } - pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.0.apply_host_terminal_theme(theme); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index ac7c4380..09c5812a 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -73,6 +73,7 @@ pub struct TerminalState { pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, + pub pending_agent_resume_plan: Option, } impl TerminalState { @@ -98,6 +99,7 @@ impl TerminalState { revision: 0, launch_argv: None, respawn_shell_on_exit: false, + pending_agent_resume_plan: None, } } @@ -111,6 +113,14 @@ impl TerminalState { self } + pub fn with_pending_agent_resume_plan( + mut self, + plan: crate::agent_resume::AgentResumePlan, + ) -> Self { + self.pending_agent_resume_plan = Some(plan); + self + } + #[cfg(test)] pub fn set_detected_state( &mut self, @@ -667,8 +677,10 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.state = AgentState::Unknown; self.launch_argv = None; self.respawn_shell_on_exit = false; + self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -1865,6 +1877,27 @@ mod tests { assert!(terminal.persisted_agent_session.is_none()); } + #[test] + fn respawn_cleanup_resets_restored_agent_status() { + let mut terminal = test_terminal(); + terminal.respawn_shell_on_exit = true; + terminal.set_agent_name("codex".into()); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("codex-session").unwrap(), + }); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + + terminal.clear_agent_runtime_identity_after_respawn(); + + assert_eq!(terminal.state, AgentState::Unknown); + assert!(terminal.detected_agent.is_none()); + assert!(terminal.agent_name.is_none()); + assert!(terminal.persisted_agent_session.is_none()); + assert!(!terminal.respawn_shell_on_exit); + } + #[test] fn detected_conflict_clears_session_ref() { let mut terminal = test_terminal();