aboutsummaryrefslogtreecommitdiff
path: root/src/terminal/state.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/terminal/state.rs')
-rw-r--r--src/terminal/state.rs188
1 files changed, 176 insertions, 12 deletions
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| {