fix: restore agents through pane shells

This commit is contained in:
Ogulcan Celik 2026-06-01 23:31:53 +03:00
parent 5bd21b7ed4
commit 36a4608603
17 changed files with 1052 additions and 293 deletions

View File

@ -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)

View File

@ -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 <name>`, then run `herdr session attach <name>` 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 <name>`, then run `herdr session attach <name>` 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.

View File

@ -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.

View File

@ -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`.

View File

@ -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:

View File

@ -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,

587
src/app/agent_resume.rs Normal file
View File

@ -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);
}
}

View File

@ -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());
}

View File

@ -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());
}

View File

@ -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());
}
}

View File

@ -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]

View File

@ -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.

View File

@ -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);

View File

@ -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]

View File

@ -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();

View File

@ -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);
}

View File

@ -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();
}
@ -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();