aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-27 20:57:00 +0300
committerGitHub <noreply@github.com>2026-09-27 20:57:00 +0300
commit2552d101afe46b8622a7695f38f6cd1766dd1afc (patch)
treee560c2cc8ef02689711af0865a5b541ee3050bf1 /src
parent0d1a39dcee6a0f244a036c55935970f40cfa76b8 (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.rs95
-rw-r--r--src/api/schema/panes.rs8
-rw-r--r--src/app/actions.rs225
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs132
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/cli/pane.rs15
-rw-r--r--src/cli/spec.rs10
-rw-r--r--src/detect/mod.rs48
-rw-r--r--src/events.rs13
-rw-r--r--src/pane.rs65
-rw-r--r--src/persist/restore.rs163
-rw-r--r--src/persist/snapshot.rs55
-rw-r--r--src/server/headless/tests/mod.rs140
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/terminal/state.rs188
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(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.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(
- &params.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(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.source, params.seq));
+ let session_ref = crate::agent_resume::session_ref_from_report(
+ &params.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(
- &params.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| {