aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/agent_resume.rs6
-rw-r--r--src/app/agent_resume.rs587
-rw-r--r--src/app/input/terminal.rs2
-rw-r--r--src/app/mod.rs13
-rw-r--r--src/app/runtime.rs98
-rw-r--r--src/config/model.rs20
-rw-r--r--src/main.rs2
-rw-r--r--src/pane.rs155
-rw-r--r--src/persist/restore.rs153
-rw-r--r--src/server/headless.rs233
-rw-r--r--src/terminal/runtime.rs27
-rw-r--r--src/terminal/state.rs33
12 files changed, 1044 insertions, 285 deletions
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<PendingAgentResumeCandidate> {
+ 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<String> {
+ 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<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) agent_metadata_deadline: Option<Instant>,
+ pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
@@ -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<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
- 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<String> {
- 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<PaneId>) {
#[cfg(test)]
mod tests {
use super::*;
- use std::ffi::OsString;
-
- struct EnvVarGuard {
- key: &'static str,
- previous: Option<OsString>,
- }
-
- 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<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
- 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<Vec<String>>,
pub respawn_shell_on_exit: bool,
+ pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>,
}
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();
}
@@ -1866,6 +1878,27 @@ mod tests {
}
#[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();
terminal.set_hook_authority_with_session_ref(