diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-27 20:57:00 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-27 20:57:00 +0300 |
| commit | 2552d101afe46b8622a7695f38f6cd1766dd1afc (patch) | |
| tree | e560c2cc8ef02689711af0865a5b541ee3050bf1 /src | |
| parent | 0d1a39dcee6a0f244a036c55935970f40cfa76b8 (diff) | |
feat: let agents report their own resume command (#4687)
* feat: let agents report their own resume command
* fix: keep reported resume commands tied to accepted sessions and current owners
Diffstat (limited to 'src')
| -rw-r--r-- | src/agent_resume.rs | 95 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 8 | ||||
| -rw-r--r-- | src/app/actions.rs | 225 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 132 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 1 | ||||
| -rw-r--r-- | src/cli/pane.rs | 15 | ||||
| -rw-r--r-- | src/cli/spec.rs | 10 | ||||
| -rw-r--r-- | src/detect/mod.rs | 48 | ||||
| -rw-r--r-- | src/events.rs | 13 | ||||
| -rw-r--r-- | src/pane.rs | 65 | ||||
| -rw-r--r-- | src/persist/restore.rs | 163 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 55 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 140 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 4 | ||||
| -rw-r--r-- | src/terminal/state.rs | 188 |
16 files changed, 1120 insertions, 43 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 56554dd3..d6a68e45 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; const MAX_SESSION_ID_LEN: usize = 512; const MAX_SESSION_PATH_LEN: usize = 4096; +const MAX_RESUME_ARGS: usize = 64; +const MAX_RESUME_ARGV_BYTES: usize = 8192; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionRef { @@ -25,6 +27,67 @@ pub struct AgentResumePlan { pub dedupe_key: String, } +/// A resume command reported by the agent itself, run in the restored pane. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportedAgentResume { + pub source: String, + pub agent: String, + pub argv: Vec<String>, +} + +impl ReportedAgentResume { + /// The same command can name different sessions in different directories, + /// for example `agent --continue`, so the directory is part of its identity. + pub fn plan(&self, cwd: &Path) -> AgentResumePlan { + AgentResumePlan { + agent: self.agent.clone(), + argv: self.argv.clone(), + dedupe_key: format!( + "{}\u{0}{}\u{0}{}\u{0}argv\u{0}{}", + self.source, + self.agent, + cwd.display(), + self.argv.join("\u{0}") + ), + } + } +} + +/// Restore types the command into the pane's shell, so the executable must be a +/// bare command name: shells disagree on how to invoke a quoted path. +pub fn validate_resume_argv(argv: &[String]) -> Result<(), String> { + let Some(command) = argv.first() else { + return Err("resume_argv must not be empty".into()); + }; + if argv.len() > MAX_RESUME_ARGS { + return Err(format!( + "resume_argv allows at most {MAX_RESUME_ARGS} arguments" + )); + } + if argv.iter().map(String::len).sum::<usize>() > MAX_RESUME_ARGV_BYTES { + return Err(format!( + "resume_argv allows at most {MAX_RESUME_ARGV_BYTES} bytes" + )); + } + if argv.iter().any(|arg| arg.chars().any(char::is_control)) { + return Err("resume_argv must not contain control characters".into()); + } + // Restore quotes arguments POSIX-style, which PowerShell reads differently + // only when an argument itself contains an apostrophe. + if argv.iter().any(|arg| arg.contains('\'')) { + return Err("resume_argv must not contain apostrophes".into()); + } + let plain_command = !command.is_empty() + && !command.starts_with('-') + && command + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')); + if !plain_command { + return Err("resume_argv must start with a plain command name, not a path".into()); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PersistedAgentSession { pub source: String, @@ -309,6 +372,38 @@ mod tests { .to_string() } + fn argv(parts: &[&str]) -> Vec<String> { + parts.iter().map(|part| part.to_string()).collect() + } + + #[test] + fn reported_resume_argv_requires_a_plain_command_name() { + assert!(validate_resume_argv(&argv(&[ + "prime-agent", + "--resume", + "01a0de21", + "--model", + "gpt 6" + ])) + .is_ok()); + assert!(validate_resume_argv(&argv(&["cursor-agent.cmd", "--resume", "id"])).is_ok()); + + for invalid in [ + argv(&[]), + argv(&[""]), + argv(&["/usr/bin/prime-agent", "--resume", "id"]), + argv(&["C:\\Program Files\\Prime\\prime.exe"]), + argv(&["prime agent"]), + argv(&["-prime"]), + argv(&["prime-agent", "bad\nline"]), + argv(&["prime-agent", "--name", "can's session"]), + vec!["prime-agent".to_string(); MAX_RESUME_ARGS + 1], + argv(&["prime-agent", &"x".repeat(MAX_RESUME_ARGV_BYTES)]), + ] { + assert!(validate_resume_argv(&invalid).is_err(), "{invalid:?}"); + } + } + #[test] fn native_state_reservation_excludes_full_lifecycle_sources() { assert!(is_reserved_native_state_source("herdr:claude", "claude")); diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index dfef31f8..9dbe0221 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -372,6 +372,10 @@ pub struct PaneReportAgentParams { pub agent_session_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session_path: Option<String>, + /// Command that resumes this agent's session after a Herdr restart. The + /// first element must be a plain command name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_argv: Option<Vec<String>>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -387,6 +391,10 @@ pub struct PaneReportAgentSessionParams { pub agent_session_path: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_start_source: Option<String>, + /// Command that resumes this agent's session after a Herdr restart. The + /// first element must be a plain command name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_argv: Option<Vec<String>>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/app/actions.rs b/src/app/actions.rs index 6a6e99d2..e1ae2ffc 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1500,6 +1500,28 @@ impl AppState { .collect() } } + AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.record_reported_resume(&source, &agent_label, seq, argv); + None + }) + .into_iter() + .collect(), + AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at, + } => self + .update_terminal_state(pane_id, |terminal| { + terminal.clear_self_reported_agent(observed_at) + }) + .into_iter() + .collect(), AppEvent::AgentSessionReported { pane_id, source, @@ -1653,7 +1675,14 @@ impl AppState { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); let had_completion = terminal.last_agent_completion_seq.is_some() || !previous_seen; - let mutation = update(terminal)?; + let resume_revision = terminal.reported_resume_revision(); + let mutation = update(terminal); + terminal.reconcile_reported_resume(); + // Resume-only changes return no mutation but must still be saved. + if terminal.reported_resume_revision() != resume_revision { + self.session_dirty = true; + } + let mutation = mutation?; let completion_reset = mutation.session_ref_changed || mutation .effective_state_change @@ -3541,6 +3570,200 @@ mod tests { assert!(state.toast.is_none()); } + fn report_custom_agent_with_resume(state: &mut AppState, pane_id: PaneId, argv: &[&str]) { + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + state: AgentState::Idle, + message: None, + seq: Some(1), + session_ref: None, + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + seq: Some(1), + argv: argv.iter().map(|part| part.to_string()).collect(), + }); + } + + fn first_pane_terminal(state: &AppState) -> (PaneId, crate::terminal::TerminalId) { + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0].panes[&pane_id] + .attached_terminal_id + .clone(); + (pane_id, terminal_id) + } + + #[test] + fn reported_resume_follows_the_reporting_agent_until_release() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + + let resume = state.terminals[&terminal_id].reported_resume().unwrap(); + assert_eq!(resume.agent, "prime-agent"); + assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "someone-else".into(), + agent_label: "other".into(), + seq: None, + argv: vec!["other".into()], + }); + assert_eq!( + state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv, + vec!["prime-agent", "--resume", "a"] + ); + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "prime-agent".into(), + agent_label: "prime-agent".into(), + known_agent: None, + seq: Some(2), + }); + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + } + + #[test] + fn reported_resume_is_dropped_and_saved_when_another_agent_takes_the_pane() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "custom:other".into(), + agent_label: "other".into(), + state: AgentState::Working, + message: None, + seq: None, + session_ref: None, + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + } + + #[test] + fn reported_resume_of_recognized_agent_is_dropped_when_its_process_exits() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!(state.terminals[&terminal_id].reported_resume().is_some()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: std::time::Instant::now(), + }); + + assert!(state.terminals[&terminal_id].reported_resume().is_none()); + assert!(state.session_dirty); + + state.handle_app_event(AppEvent::AgentResumeReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: None, + argv: vec!["pi".into(), "--continue".into()], + }); + assert!( + state.terminals[&terminal_id].reported_resume().is_none(), + "a late report must not revive an exited agent" + ); + } + + #[test] + fn shell_return_drops_self_reported_agent_and_its_resume() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "a"]); + assert!(state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), None); + assert!(terminal.reported_resume().is_none()); + assert!(!terminal.self_reported_agent_active()); + } + + #[test] + fn delayed_shell_return_keeps_an_agent_that_claimed_the_pane_afterwards() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + let shell_seen_idle_at = std::time::Instant::now(); + report_custom_agent_with_resume(&mut state, pane_id, &["prime-agent", "--resume", "b"]); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: shell_seen_idle_at, + }); + + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.effective_agent_label(), Some("prime-agent")); + assert!(terminal.reported_resume().is_some()); + } + + #[test] + fn shell_return_keeps_agents_herdr_recognizes_by_process() { + let mut state = app_with_workspaces(&["one"]); + let (pane_id, terminal_id) = first_pane_terminal(&state); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state: AgentState::Working, + message: None, + seq: Some(1), + session_ref: None, + }); + assert!(!state.terminals[&terminal_id].self_reported_agent_active()); + + state.handle_app_event(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + state.terminals[&terminal_id].effective_agent_label(), + Some("pi") + ); + } + #[test] fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); diff --git a/src/app/api.rs b/src/app/api.rs index 62c8a6b5..20ca9d0f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -462,6 +462,7 @@ impl App { runtime.set_full_lifecycle_authority_active( terminal.full_lifecycle_hook_authority_active(), ); + runtime.set_self_reported_agent_active(terminal.self_reported_agent_active()); } } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 119d9298..e4e03e71 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1549,28 +1549,44 @@ impl App { id: String, params: PaneReportAgentParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), state: detect_state_from_api(params.state), message: params.message, seq: params.seq, }); - - encode_success(id, ResponseResult::Ok {}) + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) } pub(super) fn handle_pane_report_agent_session( @@ -1578,28 +1594,100 @@ impl App { id: String, params: PaneReportAgentSessionParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { + return encode_error(id, "invalid_resume_argv", message); + } + let report_is_newer = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); + let session_ref = crate::agent_resume::session_ref_from_report( + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::AgentSessionReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), - source: params.source, - agent_label, + session_ref: session_ref.clone(), + source: params.source.clone(), + agent_label: agent_label.clone(), seq: params.seq, session_start_source: crate::agent_resume::normalize_session_start_source( params.session_start_source, ), }); + let applied = + report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); + self.report_agent_resume( + id, + ws_idx, + pane_id, + params.source, + agent_label, + params.seq.filter(|_| applied), + applied.then_some(params.resume_argv).flatten(), + ) + } + /// A resume command belongs to the session it was reported with, so it is + /// kept only when Herdr accepted that session. + fn session_report_applied( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + session_ref: Option<&crate::agent_resume::AgentSessionRef>, + ) -> bool { + session_ref.is_none_or(|session_ref| { + self.pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.session_ref_is_current(session_ref)) + }) + } + + fn pane_terminal( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option<&crate::terminal::TerminalState> { + let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?; + self.state.terminals.get(&pane.attached_terminal_id) + } + + fn report_agent_resume( + &mut self, + id: String, + ws_idx: usize, + pane_id: crate::layout::PaneId, + source: String, + agent_label: String, + seq: Option<u64>, + resume_argv: Option<Vec<String>>, + ) -> String { + let Some(argv) = resume_argv else { + return encode_success(id, ResponseResult::Ok {}); + }; + let can_record = self + .pane_terminal(ws_idx, pane_id) + .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label)); + if !can_record { + return encode_error( + id, + "resume_not_accepted", + "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first", + ); + } + self.handle_internal_event(crate::events::AppEvent::AgentResumeReported { + pane_id, + source, + agent_label, + seq, + argv, + }); encode_success(id, ResponseResult::Ok {}) } @@ -2214,6 +2302,10 @@ fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } +fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> { + argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 35dbee2f..c9b37bfe 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -3328,6 +3328,7 @@ action = "missing" seq: None, agent_session_id: None, agent_session_path: None, + resume_argv: None, }, ); diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 3b900e17..33583ee4 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1156,8 +1156,9 @@ fn parse_pane_wait_output_args(args: &[String]) -> Result<PaneWaitForOutputParam } fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"; + const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [-- <resume-command...>]"; + let (args, resume_argv) = split_resume_argv(args); let args = super::expand_equals_args( args, &[ @@ -1282,12 +1283,21 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { seq, agent_session_id, agent_session_path, + resume_argv, })) } +fn split_resume_argv(args: &[String]) -> (&[String], Option<Vec<String>>) { + match args.iter().position(|arg| arg == "--") { + Some(separator) => (&args[..separator], Some(args[separator + 1..].to_vec())), + None => (args, None), + } +} + fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE]"; + const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE] [-- <resume-command...>]"; + let (args, resume_argv) = split_resume_argv(args); let args = super::expand_equals_args( args, &[ @@ -1398,6 +1408,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { agent_session_id, agent_session_path, session_start_source, + resume_argv, }, )) } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 9465dcc3..1cfc2e7f 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -646,6 +646,7 @@ fn report_agent_command() -> Command { .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) + .arg(resume_argv_arg()) } fn report_agent_session_command() -> Command { @@ -658,6 +659,15 @@ fn report_agent_session_command() -> Command { .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) .arg(option("session-start-source", "SOURCE")) + .arg(resume_argv_arg()) +} + +fn resume_argv_arg() -> Arg { + Arg::new("resume_argv") + .value_name("RESUME_ARG") + .num_args(0..) + .last(true) + .help("Command that resumes this session after a Herdr restart; starts with a plain command name") } fn release_agent_command() -> Command { diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 408ee669..7d7759d2 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -366,6 +366,11 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> { crate::platform::foreground_process_group_id(child_pid) } +/// True when the pane's own shell is at its prompt with nothing running in it. +pub fn pane_shell_is_idle(child_pid: u32) -> bool { + crate::platform::available_pane_shell(child_pid).is_some() +} + fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { let effective = process.argv0.as_deref().unwrap_or(&process.name); let lower_effective = effective.to_lowercase(); @@ -1928,6 +1933,49 @@ mod tests { #[cfg(target_os = "linux")] #[test] + fn pane_shell_is_idle_tracks_foreground_command() { + use portable_pty::CommandBuilder; + use std::io::Write; + + fn wait_for(expected: bool, pid: u32) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if pane_shell_is_idle(pid) == expected { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + false + } + + let pair = open_test_pty(); + let mut cmd = CommandBuilder::new("sh"); + cmd.arg("-i"); + cmd.env("ENV", "/dev/null"); + let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn"); + let pid = child.process_id().expect("no pid"); + let mut reader = pair.master.try_clone_reader().expect("no reader"); + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + while matches!(std::io::Read::read(&mut reader, &mut buf), Ok(n) if n > 0) {} + }); + let mut writer = pair.master.take_writer().expect("no writer"); + + assert!(wait_for(true, pid), "interactive shell should start idle"); + writer.write_all(b"sleep 999\n").unwrap(); + assert!(wait_for(false, pid), "shell running a command is not idle"); + writer.write_all(&[3]).unwrap(); + assert!( + wait_for(true, pid), + "shell should be idle after the command ends" + ); + + child.kill().ok(); + child.wait().ok(); + } + + #[cfg(target_os = "linux")] + #[test] fn proc_stat_parsing_handles_spaces_in_comm() { // Verify our /proc/pid/stat parser correctly extracts fields // even when (comm) could contain spaces. diff --git a/src/events.rs b/src/events.rs index 0e61f5f8..c87a50a3 100644 --- a/src/events.rs +++ b/src/events.rs @@ -120,6 +120,19 @@ pub enum AppEvent { session_ref: Option<crate::agent_resume::AgentSessionRef>, session_start_source: Option<String>, }, + /// A reporter supplied the command that resumes its own session. + AgentResumeReported { + pane_id: PaneId, + source: String, + agent_label: String, + seq: Option<u64>, + argv: Vec<String>, + }, + /// A pane held by a self-reported agent is back at its idle shell. + ReportedAgentShellReturned { + pane_id: PaneId, + observed_at: std::time::Instant, + }, /// Display-only agent metadata was reported for a pane. HookMetadataReported { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index 35534135..e5695675 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -363,6 +363,35 @@ async fn apply_agent_detection_publish_update( } const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; +const SELF_REPORTED_AGENT_SHELL_RECHECK: std::time::Duration = std::time::Duration::from_secs(1); + +/// Herdr cannot identify a self-reported agent's process, so the pane's shell +/// returning to an idle prompt is the only sign that the agent exited. +async fn report_self_reported_agent_shell_return( + active: &AtomicBool, + pid: u32, + now: std::time::Instant, + last_check: &mut Option<std::time::Instant>, + state_events: &mpsc::Sender<AppEvent>, + pane_id: PaneId, +) { + if pid == 0 || !active.load(Ordering::Acquire) { + *last_check = None; + return; + } + if last_check.is_some_and(|last| now.duration_since(last) < SELF_REPORTED_AGENT_SHELL_RECHECK) { + return; + } + *last_check = Some(now); + if crate::detect::pane_shell_is_idle(pid) { + let _ = state_events + .send(AppEvent::ReportedAgentShellReturned { + pane_id, + observed_at: now, + }) + .await; + } +} const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5); const PROCESS_RECHECK_MISSING_FOREGROUND_GROUP: std::time::Duration = std::time::Duration::from_secs(30); @@ -766,6 +795,7 @@ fn spawn_basic_detection_task( terminal: Arc<PaneTerminal>, detection_content_seq: Arc<AtomicU64>, full_lifecycle_authority_active: Arc<AtomicBool>, + self_reported_agent_active: Arc<AtomicBool>, state_events: mpsc::Sender<AppEvent>, ) -> ( tokio::task::AbortHandle, @@ -797,6 +827,7 @@ fn spawn_basic_detection_task( let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; + let mut last_self_reported_shell_check = None; loop { let sleep_duration = if pending_idle.active() { @@ -845,6 +876,15 @@ fn spawn_basic_detection_task( let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active.load(Ordering::Acquire); + report_self_reported_agent_shell_return( + &self_reported_agent_active, + pid, + now, + &mut last_self_reported_shell_check, + &state_events, + pane_id, + ) + .await; let foreground_pgid = (pid > 0) .then(|| crate::detect::foreground_process_group_id(pid)) .flatten(); @@ -1328,6 +1368,7 @@ pub struct PaneRuntime { content_write_lock: Arc<Mutex<()>>, detection_content_seq: Arc<AtomicU64>, full_lifecycle_authority_active: Arc<AtomicBool>, + self_reported_agent_active: Arc<AtomicBool>, detect_reset_notify: Arc<Notify>, pending_release: Arc<Mutex<Option<PendingAgentRelease>>>, preserve_processes_on_drop: bool, @@ -2461,12 +2502,14 @@ impl PaneRuntime { }; let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); + let self_reported_agent_active = Arc::new(AtomicBool::new(false)); let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task( pane_id, child_pid.clone(), terminal.clone(), detection_content_seq.clone(), full_lifecycle_authority_active.clone(), + self_reported_agent_active.clone(), events, ); @@ -2485,6 +2528,7 @@ impl PaneRuntime { content_write_lock, detection_content_seq, full_lifecycle_authority_active, + self_reported_agent_active, detect_reset_notify, pending_release, preserve_processes_on_drop: true, @@ -2544,6 +2588,7 @@ impl PaneRuntime { let content_seq = Arc::new(AtomicU64::new(0)); let detection_content_seq = Arc::new(AtomicU64::new(0)); let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false)); + let self_reported_agent_active = Arc::new(AtomicBool::new(false)); { let child_pid = child_pid.clone(); let child_wait_completed = child_wait_completed.clone(); @@ -2667,6 +2712,7 @@ impl PaneRuntime { let state_events = events.clone(); let detection_content_seq = detection_content_seq.clone(); let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone(); + let self_reported_agent_active_for_task = self_reported_agent_active.clone(); let render_notify = render_notify.clone(); let render_dirty = render_dirty.clone(); let detect_reset_notify = Arc::new(Notify::new()); @@ -2698,6 +2744,7 @@ impl PaneRuntime { let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; + let mut last_self_reported_shell_check = None; tokio::time::sleep(Duration::from_millis(50)).await; @@ -2755,6 +2802,15 @@ impl PaneRuntime { let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active_for_task.load(Ordering::Acquire); + report_self_reported_agent_shell_return( + &self_reported_agent_active_for_task, + pid, + now, + &mut last_self_reported_shell_check, + &state_events, + pane_id, + ) + .await; let process_probe_input = ProcessProbeInput { current_agent: agent, suppressed_agent, @@ -3075,6 +3131,7 @@ impl PaneRuntime { content_write_lock, detection_content_seq, full_lifecycle_authority_active, + self_reported_agent_active, detect_reset_notify, pending_release, preserve_processes_on_drop: false, @@ -3102,6 +3159,11 @@ impl PaneRuntime { self.detect_reset_notify.clone() } + pub fn set_self_reported_agent_active(&self, active: bool) { + self.self_reported_agent_active + .store(active, Ordering::Release); + } + pub fn set_full_lifecycle_authority_active(&self, active: bool) { let previous = self .full_lifecycle_authority_active @@ -3789,6 +3851,7 @@ impl PaneRuntime { content_write_lock: Arc::new(Mutex::new(())), detection_content_seq: Arc::new(AtomicU64::new(0)), full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)), + self_reported_agent_active: Arc::new(AtomicBool::new(false)), detect_reset_notify: Arc::new(Notify::new()), pending_release: Arc::new(Mutex::new(None)), preserve_processes_on_drop: true, @@ -4940,6 +5003,7 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + self_reported_agent_active: Arc::new(AtomicBool::new(false)), cwd_process_exited: Arc::new(AtomicBool::new(false)), persistence_cwd: Mutex::new(None), pane_id, @@ -4979,6 +5043,7 @@ mod tests { )); let compression = TerminalCompressionTask::spawn(pane_id, terminal.clone()); let runtime = PaneRuntime { + self_reported_agent_active: Arc::new(AtomicBool::new(false)), cwd_process_exited: Arc::new(AtomicBool::new(false)), persistence_cwd: Mutex::new(None), pane_id, diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 265b4874..e79a0f3b 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -15,7 +15,8 @@ use crate::terminal::{TerminalId, TerminalRuntime, TerminalState}; use crate::workspace::Workspace; use super::snapshot::{ - PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot, + PaneAgentResumeSnapshot, PaneAgentSessionSnapshot, PaneHistorySnapshot, TabHistorySnapshot, + WorkspaceHistorySnapshot, }; use super::{ DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, @@ -455,6 +456,9 @@ fn unavailable_restored_terminal( if let Some(session) = restored_terminal_agent_session(pane.agent_session.as_ref(), false) { terminal.set_persisted_agent_session(session); } + if let Some(resume) = saved_reported_resume(pane) { + terminal.restore_reported_resume(reported_resume_from_snapshot(resume)); + } match ( pane.agent_name.as_ref(), pane.managed_agent_kind @@ -530,6 +534,7 @@ fn restore_tab( .and_then(crate::detect::parse_canonical_agent_label); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); + let saved_agent_resume = saved_pane.and_then(saved_reported_resume); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); let startup = { @@ -537,8 +542,17 @@ fn restore_tab( enabled: runtime_context.resume_agents_on_restore, resumed_sessions: resumed_agent_sessions, }; - pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) + pane_restore_startup( + saved_agent_session, + saved_agent_resume, + &cwd, + saved_history, + &mut agent_restore, + ) }; + let restored_agent_resume = saved_agent_resume + .filter(|_| !startup.duplicate_agent_session) + .map(reported_resume_from_snapshot); let restored_agent_session = restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); let initial_restore_agent = startup @@ -580,6 +594,9 @@ fn restore_tab( if let Some(session) = restored_agent_session { terminal.set_persisted_agent_session(session); } + if let Some(resume) = restored_agent_resume { + terminal.restore_reported_resume(resume); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) => { terminal.restore_managed_agent(agent_name, agent) @@ -677,6 +694,9 @@ fn restore_tab( if let Some(session) = restored_agent_session { terminal.set_persisted_agent_session(session); } + if let Some(resume) = restored_agent_resume { + terminal.restore_reported_resume(resume); + } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) if was_imported => { terminal.restore_managed_agent(agent_name, agent) @@ -787,6 +807,8 @@ fn restore_tab( fn pane_restore_startup<'a>( session: Option<&PaneAgentSessionSnapshot>, + reported_resume: Option<&PaneAgentResumeSnapshot>, + cwd: &std::path::Path, history: Option<&'a PaneHistorySnapshot>, agent_restore: &mut AgentRestoreState<'_>, ) -> PaneRestoreStartup<'a> { @@ -794,8 +816,13 @@ fn pane_restore_startup<'a>( // resumable agent session and resume is enabled, do not replay saved pane // presentation history into that terminal, even when this pane is a // duplicate suppressed by session de-duplication. - let restore_plan = - session.and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); + let restore_plan = if !agent_restore.enabled { + None + } else if let Some(resume) = reported_resume { + Some(reported_resume_from_snapshot(resume).plan(cwd)) + } else { + session.and_then(|session| restore_plan_for_snapshot(session, true)) + }; let has_native_agent_restore = restore_plan.is_some(); // Reserve before spawning so later panes in the same restore pass cannot // launch the same native agent session. The caller rolls this reservation @@ -830,6 +857,22 @@ fn pane_restore_startup<'a>( } } +fn saved_reported_resume(pane: &super::snapshot::PaneSnapshot) -> Option<&PaneAgentResumeSnapshot> { + pane.agent_resume + .as_ref() + .filter(|resume| crate::agent_resume::validate_resume_argv(&resume.argv).is_ok()) +} + +fn reported_resume_from_snapshot( + resume: &PaneAgentResumeSnapshot, +) -> crate::agent_resume::ReportedAgentResume { + crate::agent_resume::ReportedAgentResume { + source: resume.source.clone(), + agent: resume.agent.clone(), + argv: resume.argv.clone(), + } +} + fn restore_plan_for_snapshot( session: &PaneAgentSessionSnapshot, resume_agents_on_restore: bool, @@ -1124,7 +1167,13 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(startup.restore_plan.is_some()); assert!(startup.initial_history_ansi.is_none()); @@ -1149,8 +1198,20 @@ mod tests { resumed_sessions: &mut resumed, }; - let first = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); - let duplicate = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let first = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); + let duplicate = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(first.restore_plan.is_some()); assert!(first.initial_history_ansi.is_none()); @@ -1160,6 +1221,79 @@ mod tests { } #[test] + fn pane_restore_startup_prefers_reported_resume_argv() { + let session = super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + kind: crate::agent_resume::AgentSessionRefKind::Path, + value: test_session_path("pi-session.jsonl"), + }; + let resume = super::super::snapshot::PaneAgentResumeSnapshot { + source: "herdr:pi".into(), + agent: "pi".into(), + argv: vec![ + "pi".into(), + "--continue".into(), + "--model".into(), + "m".into(), + ], + }; + let history = super::super::snapshot::PaneHistorySnapshot { + ansi: "RESTORED_HISTORY\r\n".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut agent_restore = AgentRestoreState { + enabled: true, + resumed_sessions: &mut resumed, + }; + + let project_a = std::path::Path::new("/project-a"); + let project_b = std::path::Path::new("/project-b"); + let startup = pane_restore_startup( + Some(&session), + Some(&resume), + project_a, + Some(&history), + &mut agent_restore, + ); + let plan = startup.restore_plan.expect("reported resume plan"); + assert_eq!(plan.agent, "pi"); + assert_eq!(plan.argv, resume.argv); + assert!(startup.initial_history_ansi.is_none()); + + let custom = super::super::snapshot::PaneAgentResumeSnapshot { + source: "prime-agent".into(), + agent: "prime-agent".into(), + argv: vec!["prime-agent".into(), "--continue".into()], + }; + let first = pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore); + assert_eq!(first.restore_plan.unwrap().argv, custom.argv); + let other_project = + pane_restore_startup(None, Some(&custom), project_b, None, &mut agent_restore); + assert!(other_project.restore_plan.is_some()); + let duplicate = + pane_restore_startup(None, Some(&custom), project_a, None, &mut agent_restore); + assert!(duplicate.restore_plan.is_none()); + assert!(duplicate.duplicate_agent_session); + + let mut resumed = HashSet::new(); + let mut disabled = AgentRestoreState { + enabled: false, + resumed_sessions: &mut resumed, + }; + let startup = pane_restore_startup( + None, + Some(&custom), + project_a, + Some(&history), + &mut disabled, + ); + assert!(startup.restore_plan.is_none()); + assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); + } + + #[test] fn pane_restore_startup_keeps_history_without_native_agent_resume() { let session = super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:pi".into(), @@ -1177,7 +1311,13 @@ mod tests { resumed_sessions: &mut resumed, }; - let startup = pane_restore_startup(Some(&session), Some(&history), &mut agent_restore); + let startup = pane_restore_startup( + Some(&session), + None, + std::path::Path::new("/a"), + Some(&history), + &mut agent_restore, + ); assert!(startup.restore_plan.is_none()); assert_eq!(startup.initial_history_ansi, Some("RESTORED_HISTORY\r\n")); @@ -1332,6 +1472,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "opencode-session".into(), }), + agent_resume: None, launch_argv: None, }, )]), @@ -1413,6 +1554,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ), @@ -1424,6 +1566,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ), @@ -1477,6 +1620,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ) @@ -1492,6 +1636,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + agent_resume: None, launch_argv: None, }; let snapshot = SessionSnapshot { @@ -1643,6 +1788,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "codex-session".into(), }), + agent_resume: None, launch_argv: None, }, )]), @@ -1948,6 +2094,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index d7db3e97..3518ef41 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -108,10 +108,19 @@ pub struct PaneSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option<PaneAgentSessionSnapshot>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_resume: Option<PaneAgentResumeSnapshot>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option<Vec<String>>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneAgentResumeSnapshot { + pub source: String, + pub agent: String, + pub argv: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneAgentSessionSnapshot { pub source: String, pub agent: String, @@ -362,6 +371,13 @@ fn capture_tab( value: session.session_ref.value.clone(), }) }); + let agent_resume = terminal + .and_then(|terminal| terminal.reported_resume()) + .map(|resume| PaneAgentResumeSnapshot { + source: resume.source.clone(), + agent: resume.agent.clone(), + argv: resume.argv.clone(), + }); panes.insert( id.raw(), PaneSnapshot { @@ -370,6 +386,7 @@ fn capture_tab( agent_name, managed_agent_kind, agent_session, + agent_resume, launch_argv, }, ); @@ -688,6 +705,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -699,6 +717,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -1287,6 +1306,40 @@ mod tests { } #[test] + fn capture_contract_includes_reported_agent_resume() { + let mut state = state_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + state.ensure_test_terminals(); + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_hook_authority( + "prime-agent".into(), + "prime-agent".into(), + crate::detect::AgentState::Idle, + None, + Some(1), + ); + assert!(terminal.record_reported_resume( + "prime-agent", + "prime-agent", + Some(1), + vec!["prime-agent".into(), "--resume".into(), "a".into()], + )); + + let snapshot = capture_from_state(&state); + let resume = snapshot.workspaces[0].tabs[0].panes[&root.raw()] + .agent_resume + .as_ref() + .expect("reported resume should be captured"); + + assert_eq!(resume.source, "prime-agent"); + assert_eq!(resume.agent, "prime-agent"); + assert_eq!(resume.argv, vec!["prime-agent", "--resume", "a"]); + } + + #[test] fn capture_contract_preserves_restored_agent_session() { let mut state = state_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; @@ -1350,6 +1403,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); @@ -1363,6 +1417,7 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: None, + agent_resume: None, launch_argv: None, }, ); diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 4def3fa4..bbb7bfe6 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -7289,6 +7289,142 @@ fn completion_guard_api_report(server: &mut HeadlessServer, method: api::schema: serde_json::from_str::<api::schema::SuccessResponse>(&response).expect("successful report"); } +#[test] +fn api_report_agent_stores_valid_resume_argv_and_rejects_invalid() { + let (writer, _control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let report = |resume_argv: Vec<&str>| { + api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id.clone(), + source: "prime-agent".into(), + agent: "prime-agent".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(1), + agent_session_id: Some("01a0".into()), + agent_session_path: None, + resume_argv: Some(resume_argv.into_iter().map(String::from).collect()), + }) + }; + + completion_guard_api_report( + &mut server, + report(vec!["prime-agent", "--resume", "01a0", "--model", "x"]), + ); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv, + vec!["prime-agent", "--resume", "01a0", "--model", "x"] + ); + + completion_guard_api_report(&mut server, report(vec!["prime-agent", "--resume", "dup"])); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv[2], + "01a0", + "a duplicate sequence number must not replace the command" + ); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "invalid-resume".into(), + method: report(vec!["/opt/prime/prime-agent", "--resume", "01a0"]), + }, + respond_to, + response_write_complete: None, + }); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + assert!(response.contains("invalid_resume_argv"), "{response}"); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .argv[0], + "prime-agent" + ); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "not-owner".into(), + method: api::schema::Method::PaneReportAgentSession( + api::schema::PaneReportAgentSessionParams { + pane_id: public_pane_id.clone(), + source: "custom:intruder".into(), + agent: "intruder".into(), + seq: None, + agent_session_id: None, + agent_session_path: None, + session_start_source: None, + resume_argv: Some(vec!["intruder".into()]), + }, + ), + }, + respond_to, + response_write_complete: None, + }); + let response = response_rx + .recv_timeout(Duration::from_millis(100)) + .unwrap(); + assert!(response.contains("resume_not_accepted"), "{response}"); + assert_eq!( + server.app.state.terminals[&terminal_id] + .reported_resume() + .unwrap() + .agent, + "prime-agent" + ); +} + +#[test] +fn api_resume_argv_is_ignored_when_its_session_report_is_refused() { + let (writer, _control_rx, _render_rx) = test_client_writer(); + let (mut server, pane_id) = completion_guard_server(writer); + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Claude, + observed_at: Instant::now(), + }); + let report = |session: &str| { + api::schema::Method::PaneReportAgentSession(api::schema::PaneReportAgentSessionParams { + pane_id: public_pane_id.clone(), + source: "herdr:claude".into(), + agent: "claude".into(), + seq: None, + agent_session_id: Some(session.into()), + agent_session_path: None, + session_start_source: None, + resume_argv: Some(vec!["claude".into(), "--resume".into(), session.into()]), + }) + }; + + completion_guard_api_report(&mut server, report("session-a")); + completion_guard_api_report(&mut server, report("session-b")); + + let terminal = &server.app.state.terminals[&terminal_id]; + assert!(terminal + .session_ref_is_current(&crate::agent_resume::AgentSessionRef::id("session-a").unwrap())); + assert_eq!( + terminal.reported_resume().unwrap().argv, + vec!["claude", "--resume", "session-a"] + ); +} + fn completion_guard_notifications( server: &mut HeadlessServer, receiver: &std::sync::mpsc::Receiver<Vec<u8>>, @@ -7347,6 +7483,7 @@ fn completion_guard_api_startup_blocker_respects_suppression() { seq: Some(seq as u64 + 1), agent_session_id: None, agent_session_path: None, + resume_argv: None, }), ); } @@ -7410,6 +7547,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() { seq: Some(11), agent_session_id: None, agent_session_path: Some(new_session.clone()), + resume_argv: None, session_start_source: Some(reason.into()), }), ); @@ -7422,6 +7560,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() { seq: Some(12), agent_session_id: None, agent_session_path: Some(new_session.clone()), + resume_argv: None, }; completion_guard_api_report(&mut server, Method::PaneReportAgent(report.clone())); let terminal = &server.app.state.terminals[&terminal_id]; @@ -7612,6 +7751,7 @@ fn stale_api_agent_report_does_not_forward_done_sound() { seq: Some(19), agent_session_id: None, agent_session_path: None, + resume_argv: None, }), }, respond_to, diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index ce7cab92..5f1c8a85 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -249,6 +249,10 @@ impl TerminalRuntime { self.0.set_full_lifecycle_authority_active(active); } + pub fn set_self_reported_agent_active(&self, active: bool) { + self.0.set_self_reported_agent_active(active); + } + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { self.0.resize(rows, cols, cell_width_px, cell_height_px); } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4a5abb12..11d540b1 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -137,6 +137,8 @@ pub struct TerminalState { pub agent_metadata: HashMap<String, AgentMetadata>, pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, + reported_resume: Option<crate::agent_resume::ReportedAgentResume>, + reported_resume_revision: u64, pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, @@ -175,6 +177,8 @@ impl TerminalState { agent_metadata: HashMap::new(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + reported_resume: None, + reported_resume_revision: 0, terminal_title: None, manual_label: None, agent_name: None, @@ -554,6 +558,15 @@ impl TerminalState { { self.persisted_agent_session = None; } + if !newer_custom_authority + && agent.is_some() + && self + .reported_resume + .as_ref() + .is_some_and(|resume| crate::detect::parse_agent_label(&resume.agent) == agent) + { + self.set_reported_resume(None); + } if let Some(agent) = agent { let agent_label = crate::detect::agent_label(agent); let mut cleared_metadata_sources = Vec::new(); @@ -1727,19 +1740,12 @@ impl TerminalState { } fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { - let Some(seq) = seq else { - return !self.hook_report_sequences.contains_key(source); - }; - - if self - .hook_report_sequences - .get(source) - .is_some_and(|last_seq| seq <= *last_seq) - { + if !self.hook_report_is_newer(source, seq) { return false; } - - self.hook_report_sequences.insert(source.to_string(), seq); + if let Some(seq) = seq { + self.hook_report_sequences.insert(source.to_string(), seq); + } true } @@ -1785,7 +1791,9 @@ impl TerminalState { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, ); - self.hook_authority = None; + if let Some(authority) = self.hook_authority.take() { + self.forget_reported_resume_of(&authority.source, &authority.agent_label); + } self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -1848,6 +1856,7 @@ impl TerminalState { self.clear_agent_name(); } self.hook_authority = None; + self.forget_reported_resume_of(source, agent_label); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1865,6 +1874,161 @@ impl TerminalState { }) } + /// A reporter that Herdr cannot also identify by process holds this pane, + /// so only the pane returning to its shell can tell Herdr it exited. + pub fn self_reported_agent_active(&self) -> bool { + self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label).is_none() + }) + } + + /// `observed_at` is when the idle shell was seen; a claim made after that + /// belongs to a newer agent and must survive the delayed signal. + pub fn clear_self_reported_agent( + &mut self, + observed_at: Instant, + ) -> Option<TerminalStateMutation> { + if !self.self_reported_agent_active() || !self.hook_authority_not_newer_than(observed_at) { + return None; + } + let now = Instant::now(); + let previous_agent_label = self.effective_agent_label().map(str::to_string); + let previous_known_agent = self.effective_known_agent(); + let previous_state = self.state; + let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); + let previous_session = self.current_session_identity_for_persistence(); + self.hook_authority = None; + self.set_reported_resume(None); + self.detected_agent = None; + self.fallback_state = AgentState::Unknown; + self.fallback_visible_blocker = false; + self.fallback_observed_at = None; + self.clear_agent_name(); + let current_session = self.current_session_identity_for_persistence(); + Some(TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session != current_session, + agent_released: true, + }) + } + + pub fn session_ref_is_current( + &self, + session_ref: &crate::agent_resume::AgentSessionRef, + ) -> bool { + self.current_session_identity_for_persistence() + .is_some_and(|(_, _, kind, value)| { + kind == session_ref.kind && value == session_ref.value + }) + } + + /// A reporter may own the resume command when it holds the pane, or when it + /// is an agent Herdr currently sees running there and can therefore see exit. + pub fn can_record_reported_resume(&self, source: &str, agent_label: &str) -> bool { + match self.hook_authority.as_ref() { + Some(authority) => authority.source == source && authority.agent_label == agent_label, + None => { + self.recent_agent_process_exit.is_none() + && self.detected_agent.is_some() + && crate::detect::parse_agent_label(agent_label) == self.detected_agent + } + } + } + + /// Same ordering rule as lifecycle reports, evaluated before the report is + /// applied so a duplicate cannot pass as the report that was just accepted. + pub fn hook_report_is_newer(&self, source: &str, seq: Option<u64>) -> bool { + let last_seq = self.hook_report_sequences.get(source).copied(); + match seq { + Some(seq) => last_seq.is_none_or(|last| seq > last), + None => last_seq.is_none(), + } + } + + /// Callers must first check `hook_report_is_newer` against the state + /// before the carrying report was applied. + pub fn record_reported_resume( + &mut self, + source: &str, + agent_label: &str, + seq: Option<u64>, + argv: Vec<String>, + ) -> bool { + if !self.can_record_reported_resume(source, agent_label) { + return false; + } + if let Some(seq) = seq { + let last = self + .hook_report_sequences + .entry(source.to_string()) + .or_insert(seq); + *last = (*last).max(seq); + } + let resume = crate::agent_resume::ReportedAgentResume { + source: source.to_string(), + agent: agent_label.to_string(), + argv, + }; + if self.reported_resume.as_ref() == Some(&resume) { + return false; + } + self.set_reported_resume(Some(resume)); + true + } + + pub fn reported_resume(&self) -> Option<&crate::agent_resume::ReportedAgentResume> { + self.reported_resume.as_ref() + } + + /// Changes whenever the stored resume command changes, so callers can + /// persist removals as well as new commands. + pub fn reported_resume_revision(&self) -> u64 { + self.reported_resume_revision + } + + /// Drops the command once a different agent holds the pane. + pub fn reconcile_reported_resume(&mut self) { + let Some(resume) = self.reported_resume.as_ref() else { + return; + }; + let other_authority = self.hook_authority.as_ref().is_some_and(|authority| { + authority.source != resume.source || authority.agent_label != resume.agent + }); + let other_process = self + .detected_agent + .is_some_and(|agent| crate::detect::agent_label(agent) != resume.agent); + if other_authority || other_process { + self.set_reported_resume(None); + } + } + + pub fn restore_reported_resume(&mut self, resume: crate::agent_resume::ReportedAgentResume) { + self.set_reported_resume(Some(resume)); + } + + fn set_reported_resume(&mut self, resume: Option<crate::agent_resume::ReportedAgentResume>) { + if self.reported_resume != resume { + self.reported_resume = resume; + self.reported_resume_revision += 1; + } + } + + fn forget_reported_resume_of(&mut self, source: &str, agent_label: &str) { + if self + .reported_resume + .as_ref() + .is_some_and(|resume| resume.source == source && resume.agent == agent_label) + { + self.set_reported_resume(None); + } + } + fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool { !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| { |
