aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-19 03:32:53 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-19 03:32:53 +0300
commit26c5f97a908c1ebe4228302d1b9e8c5423b2ab75 (patch)
tree43b7aaff6796451ffe2d13d9503ed58bf604e0d7 /src
parent9f4f1630893f74697a2097ef11bf5bf512a37850 (diff)
fix: reanchor lifecycle hook generations
refs #684
Diffstat (limited to 'src')
-rw-r--r--src/config/sound.rs1
-rw-r--r--src/detect/manifest.rs2
-rw-r--r--src/detect/manifest/tests.rs23
-rw-r--r--src/detect/mod.rs26
-rw-r--r--src/pane.rs97
-rw-r--r--src/terminal/state.rs866
6 files changed, 881 insertions, 134 deletions
diff --git a/src/config/sound.rs b/src/config/sound.rs
index 25376f2a..8624881a 100644
--- a/src/config/sound.rs
+++ b/src/config/sound.rs
@@ -126,6 +126,7 @@ impl AgentSoundOverrides {
Some(Agent::Devin) => self.devin,
Some(Agent::Antigravity) => self.agy,
Some(Agent::Cline) => self.cline,
+ Some(Agent::Omp) => AgentSoundSetting::Default,
Some(Agent::OpenCode) => self.open_code,
Some(Agent::GithubCopilot) => self.github_copilot,
Some(Agent::Kimi) => self.kimi,
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index a6da1277..f798e871 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -288,7 +288,7 @@ fn manifest_cache() -> &'static RwLock<ManifestCache> {
fn build_manifest_cache() -> ManifestCache {
ManifestCache {
- manifests: Agent::ALL
+ manifests: Agent::SCREEN_MANIFEST_AGENTS
.into_iter()
.map(|agent| (agent, load_manifest_uncached(agent)))
.collect(),
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index 2570aa03..30a94220 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -291,28 +291,7 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
#[test]
fn all_bundled_manifests_parse_and_validate() {
- let agents = [
- Agent::Pi,
- Agent::Claude,
- Agent::Codex,
- Agent::Gemini,
- Agent::Cursor,
- Agent::Devin,
- Agent::Antigravity,
- Agent::Cline,
- Agent::OpenCode,
- Agent::GithubCopilot,
- Agent::Kimi,
- Agent::Kiro,
- Agent::Droid,
- Agent::Amp,
- Agent::Grok,
- Agent::Hermes,
- Agent::Kilo,
- Agent::Qodercli,
- ];
-
- for agent in agents {
+ for agent in Agent::SCREEN_MANIFEST_AGENTS {
assert!(
bundled_manifest(agent).is_some(),
"missing bundled manifest for {}",
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 66443877..c8292ab2 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -49,6 +49,7 @@ pub enum Agent {
Devin,
Antigravity,
Cline,
+ Omp,
OpenCode,
GithubCopilot,
Kimi,
@@ -62,7 +63,7 @@ pub enum Agent {
}
impl Agent {
- pub const ALL: [Self; 18] = [
+ pub const SCREEN_MANIFEST_AGENTS: [Self; 18] = [
Self::Pi,
Self::Claude,
Self::Codex,
@@ -94,6 +95,7 @@ pub fn agent_label(agent: Agent) -> &'static str {
Agent::Devin => "devin",
Agent::Antigravity => "agy",
Agent::Cline => "cline",
+ Agent::Omp => "omp",
Agent::OpenCode => "opencode",
Agent::GithubCopilot => "copilot",
Agent::Kimi => "kimi",
@@ -118,6 +120,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> {
"devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
"agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
"cline" => Some(Agent::Cline),
+ "omp" => Some(Agent::Omp),
"opencode" | "open-code" => Some(Agent::OpenCode),
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
"kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi),
@@ -146,6 +149,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> {
"devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
"agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
"cline" => Some(Agent::Cline),
+ "omp" => Some(Agent::Omp),
"opencode" | "open-code" => Some(Agent::OpenCode),
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
"kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi),
@@ -592,6 +596,7 @@ mod tests {
assert_eq!(identify_agent("agy"), Some(Agent::Antigravity));
assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity));
assert_eq!(identify_agent("cline"), Some(Agent::Cline));
+ assert_eq!(identify_agent("omp"), Some(Agent::Omp));
assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode));
assert_eq!(identify_agent("opencode.exe"), Some(Agent::OpenCode));
assert_eq!(identify_agent("kimi"), Some(Agent::Kimi));
@@ -616,6 +621,7 @@ mod tests {
assert_eq!(parse_agent_label("devin-cli"), Some(Agent::Devin));
assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity));
assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity));
+ assert_eq!(parse_agent_label("omp"), Some(Agent::Omp));
assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode));
assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot));
assert_eq!(parse_agent_label("kimi-code"), Some(Agent::Kimi));
@@ -637,6 +643,7 @@ mod tests {
assert_eq!(agent_label(Agent::OpenCode), "opencode");
assert_eq!(agent_label(Agent::Devin), "devin");
assert_eq!(agent_label(Agent::Antigravity), "agy");
+ assert_eq!(agent_label(Agent::Omp), "omp");
assert_eq!(agent_label(Agent::Kiro), "kiro");
assert_eq!(agent_label(Agent::Grok), "grok");
assert_eq!(agent_label(Agent::Hermes), "hermes");
@@ -759,6 +766,23 @@ mod tests {
}
#[test]
+ fn identify_agent_in_job_detects_bun_wrapped_omp() {
+ let job = crate::platform::ForegroundJob {
+ process_group_id: 123,
+ processes: vec![foreground_process(
+ 123,
+ "bun",
+ &["bun", "/home/can/.bun/bin/omp"],
+ )],
+ };
+
+ assert_eq!(
+ identify_agent_in_job(&job),
+ Some((Agent::Omp, "omp".to_string()))
+ );
+ }
+
+ #[test]
fn identify_agent_in_job_detects_windows_cmd_wrapped_codex() {
let job = crate::platform::ForegroundJob {
process_group_id: 123,
diff --git a/src/pane.rs b/src/pane.rs
index 7cb3e12b..468eaad6 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -318,10 +318,13 @@ fn foreground_group_changed(
fn should_skip_process_probe_for_lifecycle_authority(
full_lifecycle_authority_active: bool,
- process_exit_pending: bool,
- release_pending: bool,
+ input: ProcessProbeInput,
) -> bool {
- full_lifecycle_authority_active && !process_exit_pending && !release_pending
+ full_lifecycle_authority_active
+ && !input.pending_foreground_shell_clear
+ && input.suppressed_agent.is_none()
+ && input.has_process_probe
+ && !foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid)
}
fn should_probe_foreground_job(input: ProcessProbeInput) -> bool {
@@ -518,9 +521,6 @@ fn spawn_basic_detection_task(
let pid = child_pid.load(Ordering::Acquire);
let mut agent_changed = false;
let mut agent = agent_presence.current_agent();
- let process_exit_pending = pending_foreground_shell_clear
- && agent.is_some()
- && !foreground_shell_exit_reported;
let lifecycle_authority_active =
full_lifecycle_authority_active.load(Ordering::Acquire);
let foreground_pgid = (pid > 0)
@@ -528,13 +528,8 @@ fn spawn_basic_detection_task(
.flatten();
let process_group_changed =
foreground_group_changed(foreground_pgid, last_foreground_pgid);
- let should_check_process = pid > 0
- && !should_skip_process_probe_for_lifecycle_authority(
- lifecycle_authority_active,
- process_exit_pending,
- suppressed_agent.is_some(),
- )
- && should_probe_foreground_job(ProcessProbeInput {
+ let should_check_process = pid > 0 && {
+ let process_probe_input = ProcessProbeInput {
current_agent: agent,
suppressed_agent,
foreground_pgid,
@@ -545,7 +540,12 @@ fn spawn_basic_detection_task(
pending_foreground_shell_clear,
pending_restore_probe: false,
elapsed_since_process_check: now.duration_since(last_process_check),
- });
+ };
+ !should_skip_process_probe_for_lifecycle_authority(
+ lifecycle_authority_active,
+ process_probe_input,
+ ) && should_probe_foreground_job(process_probe_input)
+ };
if should_check_process {
last_process_check = now;
@@ -1870,9 +1870,6 @@ impl PaneRuntime {
release_was_active = suppressed_agent.is_some();
let pid = child_pid.load(Ordering::Acquire);
let mut agent = agent_presence.current_agent();
- let process_exit_pending = pending_foreground_shell_clear
- && agent.is_some()
- && !foreground_shell_exit_reported;
let lifecycle_authority_active =
full_lifecycle_authority_active_for_task.load(Ordering::Acquire);
let foreground_pgid = (pid > 0)
@@ -1880,13 +1877,8 @@ impl PaneRuntime {
.flatten();
let process_group_changed =
foreground_group_changed(foreground_pgid, last_foreground_pgid);
- let should_check_process = pid > 0
- && !should_skip_process_probe_for_lifecycle_authority(
- lifecycle_authority_active,
- process_exit_pending,
- suppressed_agent.is_some(),
- )
- && should_probe_foreground_job(ProcessProbeInput {
+ let should_check_process = pid > 0 && {
+ let process_probe_input = ProcessProbeInput {
current_agent: agent,
suppressed_agent,
foreground_pgid,
@@ -1897,7 +1889,12 @@ impl PaneRuntime {
pending_foreground_shell_clear,
pending_restore_probe,
elapsed_since_process_check: now.duration_since(last_process_check),
- });
+ };
+ !should_skip_process_probe_for_lifecycle_authority(
+ lifecycle_authority_active,
+ process_probe_input,
+ ) && should_probe_foreground_job(process_probe_input)
+ };
let mut agent_changed = false;
if should_check_process {
@@ -3104,22 +3101,62 @@ mod tests {
}
#[test]
- fn lifecycle_authority_skips_normal_process_probe() {
+ fn lifecycle_authority_skips_stable_routine_process_probe() {
assert!(should_skip_process_probe_for_lifecycle_authority(
- true, false, false
+ true,
+ ProcessProbeInput {
+ current_agent: Some(Agent::Pi),
+ elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED,
+ ..process_probe_input()
+ }
));
assert!(!should_skip_process_probe_for_lifecycle_authority(
- false, false, false
+ false,
+ ProcessProbeInput {
+ current_agent: Some(Agent::Pi),
+ elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED,
+ ..process_probe_input()
+ }
));
}
#[test]
fn lifecycle_authority_preserves_process_exit_and_release_probes() {
assert!(!should_skip_process_probe_for_lifecycle_authority(
- true, true, false
+ true,
+ ProcessProbeInput {
+ current_agent: Some(Agent::Pi),
+ pending_foreground_shell_clear: true,
+ ..process_probe_input()
+ }
));
assert!(!should_skip_process_probe_for_lifecycle_authority(
- true, false, true
+ true,
+ ProcessProbeInput {
+ current_agent: Some(Agent::Pi),
+ suppressed_agent: Some(Agent::Pi),
+ ..process_probe_input()
+ }
+ ));
+ }
+
+ #[test]
+ fn lifecycle_authority_preserves_initial_and_foreground_group_change_probes() {
+ assert!(!should_skip_process_probe_for_lifecycle_authority(
+ true,
+ ProcessProbeInput {
+ current_agent: None,
+ has_process_probe: false,
+ ..process_probe_input()
+ }
+ ));
+ assert!(!should_skip_process_probe_for_lifecycle_authority(
+ true,
+ ProcessProbeInput {
+ current_agent: Some(Agent::Pi),
+ foreground_pgid: Some(43),
+ ..process_probe_input()
+ }
));
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index b1c0640c..01b6bdfe 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -32,6 +32,19 @@ struct SuppressedFullLifecycleHookReport {
agent_label: String,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
observed_at: Instant,
+ reason: FullLifecycleHookSuppressionReason,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum FullLifecycleHookSuppressionReason {
+ HookClear,
+ ProcessExit,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct StaleFullLifecycleHookSession {
+ agent_label: String,
+ session_ref: crate::agent_resume::AgentSessionRef,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -71,6 +84,7 @@ pub struct TerminalState {
pub agent_name: Option<String>,
hook_report_sequences: HashMap<String, u64>,
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
+ stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>,
metadata_report_sequences: HashMap<String, u64>,
pub state: AgentState,
pub last_agent_state_change_seq: Option<u64>,
@@ -96,6 +110,7 @@ impl TerminalState {
agent_name: None,
hook_report_sequences: HashMap::new(),
suppressed_full_lifecycle_hook_reports: HashMap::new(),
+ stale_full_lifecycle_hook_sessions: HashMap::new(),
metadata_report_sequences: HashMap::new(),
state: AgentState::Unknown,
last_agent_state_change_seq: None,
@@ -237,7 +252,16 @@ impl TerminalState {
crate::detect::parse_agent_label(&authority.agent_label) == agent
})
{
- self.suppress_current_full_lifecycle_hook_authority();
+ let cleared_source = self
+ .hook_authority
+ .as_ref()
+ .map(|authority| authority.source.clone());
+ self.suppress_current_full_lifecycle_hook_authority(
+ FullLifecycleHookSuppressionReason::ProcessExit,
+ );
+ if let Some(source) = cleared_source {
+ self.hook_report_sequences.remove(&source);
+ }
self.hook_authority = None;
}
if self.hook_authority_not_newer_than(now)
@@ -249,7 +273,9 @@ impl TerminalState {
== previous_detected_agent
})))
{
- self.suppress_current_full_lifecycle_hook_authority();
+ self.suppress_current_full_lifecycle_hook_authority(
+ FullLifecycleHookSuppressionReason::HookClear,
+ );
self.hook_authority = None;
}
let detected_agent_changed_or_disappeared =
@@ -345,15 +371,23 @@ impl TerminalState {
if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) {
return None;
}
- if !self.accept_hook_report(&source, seq) {
+ if self.full_lifecycle_hook_report_matches_stale_session(
+ &source,
+ &agent_label,
+ &session_ref,
+ ) {
return None;
}
-
- 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();
+ let reanchor_sequence =
+ self.full_lifecycle_hook_report_has_fresh_session_after_suppression(
+ &source,
+ &agent_label,
+ &session_ref,
+ ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session(
+ &source,
+ &agent_label,
+ &session_ref,
+ );
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
}
@@ -368,8 +402,28 @@ impl TerminalState {
) {
return None;
}
+ if reanchor_sequence {
+ self.hook_report_sequences.remove(&source);
+ }
+ if !self.accept_hook_report(&source, seq) {
+ return None;
+ }
+
+ 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();
if session_ref.is_some() {
- self.suppressed_full_lifecycle_hook_reports.remove(&source);
+ if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) {
+ if let Some(suppressed_ref) = suppressed.session_ref {
+ self.remember_stale_full_lifecycle_hook_session(
+ source.clone(),
+ suppressed.agent_label,
+ suppressed_ref,
+ );
+ }
+ }
}
self.persisted_agent_session = None;
self.hook_authority = Some(HookAuthority {
@@ -459,49 +513,72 @@ impl TerminalState {
.is_some_and(|session| session.source == source && session.agent == agent)
}
- fn suppress_current_full_lifecycle_hook_authority(&mut self) {
- if let Some((source, agent_label)) = self
- .hook_authority
- .as_ref()
- .filter(|authority| {
+ fn suppress_current_full_lifecycle_hook_authority(
+ &mut self,
+ reason: FullLifecycleHookSuppressionReason,
+ ) {
+ if let Some((source, agent_label, session_ref)) =
+ self.hook_authority.as_ref().and_then(|authority| {
crate::detect::full_lifecycle_hook_authority(
&authority.source,
&authority.agent_label,
)
+ .then(|| {
+ (
+ authority.source.clone(),
+ authority.agent_label.clone(),
+ authority.session_ref.clone(),
+ )
+ })
})
- .map(|authority| (authority.source.clone(), authority.agent_label.clone()))
{
- let session_ref = self
- .hook_authority
- .as_ref()
- .and_then(|authority| authority.session_ref.clone());
- self.suppressed_full_lifecycle_hook_reports.insert(
+ self.suppress_full_lifecycle_hook_report_with_session_ref(
source,
- SuppressedFullLifecycleHookReport {
- agent_label,
- session_ref,
- observed_at: Instant::now(),
- },
+ agent_label,
+ session_ref,
+ reason,
);
}
}
- fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) {
+ fn suppress_full_lifecycle_hook_report(
+ &mut self,
+ source: &str,
+ agent_label: &str,
+ reason: FullLifecycleHookSuppressionReason,
+ ) {
if crate::detect::full_lifecycle_hook_authority(source, agent_label) {
- self.suppressed_full_lifecycle_hook_reports.insert(
+ let session_ref = self
+ .hook_authority
+ .as_ref()
+ .and_then(|authority| authority.session_ref.clone());
+ self.suppress_full_lifecycle_hook_report_with_session_ref(
source.to_string(),
- SuppressedFullLifecycleHookReport {
- agent_label: agent_label.to_string(),
- session_ref: self
- .hook_authority
- .as_ref()
- .and_then(|authority| authority.session_ref.clone()),
- observed_at: Instant::now(),
- },
+ agent_label.to_string(),
+ session_ref,
+ reason,
);
}
}
+ fn suppress_full_lifecycle_hook_report_with_session_ref(
+ &mut self,
+ source: String,
+ agent_label: String,
+ session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ reason: FullLifecycleHookSuppressionReason,
+ ) {
+ self.suppressed_full_lifecycle_hook_reports.insert(
+ source,
+ SuppressedFullLifecycleHookReport {
+ agent_label,
+ session_ref,
+ observed_at: Instant::now(),
+ reason,
+ },
+ );
+ }
+
fn full_lifecycle_hook_report_is_suppressed(
&self,
source: &str,
@@ -517,6 +594,9 @@ impl TerminalState {
if suppressed.agent_label != agent_label {
return false;
}
+ if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit {
+ return true;
+ }
match (&suppressed.session_ref, session_ref) {
(Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref,
(Some(_), None) => true,
@@ -526,6 +606,71 @@ impl TerminalState {
})
}
+ fn full_lifecycle_hook_report_has_fresh_session_after_suppression(
+ &self,
+ source: &str,
+ agent_label: &str,
+ session_ref: &Option<crate::agent_resume::AgentSessionRef>,
+ ) -> bool {
+ if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
+ return false;
+ }
+ self.suppressed_full_lifecycle_hook_reports
+ .get(source)
+ .is_some_and(|suppressed| {
+ suppressed.agent_label == agent_label
+ && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit
+ && matches!(
+ (&suppressed.session_ref, session_ref),
+ (Some(suppressed_ref), Some(incoming_ref))
+ if incoming_ref != suppressed_ref
+ )
+ })
+ }
+
+ fn full_lifecycle_hook_report_matches_stale_session(
+ &self,
+ source: &str,
+ agent_label: &str,
+ session_ref: &Option<crate::agent_resume::AgentSessionRef>,
+ ) -> bool {
+ if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
+ return false;
+ }
+ self.stale_full_lifecycle_hook_sessions
+ .get(source)
+ .is_some_and(|stale_sessions| {
+ session_ref.as_ref().is_some_and(|incoming_ref| {
+ stale_sessions.iter().any(|stale| {
+ stale.agent_label == agent_label && incoming_ref == &stale.session_ref
+ })
+ })
+ })
+ }
+
+ fn full_lifecycle_hook_report_has_fresh_session_after_stale_session(
+ &self,
+ source: &str,
+ agent_label: &str,
+ session_ref: &Option<crate::agent_resume::AgentSessionRef>,
+ ) -> bool {
+ if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
+ return false;
+ }
+ self.stale_full_lifecycle_hook_sessions
+ .get(source)
+ .is_some_and(|stale_sessions| {
+ stale_sessions
+ .iter()
+ .any(|stale| stale.agent_label == agent_label)
+ && session_ref.as_ref().is_some_and(|incoming_ref| {
+ stale_sessions.iter().all(|stale| {
+ stale.agent_label != agent_label || incoming_ref != &stale.session_ref
+ })
+ })
+ })
+ }
+
fn live_full_lifecycle_hook_authority_conflicts_with_session(
&self,
source: &str,
@@ -560,10 +705,57 @@ impl TerminalState {
if previous_detected_agent == Some(detected_agent) {
return;
}
+ let detected_label = crate::detect::agent_label(detected_agent);
+ let mut stale_sessions = Vec::new();
self.suppressed_full_lifecycle_hook_reports
- .retain(|_, agent_label| {
- crate::detect::parse_agent_label(&agent_label.agent_label) != Some(detected_agent)
+ .retain(|source, suppressed| {
+ let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label)
+ == Some(detected_agent);
+ if should_clear {
+ if let Some(session_ref) = suppressed.session_ref.clone() {
+ stale_sessions.push((
+ source.clone(),
+ StaleFullLifecycleHookSession {
+ agent_label: suppressed.agent_label.clone(),
+ session_ref,
+ },
+ ));
+ }
+ }
+ !should_clear
});
+ for (source, stale_session) in stale_sessions {
+ self.remember_stale_full_lifecycle_hook_session(
+ source,
+ stale_session.agent_label,
+ stale_session.session_ref,
+ );
+ }
+ self.hook_report_sequences.retain(|source, _| {
+ !crate::detect::full_lifecycle_hook_authority(source, detected_label)
+ });
+ }
+
+ fn remember_stale_full_lifecycle_hook_session(
+ &mut self,
+ source: String,
+ agent_label: String,
+ session_ref: crate::agent_resume::AgentSessionRef,
+ ) {
+ let stale_session = StaleFullLifecycleHookSession {
+ agent_label,
+ session_ref,
+ };
+ let source_stale_sessions = self
+ .stale_full_lifecycle_hook_sessions
+ .entry(source)
+ .or_default();
+ if !source_stale_sessions
+ .iter()
+ .any(|existing| existing == &stale_session)
+ {
+ source_stale_sessions.push(stale_session);
+ }
}
fn detected_state_observed_before_release_suppression(
@@ -768,7 +960,9 @@ impl TerminalState {
if !should_clear {
return None;
}
- self.suppress_current_full_lifecycle_hook_authority();
+ self.suppress_current_full_lifecycle_hook_authority(
+ FullLifecycleHookSuppressionReason::HookClear,
+ );
self.hook_authority = None;
self.persisted_agent_session = None;
Some(TerminalStateMutation {
@@ -822,7 +1016,11 @@ impl TerminalState {
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.suppress_full_lifecycle_hook_report(source, agent_label);
+ self.suppress_full_lifecycle_hook_report(
+ source,
+ agent_label,
+ FullLifecycleHookSuppressionReason::HookClear,
+ );
self.detected_agent = None;
self.fallback_state = AgentState::Unknown;
self.fallback_visible_blocker = false;
@@ -905,6 +1103,7 @@ impl TerminalState {
self.persisted_agent_session = None;
self.agent_metadata.clear();
self.suppressed_full_lifecycle_hook_reports.clear();
+ self.stale_full_lifecycle_hook_sessions.clear();
self.state = AgentState::Unknown;
self.last_agent_state_change_seq = None;
self.launch_argv = None;
@@ -1046,8 +1245,9 @@ mod tests {
}
#[test]
- fn omp_hook_authority_works_without_detected_agent_variant() {
+ fn omp_hook_authority_overrides_detected_fallback() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle);
terminal.set_hook_authority(
"herdr:omp".into(),
"omp".into(),
@@ -1056,20 +1256,20 @@ mod tests {
None,
);
- assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.detected_agent, Some(Agent::Omp));
assert_eq!(terminal.effective_agent_label(), Some("omp"));
- assert_eq!(terminal.effective_known_agent(), None);
+ assert_eq!(terminal.effective_known_agent(), Some(Agent::Omp));
assert_eq!(terminal.state, AgentState::Working);
let change = terminal.set_detected_state_with_visible_blocker(
- None,
+ Some(Agent::Omp),
AgentState::Blocked,
true,
false,
false,
);
- assert_eq!(terminal.fallback_state, AgentState::Unknown);
+ assert_eq!(terminal.fallback_state, AgentState::Idle);
assert_eq!(terminal.state, AgentState::Working);
assert!(change.is_none());
}
@@ -1158,39 +1358,6 @@ mod tests {
}
#[test]
- fn process_exit_clears_omp_full_lifecycle_hook_authority_without_known_agent() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_hook_authority_with_custom_status_at(
- "herdr:omp".into(),
- "omp".into(),
- AgentState::Working,
- None,
- None,
- None,
- Some(10),
- now,
- );
-
- let change = terminal.set_detected_state_with_screen_signals_at(
- None,
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
-
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- assert_eq!(
- change.effective_state_change.unwrap().previous_state,
- AgentState::Working
- );
- }
-
- #[test]
fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() {
let now = Instant::now();
let mut terminal = test_terminal();
@@ -1360,6 +1527,91 @@ mod tests {
}
#[test]
+ fn changed_session_ref_reanchors_hook_sequence_after_release() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
+ Some(1000),
+ );
+ terminal.release_agent("herdr:pi", "pi", Some(3000));
+
+ let fresh = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
+ Some(1500),
+ );
+
+ assert!(fresh.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn stale_session_suppression_survives_multiple_release_generations() {
+ let mut terminal = test_terminal();
+ let session_a = test_session_path("release-generation-a.jsonl");
+ let session_b = test_session_path("release-generation-b.jsonl");
+ let session_c = test_session_path("release-generation-c.jsonl");
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_a.clone()),
+ Some(1000),
+ );
+ terminal.release_agent("herdr:pi", "pi", Some(2000));
+
+ let generation_b = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_b),
+ Some(1500),
+ );
+ assert!(generation_b.is_some());
+ terminal.release_agent("herdr:pi", "pi", Some(3000));
+
+ let late_generation_a = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some("late".into()),
+ crate::agent_resume::AgentSessionRef::path(session_a),
+ Some(2500),
+ );
+ let generation_c = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_c),
+ Some(2500),
+ );
+
+ assert!(late_generation_a.is_none());
+ assert!(generation_c.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
@@ -1432,6 +1684,393 @@ mod tests {
}
#[test]
+ fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1000),
+ );
+ let process_exit_seen_at = Instant::now() + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ process_exit_seen_at,
+ );
+
+ let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at + Duration::from_millis(1),
+ );
+ let fresh = terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(500),
+ );
+
+ assert!(fresh.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() {
+ let mut terminal = test_terminal();
+ let old_session = test_session_path("old-process-exit.jsonl");
+ let new_session = test_session_path("new-process-exit.jsonl");
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()),
+ Some(1000),
+ );
+ let process_exit_seen_at = Instant::now() + Duration::from_secs(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ process_exit_seen_at,
+ );
+
+ let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at + Duration::from_millis(1),
+ );
+
+ let late_old = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some("late".into()),
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(500),
+ );
+ let fresh_new = terminal.set_hook_authority_with_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(new_session),
+ Some(500),
+ );
+
+ assert!(late_old.is_none());
+ assert!(fresh_new.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn different_session_after_process_exit_waits_for_fresh_process_evidence() {
+ let mut terminal = test_terminal();
+ let old_session = test_session_path("old-before-process-exit.jsonl");
+ let new_session = test_session_path("new-after-process-exit.jsonl");
+ let now = Instant::now();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(1000),
+ now,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
+ );
+
+ let early_new = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(new_session.clone()),
+ Some(500),
+ now + Duration::from_millis(2),
+ );
+
+ assert!(early_new.is_none());
+ assert!(terminal.hook_authority.is_none());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(3),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(4),
+ );
+ let fresh_new = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(new_session),
+ Some(500),
+ now + Duration::from_millis(5),
+ );
+
+ assert!(fresh_new.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn missing_session_after_process_exit_waits_for_fresh_process_evidence() {
+ let mut terminal = test_terminal();
+ let old_session = test_session_path("old-before-nosession-process-exit.jsonl");
+ let now = Instant::now();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(1000),
+ now,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
+ );
+
+ let early_without_session = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ None,
+ Some(500),
+ now + Duration::from_millis(2),
+ );
+
+ assert!(early_without_session.is_none());
+ assert!(terminal.hook_authority.is_none());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(3),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(4),
+ );
+ let fresh_without_session = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ None,
+ Some(500),
+ now + Duration::from_millis(5),
+ );
+
+ assert!(fresh_without_session.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn stale_session_suppression_survives_multiple_process_generations() {
+ let mut terminal = test_terminal();
+ let session_a = test_session_path("generation-a.jsonl");
+ let session_b = test_session_path("generation-b.jsonl");
+ let session_c = test_session_path("generation-c.jsonl");
+ let now = Instant::now();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_a.clone()),
+ Some(1000),
+ now,
+ );
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(2),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(3),
+ );
+ let generation_b = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_b),
+ Some(500),
+ now + Duration::from_millis(4),
+ );
+ assert!(generation_b.is_some());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(5),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(6),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(7),
+ );
+
+ let late_generation_a = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some("late".into()),
+ crate::agent_resume::AgentSessionRef::path(session_a),
+ Some(250),
+ now + Duration::from_millis(8),
+ );
+ let generation_c = terminal.set_hook_authority_with_custom_status_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_c),
+ Some(250),
+ now + Duration::from_millis(9),
+ );
+
+ assert!(late_generation_a.is_none());
+ assert!(generation_c.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
fn release_suppression_ignores_same_agent_idle_publish() {
let now = Instant::now();
let mut terminal = test_terminal();
@@ -1496,8 +2135,6 @@ mod tests {
assert_eq!(terminal.state, AgentState::Working);
}
- // omp has no Agent identity (the process is pi), so its release suppression
- // can only be cleared by a fresh session ref, never by process detection.
// Regression for #614: a same-pane restart must reacquire lifecycle authority.
#[test]
fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() {
@@ -1543,6 +2180,75 @@ mod tests {
}
#[test]
+ fn omp_reacquires_full_lifecycle_hook_after_process_exit_with_fresh_process_and_session_ref() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle);
+ terminal.set_hook_authority_with_session_ref(
+ "herdr:omp".into(),
+ "omp".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::id("omp-old"),
+ Some(1000),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Omp),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
+ );
+
+ let stale = terminal.set_hook_authority_with_session_ref(
+ "herdr:omp".into(),
+ "omp".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::id("omp-old"),
+ Some(500),
+ );
+ assert!(stale.is_none());
+ assert!(terminal.hook_authority.is_none());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(2),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Omp),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ now + Duration::from_millis(3),
+ );
+ let fresh = terminal.set_hook_authority_with_session_ref(
+ "herdr:omp".into(),
+ "omp".into(),
+ AgentState::Working,
+ None,
+ None,
+ crate::agent_resume::AgentSessionRef::id("omp-new"),
+ Some(500),
+ );
+
+ assert!(fresh.is_some());
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
fn visible_blocker_overrides_non_blocked_hook_for_same_agent() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);