aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 19:54:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 19:55:07 +0300
commit062259ba3e060c7463b4896a6f761cdf66d2f8ff (patch)
treedf034626fad636e06cc558b3771c3d96914da06f /src
parent623a1288a7719f53f7829b9fdbab33665e2ccc57 (diff)
fix: arbitrate stale agent state evidence
refs #249
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs163
-rw-r--r--src/app/input/mouse.rs5
-rw-r--r--src/app/mod.rs10
-rw-r--r--src/detect.rs317
-rw-r--r--src/events.rs7
-rw-r--r--src/pane.rs204
-rw-r--r--src/terminal/mod.rs1
-rw-r--r--src/terminal/state.rs742
8 files changed, 1398 insertions, 51 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index d6b3ae36..ee2e210a 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -876,9 +876,22 @@ impl AppState {
pane_id,
agent,
state,
+ visible_blocker,
+ visible_idle,
+ visible_working,
+ process_exited,
+ observed_at,
} => self
.update_terminal_state(pane_id, |terminal| {
- terminal.set_detected_state(agent, state)
+ terminal.set_detected_state_with_screen_signals_at(
+ agent,
+ state,
+ visible_blocker,
+ visible_idle,
+ visible_working,
+ process_exited,
+ observed_at,
+ )
})
.into_iter()
.collect(),
@@ -1610,6 +1623,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Working,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let terminal_id = state.workspaces[0]
@@ -1643,6 +1661,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
@@ -1668,6 +1691,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let terminal = state.terminals.get(&terminal_id).unwrap();
@@ -1686,6 +1714,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
@@ -1727,6 +1760,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let toast = state.toast.as_ref().unwrap();
@@ -1759,6 +1797,104 @@ mod tests {
}
#[test]
+ fn visible_blocker_overrides_hook_working_and_notifies() {
+ let mut state = app_with_workspaces(&["active", "background"]);
+ state.active = Some(0);
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
+ let bg_terminal_id = state.workspaces[1]
+ .panes
+ .get(&bg_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id: bg_pane_id,
+ agent: Some(Agent::Codex),
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id: bg_pane_id,
+ source: "herdr:codex".into(),
+ agent_label: "codex".into(),
+ state: AgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: Some(1),
+ });
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id: bg_pane_id,
+ agent: Some(Agent::Codex),
+ state: AgentState::Blocked,
+ visible_blocker: true,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+
+ let terminal = state.terminals.get(&bg_terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Blocked);
+ let toast = state.toast.as_ref().unwrap();
+ assert_eq!(toast.kind, ToastKind::NeedsAttention);
+ assert_eq!(toast.title, "codex needs attention");
+ }
+
+ #[test]
+ fn visible_idle_waits_before_overriding_claude_hook_working() {
+ let mut state = app_with_workspaces(&["active", "background"]);
+ state.active = Some(0);
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
+ let bg_terminal_id = state.workspaces[1]
+ .panes
+ .get(&bg_pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id: bg_pane_id,
+ agent: Some(Agent::Claude),
+ state: AgentState::Working,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id: bg_pane_id,
+ source: "herdr:claude".into(),
+ agent_label: "claude".into(),
+ state: AgentState::Working,
+ message: None,
+ custom_status: None,
+ seq: Some(1),
+ });
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id: bg_pane_id,
+ agent: Some(Agent::Claude),
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: true,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+
+ let terminal = state.terminals.get(&bg_terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(state.toast.is_none());
+ }
+
+ #[test]
fn background_idle_sets_finished_toast() {
let mut state = app_with_workspaces(&["active", "background"]);
state.active = Some(0);
@@ -1776,6 +1912,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Droid),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let toast = state.toast.as_ref().unwrap();
@@ -1801,6 +1942,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let toast = state.toast.as_ref().unwrap();
@@ -1823,6 +1969,11 @@ mod tests {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
let toast = state.toast.as_ref().unwrap();
@@ -1842,6 +1993,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
assert!(state.toast.is_none());
@@ -1859,6 +2015,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Blocked,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
assert!(state.toast.is_none());
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 691026de..87c14756 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -1723,6 +1723,11 @@ mod tests {
pane_id: target_pane,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
diff --git a/src/app/mod.rs b/src/app/mod.rs
index a1ae4a74..1eeba25b 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2566,6 +2566,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Working,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
assert_eq!(
app.state.terminals.get(&terminal_id).unwrap().state,
@@ -2586,6 +2591,11 @@ mod tests {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
});
tokio::pin!(send);
diff --git a/src/detect.rs b/src/detect.rs
index 8479ea70..9b973678 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -16,6 +16,24 @@ pub enum AgentState {
Unknown,
}
+/// Screen-derived agent state plus confidence metadata used for source arbitration.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AgentDetection {
+ pub state: AgentState,
+ /// True when the current screen visibly shows live UI chrome that needs
+ /// human input. This is stronger than arbitrary prompt-like text in the
+ /// scrollback and may override a non-blocked integration state.
+ pub visible_blocker: bool,
+ /// True when the current screen visibly shows the agent's idle input UI.
+ /// This lets Herdr recover from integrations that miss an interrupt/stop
+ /// event without treating an empty or ambiguous screen as idle authority.
+ pub visible_idle: bool,
+ /// True when the current screen visibly shows live working chrome. This is
+ /// narrower than a fallback `Working` heuristic and may guard against stale
+ /// hook idle reports.
+ pub visible_working: bool,
+}
+
/// Which agent we detected running in a pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
@@ -135,11 +153,22 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag
/// Detect the state of an agent from the live terminal tail snapshot.
/// If `agent` is `None`, returns `Unknown`.
+#[cfg(test)]
pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
+ detect_agent(agent, screen_content).state
+}
+
+/// Detect state and whether a visible blocker is present on the current screen.
+pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetection {
let Some(agent) = agent else {
- return AgentState::Unknown;
+ return AgentDetection {
+ state: AgentState::Unknown,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ };
};
- match agent {
+ let state = match agent {
Agent::Pi => detect_pi(screen_content),
Agent::Claude => detect_claude(screen_content),
Agent::Codex => detect_codex(screen_content),
@@ -155,6 +184,12 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
Agent::Amp => detect_amp(screen_content),
Agent::Grok => detect_grok(screen_content),
Agent::Hermes => detect_hermes(screen_content),
+ };
+ AgentDetection {
+ state,
+ visible_blocker: has_visible_blocker(agent, screen_content, state),
+ visible_idle: has_visible_idle(agent, screen_content, state),
+ visible_working: has_visible_working(agent, screen_content, state),
}
}
@@ -201,14 +236,7 @@ fn detect_claude(content: &str) -> AgentState {
// --- Working detection (content above the prompt box) ---
- let above = content_above_prompt_box(content);
- let above_lower = above.to_lowercase();
-
- if above_lower.contains("esc to interrupt") || above_lower.contains("ctrl+c to interrupt") {
- return AgentState::Working;
- }
-
- if has_spinner_activity(above) {
+ if has_claude_working_chrome(content) {
return AgentState::Working;
}
@@ -221,6 +249,7 @@ fn detect_codex(content: &str) -> AgentState {
// Blocked patterns
if lower.contains("press enter to confirm or esc to cancel")
|| lower.contains("enter to submit answer")
+ || lower.contains("enter to submit all")
|| lower.contains("allow command?")
|| lower.contains("[y/n]")
|| lower.contains("yes (y)")
@@ -681,6 +710,86 @@ fn has_spinner_activity(content: &str) -> bool {
false
}
+fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool {
+ if state != AgentState::Blocked {
+ return false;
+ }
+
+ match agent {
+ // Strong visible blockers are opt-in because this flag can override
+ // hook authority. Plain blocked heuristics remain valid fallback state,
+ // but they must not become hook overrides unless the current UI chrome
+ // is known to be structural and live.
+ Agent::Claude => has_claude_visible_blocker(content),
+ Agent::Codex => has_codex_visible_blocker(content),
+ _ => false,
+ }
+}
+
+fn has_claude_visible_blocker(content: &str) -> bool {
+ let lower = content.to_lowercase();
+ lower.contains("do you want to proceed?")
+ && has_claude_yes_no_choice(content)
+ && (lower.contains("bash command")
+ || lower.contains("bash(")
+ || lower.contains("contains expansion")
+ || lower.contains("tab to amend")
+ || lower.contains("ctrl+e to explain"))
+}
+
+fn has_codex_visible_blocker(content: &str) -> bool {
+ let lower = content.to_lowercase();
+ lower.contains("press enter to confirm or esc to cancel")
+ || lower.contains("enter to submit answer")
+ || lower.contains("enter to submit all")
+ || lower.contains("allow command?")
+}
+
+fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool {
+ if state != AgentState::Idle {
+ return false;
+ }
+
+ match agent {
+ Agent::Claude => has_claude_prompt_box(content),
+ Agent::Codex => has_codex_prompt(content),
+ _ => false,
+ }
+}
+
+fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool {
+ if state != AgentState::Working {
+ return false;
+ }
+
+ match agent {
+ Agent::Claude => has_claude_working_chrome(content),
+ Agent::Codex => has_codex_visible_working(content),
+ _ => false,
+ }
+}
+
+fn has_codex_visible_working(content: &str) -> bool {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(working_index) = lines.iter().rposition(|line| {
+ let trimmed = line.trim_start();
+ let lower = trimmed.to_lowercase();
+ trimmed.starts_with('•')
+ && trimmed.contains("Working (")
+ && (lower.contains("esc to interrupt") || lower.contains("esc…"))
+ }) else {
+ return false;
+ };
+
+ lines[working_index + 1..].iter().all(|line| {
+ let trimmed = line.trim_start();
+ !trimmed.starts_with('•')
+ && !trimmed.starts_with('■')
+ && !trimmed.starts_with('✗')
+ && !trimmed.starts_with('✓')
+ })
+}
+
fn has_codex_working_header(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();
@@ -688,6 +797,13 @@ fn has_codex_working_header(content: &str) -> bool {
})
}
+fn has_codex_prompt(content: &str) -> bool {
+ content.lines().any(|line| {
+ let trimmed = line.trim_start();
+ trimmed == "›" || trimmed.starts_with("› ")
+ })
+}
+
fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool {
if lower.contains("waiting for approval") || lower.contains("run this command?") {
return true;
@@ -799,26 +915,58 @@ fn has_opencode_question_prompt(content: &str) -> bool {
lower.contains("esc dismiss") && has_enter_action && has_question_nav
}
+fn has_claude_working_chrome(content: &str) -> bool {
+ let above = content_above_prompt_box(content);
+ let above_lower = above.to_lowercase();
+ above_lower.contains("esc to interrupt")
+ || above_lower.contains("ctrl+c to interrupt")
+ || has_spinner_activity(above)
+}
+
/// Extract content above Claude's prompt box.
/// The prompt box is two ─── border lines with ❯ between them.
fn content_above_prompt_box(content: &str) -> &str {
let lines: Vec<&str> = content.lines().collect();
+
+ if let Some(i) = claude_prompt_box_top_border_index(&lines) {
+ let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
+ return &content[..byte_offset.min(content.len())];
+ }
+
+ // No prompt box found, return all content
+ content
+}
+
+fn has_claude_prompt_box(content: &str) -> bool {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(top_border_index) = claude_prompt_box_top_border_index(&lines) else {
+ return false;
+ };
+
+ lines[top_border_index + 1..]
+ .iter()
+ .take_while(|line| !is_horizontal_rule(line))
+ .any(|line| line.trim_start().starts_with('❯'))
+}
+
+fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
let mut border_count = 0;
for i in (0..lines.len()).rev() {
- let trimmed = lines[i].trim();
- if !trimmed.is_empty() && trimmed.chars().all(|c| c == '─') {
+ if is_horizontal_rule(lines[i]) {
border_count += 1;
if border_count == 2 {
- // Return everything above this border
- let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
- return &content[..byte_offset.min(content.len())];
+ return Some(i);
}
}
}
- // No prompt box found, return all content
- content
+ None
+}
+
+fn is_horizontal_rule(line: &str) -> bool {
+ let trimmed = line.trim();
+ !trimmed.is_empty() && trimmed.chars().all(|c| c == '─')
}
// ---------------------------------------------------------------------------
@@ -1402,6 +1550,25 @@ mod tests {
}
#[test]
+ fn claude_bash_permission_modal_is_visible_blocker() {
+ let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn claude_cropped_bash_permission_modal_is_visible_blocker() {
+ let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ }
+
+ #[test]
fn claude_waiting_ask_user_question_menu() {
let screen =
"Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
@@ -1427,6 +1594,33 @@ mod tests {
}
#[test]
+ fn claude_prompt_box_is_visible_idle() {
+ let screen = "Interrupted.\n─────────────\n❯ \n─────────────";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ }
+
+ #[test]
+ fn claude_separators_without_prompt_are_not_visible_idle() {
+ let screen = "Task complete.\n─────────────\nplain text\n─────────────";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn claude_spinner_above_prompt_box_is_working() {
+ let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project";
+ let detection = detect_agent(Some(Agent::Claude), screen);
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
fn claude_idle_search() {
let screen = "⌕ Search…\nsome content";
assert_eq!(detect_claude(screen), AgentState::Idle);
@@ -1463,6 +1657,42 @@ mod tests {
}
#[test]
+ fn codex_question_ui_is_visible_blocker() {
+ let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt";
+ let detection = detect_agent(Some(Agent::Codex), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ }
+
+ #[test]
+ fn codex_bare_yes_no_hint_is_not_visible_blocker() {
+ let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts.");
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
+ fn codex_generic_confirmation_prompt_is_not_visible_blocker() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "Earlier output asked: do you want to continue? The answer was yes.",
+ );
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
+ fn non_codex_blocked_heuristics_are_not_strong_visible_blockers_by_default() {
+ let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No");
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(!detection.visible_blocker);
+ }
+
+ #[test]
fn codex_waiting_submit_answer_wrapped_footer() {
assert_eq!(
detect_codex(
@@ -1473,6 +1703,25 @@ mod tests {
}
#[test]
+ fn codex_waiting_submit_all_multi_question_footer() {
+ let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt";
+ let detection = detect_agent(Some(Agent::Codex), screen);
+
+ assert_eq!(detection.state, AgentState::Blocked);
+ assert!(detection.visible_blocker);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_interrupted_prompt_is_visible_idle() {
+ let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration";
+ let detection = detect_agent(Some(Agent::Codex), screen);
+
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ }
+
+ #[test]
fn codex_working_interrupt() {
assert_eq!(
detect_codex("generating code\nesc to interrupt"),
@@ -1486,6 +1735,40 @@ mod tests {
}
#[test]
+ fn codex_status_line_is_visible_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
+ assert!(!detection.visible_idle);
+ }
+
+ #[test]
+ fn codex_working_header_without_interrupt_is_not_visible_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Working (17s)\n\n› Implement {feature}",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(!detection.visible_working);
+ }
+
+ #[test]
+ fn codex_old_working_line_before_later_block_is_not_visible_working() {
+ let detection = detect_agent(
+ Some(Agent::Codex),
+ "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
+ );
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(!detection.visible_working);
+ }
+
+ #[test]
fn codex_idle() {
assert_eq!(detect_codex("❯ "), AgentState::Idle);
}
diff --git a/src/events.rs b/src/events.rs
index 90a65957..4416d00d 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -3,6 +3,8 @@
//! Background tasks (PTY child watchers, future hook listeners, etc.) send
//! events to the main loop through this channel. No polling needed.
+use std::time::Instant;
+
use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
use crate::workspace::WorkspaceGitStatus;
@@ -30,6 +32,11 @@ pub enum AppEvent {
pane_id: PaneId,
agent: Option<Agent>,
state: AgentState,
+ visible_blocker: bool,
+ visible_idle: bool,
+ visible_working: bool,
+ process_exited: bool,
+ observed_at: Instant,
},
/// Hook-authoritative agent state was reported for a pane.
HookStateReported {
diff --git a/src/pane.rs b/src/pane.rs
index 10112f0f..20eb43da 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -25,7 +25,6 @@ pub use self::{
state::PaneState,
terminal::{InputState, ScrollMetrics, TerminalCursorState},
};
-use crate::terminal::stabilize_agent_state;
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
const PANE_TERM: &str = "xterm-256color";
@@ -66,6 +65,11 @@ async fn publish_state_changed_event(
pane_id: PaneId,
agent: Option<Agent>,
state: AgentState,
+ visible_blocker: bool,
+ visible_idle: bool,
+ visible_working: bool,
+ process_exited: bool,
+ observed_at: std::time::Instant,
) {
// This runs on the async detector task, not the PTY reader thread.
// Waiting for queue space here preserves correctness-critical state transitions
@@ -75,6 +79,11 @@ async fn publish_state_changed_event(
pane_id,
agent,
state,
+ visible_blocker,
+ visible_idle,
+ visible_working,
+ process_exited,
+ observed_at,
})
.await
{
@@ -102,6 +111,57 @@ fn should_clear_agent_for_foreground_shell(
previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ForegroundShellAgentAction {
+ ObserveProbe,
+ ReportProcessExit,
+ ClearAgent,
+}
+
+fn foreground_shell_agent_action(
+ previous_agent: Option<Agent>,
+ new_agent: Option<Agent>,
+ foreground_is_pane_shell: bool,
+ process_exit_reported: bool,
+) -> ForegroundShellAgentAction {
+ if !should_clear_agent_for_foreground_shell(previous_agent, new_agent, foreground_is_pane_shell)
+ {
+ return ForegroundShellAgentAction::ObserveProbe;
+ }
+
+ // Do not clear identity immediately. First publish an idle process-exit
+ // transition for the previous agent so notifications and wait-agent callers
+ // observe completion before the pane becomes unknown.
+ if process_exit_reported {
+ ForegroundShellAgentAction::ClearAgent
+ } else {
+ ForegroundShellAgentAction::ReportProcessExit
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct DetectionPublishState {
+ state: AgentState,
+ visible_blocker: bool,
+ visible_idle: bool,
+ visible_working: bool,
+}
+
+fn should_publish_detection_update(
+ previous: DetectionPublishState,
+ next: DetectionPublishState,
+ agent_changed: bool,
+ process_exited: bool,
+) -> bool {
+ next.state != previous.state
+ || next.visible_blocker != previous.visible_blocker
+ || next.visible_idle != previous.visible_idle
+ || next.visible_working != previous.visible_working
+ || agent_changed
+ || process_exited
+ || (next.visible_idle && previous.visible_idle)
+}
+
impl AgentDetectionPresence {
fn from_agent(current_agent: Option<Agent>) -> Self {
Self {
@@ -550,7 +610,11 @@ impl PaneRuntime {
let mut last_process_check = Instant::now();
let mut last_foreground_pgid = None;
let mut pending_foreground_shell_clear = false;
+ let mut foreground_shell_exit_reported = false;
let mut last_claude_working_at = None;
+ let mut last_visible_blocker = false;
+ let mut last_visible_idle = false;
+ let mut last_visible_working = false;
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -572,7 +636,11 @@ impl PaneRuntime {
state = AgentState::Unknown;
last_foreground_pgid = None;
pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
last_claude_working_at = None;
+ last_visible_blocker = false;
+ last_visible_idle = false;
+ last_visible_working = false;
}
}
@@ -627,21 +695,26 @@ impl PaneRuntime {
}
let previous_agent = agent_presence.current_agent();
- let changed = if should_clear_agent_for_foreground_shell(
+ let changed = match foreground_shell_agent_action(
previous_agent,
new_agent,
foreground_is_pane_shell,
+ foreground_shell_exit_reported,
) {
- if state == AgentState::Idle {
- pending_foreground_shell_clear = false;
- agent_presence.clear_current_agent()
- } else {
+ ForegroundShellAgentAction::ReportProcessExit => {
pending_foreground_shell_clear = true;
false
}
- } else {
- pending_foreground_shell_clear = false;
- agent_presence.observe_process_probe(new_agent)
+ ForegroundShellAgentAction::ClearAgent => {
+ pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
+ agent_presence.clear_current_agent()
+ }
+ ForegroundShellAgentAction::ObserveProbe => {
+ pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
+ agent_presence.observe_process_probe(new_agent)
+ }
};
if new_agent.is_some() {
last_foreground_pgid = process_group_id;
@@ -683,16 +756,50 @@ impl PaneRuntime {
}
let content = terminal.detection_text();
- let raw_state = detect::detect_state(agent, &content);
- let new_state = stabilize_agent_state(
+ let process_exited = pending_foreground_shell_clear
+ && agent.is_some()
+ && !foreground_shell_exit_reported;
+ let detection = if process_exited {
+ detect::AgentDetection {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ }
+ } else {
+ detect::detect_agent(agent, &content)
+ };
+ let raw_state = detection.state;
+ let new_state = crate::terminal::state::stabilize_agent_detection(
agent,
state,
- raw_state,
+ detection,
+ process_exited,
now,
&mut last_claude_working_at,
);
-
- if new_state != state || agent_changed {
+ let visible_blocker =
+ detection.visible_blocker && new_state == AgentState::Blocked;
+ let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
+ let visible_working =
+ detection.visible_working && new_state == AgentState::Working;
+
+ if should_publish_detection_update(
+ DetectionPublishState {
+ state,
+ visible_blocker: last_visible_blocker,
+ visible_idle: last_visible_idle,
+ visible_working: last_visible_working,
+ },
+ DetectionPublishState {
+ state: new_state,
+ visible_blocker,
+ visible_idle,
+ visible_working,
+ },
+ agent_changed,
+ process_exited,
+ ) {
debug!(
pane = pane_id.raw(),
?state,
@@ -702,13 +809,24 @@ impl PaneRuntime {
"state changed"
);
state = new_state;
+ last_visible_blocker = visible_blocker;
+ last_visible_idle = visible_idle;
+ last_visible_working = visible_working;
publish_state_changed_event(
state_events.clone(),
pane_id,
agent,
new_state,
+ visible_blocker,
+ visible_idle,
+ visible_working,
+ process_exited,
+ now,
)
.await;
+ if process_exited {
+ foreground_shell_exit_reported = true;
+ }
}
}
});
@@ -1218,6 +1336,46 @@ mod tests {
}
#[test]
+ fn foreground_shell_reports_process_exit_before_clearing_agent() {
+ assert_eq!(
+ foreground_shell_agent_action(Some(Agent::Codex), None, true, false),
+ ForegroundShellAgentAction::ReportProcessExit
+ );
+ assert_eq!(
+ foreground_shell_agent_action(Some(Agent::Codex), None, true, true),
+ ForegroundShellAgentAction::ClearAgent
+ );
+ }
+
+ #[test]
+ fn stable_visible_idle_republishes_for_stale_hook_deadline() {
+ let previous = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: true,
+ visible_working: false,
+ };
+
+ assert!(should_publish_detection_update(
+ previous, previous, false, false
+ ));
+ }
+
+ #[test]
+ fn stable_plain_idle_does_not_republish() {
+ let previous = DetectionPublishState {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ };
+
+ assert!(!should_publish_detection_update(
+ previous, previous, false, false
+ ));
+ }
+
+ #[test]
fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() {
assert!(!should_clear_agent_for_foreground_shell(
Some(Agent::Claude),
@@ -1274,8 +1432,17 @@ mod tests {
})
.unwrap();
- let publish =
- publish_state_changed_event(tx.clone(), pane_id, Some(Agent::Pi), AgentState::Idle);
+ let publish = publish_state_changed_event(
+ tx.clone(),
+ pane_id,
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ std::time::Instant::now(),
+ );
tokio::pin!(publish);
let blocked = tokio::time::timeout(std::time::Duration::from_millis(20), async {
@@ -1309,6 +1476,11 @@ mod tests {
pane_id: delivered_pane,
agent: Some(Agent::Pi),
state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: _,
} if delivered_pane == pane_id
));
}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 588d5d76..7e43f7a8 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -6,5 +6,4 @@ pub mod state;
pub use id::TerminalId;
pub use runtime::TerminalRuntime;
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
-pub(crate) use state::stabilize_agent_state;
pub use state::{EffectiveStateChange, TerminalState};
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 5b2d956d..7f9a254a 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -1,10 +1,18 @@
use std::collections::HashMap;
use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+// Effective state arbitration is intentionally centralized here. Hooks are the
+// default authority for agent-owned internal state, but a narrow set of strong
+// visible screen signals can veto stale non-blocked hook reports. Precedence is:
+// hook blocked > strong visible blocker > Claude visible idle > hook > fallback.
+// Process-exit updates clear matching hook authority before recomputing state.
use crate::detect::{Agent, AgentState};
use crate::terminal::TerminalId;
-const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
+const CLAUDE_WORKING_HOLD: Duration = Duration::from_millis(1200);
+const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookAuthority {
@@ -13,6 +21,7 @@ pub struct HookAuthority {
pub state: AgentState,
pub message: Option<String>,
pub custom_status: Option<String>,
+ pub reported_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -36,6 +45,11 @@ pub struct TerminalState {
pub cwd: PathBuf,
pub detected_agent: Option<Agent>,
pub fallback_state: AgentState,
+ fallback_visible_blocker: bool,
+ fallback_visible_idle: bool,
+ fallback_visible_working: bool,
+ fallback_observed_at: Option<Instant>,
+ stale_hook_idle_since: Option<Instant>,
pub hook_authority: Option<HookAuthority>,
pub manual_label: Option<String>,
pub agent_name: Option<String>,
@@ -52,6 +66,11 @@ impl TerminalState {
cwd,
detected_agent: None,
fallback_state: AgentState::Unknown,
+ fallback_visible_blocker: false,
+ fallback_visible_idle: false,
+ fallback_visible_working: false,
+ fallback_observed_at: None,
+ stale_hook_idle_since: None,
hook_authority: None,
manual_label: None,
agent_name: None,
@@ -67,28 +86,83 @@ impl TerminalState {
self
}
+ #[cfg(test)]
pub fn set_detected_state(
&mut self,
agent: Option<Agent>,
fallback_state: AgentState,
) -> Option<EffectiveStateChange> {
+ self.set_detected_state_with_visible_blocker(agent, fallback_state, false, false, false)
+ }
+
+ #[cfg(test)]
+ pub fn set_detected_state_with_visible_blocker(
+ &mut self,
+ agent: Option<Agent>,
+ fallback_state: AgentState,
+ visible_blocker: bool,
+ visible_idle: bool,
+ process_exited: bool,
+ ) -> Option<EffectiveStateChange> {
+ self.set_detected_state_with_screen_signals_at(
+ agent,
+ fallback_state,
+ visible_blocker,
+ visible_idle,
+ false,
+ process_exited,
+ Instant::now(),
+ )
+ }
+
+ pub fn set_detected_state_with_screen_signals_at(
+ &mut self,
+ agent: Option<Agent>,
+ fallback_state: AgentState,
+ visible_blocker: bool,
+ visible_idle: bool,
+ visible_working: bool,
+ process_exited: bool,
+ now: Instant,
+ ) -> Option<EffectiveStateChange> {
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_detected_agent = self.detected_agent;
self.detected_agent = agent;
self.fallback_state = fallback_state;
- if self.hook_authority_conflicts_with_detected_agent(agent)
- || (previous_detected_agent.is_some()
- && agent != previous_detected_agent
- && self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::parse_agent_label(&authority.agent_label)
- == previous_detected_agent
- }))
+ self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked;
+ self.fallback_visible_idle = visible_idle && fallback_state == AgentState::Idle;
+ self.fallback_visible_working = visible_working && fallback_state == AgentState::Working;
+ self.fallback_observed_at = Some(now);
+ if process_exited
+ && self.hook_authority_not_newer_than(now)
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label) == agent
+ })
+ {
+ self.hook_authority = None;
+ self.stale_hook_idle_since = None;
+ }
+ if self.hook_authority_not_newer_than(now)
+ && (self.hook_authority_conflicts_with_detected_agent(agent)
+ || (previous_detected_agent.is_some()
+ && agent != previous_detected_agent
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label)
+ == previous_detected_agent
+ })))
{
self.hook_authority = None;
+ self.stale_hook_idle_since = None;
}
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ self.update_stale_hook_idle_window(now);
+ self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ now,
+ )
}
#[cfg(test)]
@@ -112,6 +186,27 @@ impl TerminalState {
custom_status: Option<String>,
seq: Option<u64>,
) -> Option<EffectiveStateChange> {
+ self.set_hook_authority_with_custom_status_at(
+ source,
+ agent_label,
+ state,
+ message,
+ custom_status,
+ seq,
+ Instant::now(),
+ )
+ }
+
+ pub fn set_hook_authority_with_custom_status_at(
+ &mut self,
+ source: String,
+ agent_label: String,
+ state: AgentState,
+ message: Option<String>,
+ custom_status: Option<String>,
+ seq: Option<u64>,
+ now: Instant,
+ ) -> Option<EffectiveStateChange> {
if !self.accept_hook_report(&source, seq) {
return None;
}
@@ -128,8 +223,28 @@ impl TerminalState {
state,
message,
custom_status,
+ reported_at: now,
});
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ self.stale_hook_idle_since = None;
+ self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ now,
+ )
+ }
+
+ fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool {
+ self.hook_authority
+ .as_ref()
+ .is_none_or(|authority| authority.reported_at <= observed_at)
+ }
+
+ fn fallback_not_older_than_hook(&self) -> bool {
+ self.hook_authority.as_ref().is_none_or(|authority| {
+ self.fallback_observed_at
+ .is_some_and(|observed_at| authority.reported_at <= observed_at)
+ })
}
fn hook_authority_conflicts_with_detected_agent(&self, detected_agent: Option<Agent>) -> bool {
@@ -194,7 +309,13 @@ impl TerminalState {
return None;
}
self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ self.stale_hook_idle_since = None;
+ self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ Instant::now(),
+ )
}
pub fn release_agent(
@@ -223,8 +344,18 @@ impl TerminalState {
let previous_state = self.state;
self.detected_agent = None;
self.fallback_state = AgentState::Unknown;
+ self.fallback_visible_blocker = false;
+ self.fallback_visible_idle = false;
+ self.fallback_visible_working = false;
+ self.fallback_observed_at = None;
self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ self.stale_hook_idle_since = None;
+ self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ Instant::now(),
+ )
}
pub fn effective_agent_label(&self) -> Option<&str> {
@@ -242,11 +373,69 @@ impl TerminalState {
}
pub fn effective_custom_status(&self) -> Option<&str> {
+ if self.visible_blocker_overrides_hook()
+ || self.visible_idle_masks_hook_custom_status(self.state, Instant::now())
+ {
+ return None;
+ }
+
self.hook_authority
.as_ref()
.and_then(|authority| authority.custom_status.as_deref())
}
+ fn visible_blocker_overrides_hook(&self) -> bool {
+ self.fallback_visible_blocker
+ && self.fallback_not_older_than_hook()
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.state != AgentState::Blocked
+ && crate::detect::parse_agent_label(&authority.agent_label)
+ == self.detected_agent
+ })
+ }
+
+ fn visible_working_overrides_hook(&self) -> bool {
+ self.fallback_visible_working
+ && self.fallback_not_older_than_hook()
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.state == AgentState::Idle
+ && crate::detect::parse_agent_label(&authority.agent_label)
+ == self.detected_agent
+ })
+ }
+
+ fn visible_idle_stales_hook(&self, now: Instant) -> bool {
+ self.stale_hook_idle_since
+ .is_some_and(|since| now.duration_since(since) >= STALE_HOOK_IDLE_GRACE)
+ }
+
+ fn visible_idle_masks_hook_custom_status(&self, state: AgentState, now: Instant) -> bool {
+ self.fallback_visible_idle
+ && self.fallback_not_older_than_hook()
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.state == AgentState::Working
+ && crate::detect::parse_agent_label(&authority.agent_label)
+ == self.detected_agent
+ })
+ && (state == AgentState::Idle || self.visible_idle_stales_hook(now))
+ }
+
+ fn update_stale_hook_idle_window(&mut self, now: Instant) {
+ let visible_idle_stales_hook = self.fallback_visible_idle
+ && self.fallback_not_older_than_hook()
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.state == AgentState::Working
+ && crate::detect::parse_agent_label(&authority.agent_label)
+ == self.detected_agent
+ });
+
+ if visible_idle_stales_hook {
+ self.stale_hook_idle_since.get_or_insert(now);
+ } else {
+ self.stale_hook_idle_since = None;
+ }
+ }
+
pub fn set_manual_label(&mut self, label: String) {
let label = label.trim().to_string();
self.manual_label = (!label.is_empty()).then_some(label);
@@ -284,16 +473,38 @@ impl TerminalState {
previous_agent_label: Option<String>,
previous_known_agent: Option<Agent>,
previous_state: AgentState,
+ now: Instant,
) -> Option<EffectiveStateChange> {
- let state = self
+ let state = if self
.hook_authority
.as_ref()
- .map(|authority| authority.state)
- .unwrap_or(self.fallback_state);
+ .is_some_and(|authority| authority.state == AgentState::Blocked)
+ || self.visible_blocker_overrides_hook()
+ {
+ AgentState::Blocked
+ } else if self.visible_working_overrides_hook() {
+ AgentState::Working
+ } else if self.visible_idle_stales_hook(now) {
+ AgentState::Idle
+ } else {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.state)
+ .unwrap_or(self.fallback_state)
+ };
let agent_label = self.effective_agent_label().map(str::to_string);
let known_agent = self.effective_known_agent();
- let custom_status = self.effective_custom_status().map(str::to_string);
+ let custom_status = if self.visible_blocker_overrides_hook()
+ || self.visible_idle_masks_hook_custom_status(state, now)
+ {
+ None
+ } else {
+ self.hook_authority
+ .as_ref()
+ .and_then(|authority| authority.custom_status.as_deref())
+ .map(str::to_string)
+ };
if previous_agent_label == agent_label && previous_state == state {
return None;
@@ -342,9 +553,31 @@ pub(crate) fn stabilize_agent_state(
}
}
+pub(crate) fn stabilize_agent_detection(
+ agent: Option<Agent>,
+ previous: AgentState,
+ detection: crate::detect::AgentDetection,
+ process_exited: bool,
+ now: std::time::Instant,
+ last_claude_working_at: &mut Option<std::time::Instant>,
+) -> AgentState {
+ if process_exited {
+ return detection.state;
+ }
+
+ stabilize_agent_state(
+ agent,
+ previous,
+ detection.state,
+ now,
+ last_claude_working_at,
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use crate::detect::AgentDetection;
fn test_terminal() -> TerminalState {
TerminalState::new(TerminalId::alloc(), "/tmp".into())
@@ -390,6 +623,50 @@ mod tests {
}
#[test]
+ fn process_exit_idle_bypasses_claude_working_hold() {
+ let now = std::time::Instant::now();
+ let mut last_working = Some(now);
+
+ let state = stabilize_agent_detection(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentDetection {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: false,
+ visible_working: false,
+ },
+ true,
+ now + std::time::Duration::from_millis(100),
+ &mut last_working,
+ );
+
+ assert_eq!(state, AgentState::Idle);
+ }
+
+ #[test]
+ fn visible_idle_does_not_bypass_claude_working_hold() {
+ let now = std::time::Instant::now();
+ let mut last_working = Some(now);
+
+ let state = stabilize_agent_detection(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentDetection {
+ state: AgentState::Idle,
+ visible_blocker: false,
+ visible_idle: true,
+ visible_working: false,
+ },
+ false,
+ now + std::time::Duration::from_millis(100),
+ &mut last_working,
+ );
+
+ assert_eq!(state, AgentState::Working);
+ }
+
+ #[test]
fn non_claude_states_are_unchanged() {
let now = std::time::Instant::now();
let mut last_working = None;
@@ -441,6 +718,308 @@ mod tests {
}
#[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);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ let change = terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Blocked,
+ true,
+ false,
+ false,
+ );
+
+ assert_eq!(terminal.fallback_state, AgentState::Blocked);
+ assert_eq!(terminal.state, AgentState::Blocked);
+ assert_eq!(change.unwrap().previous_state, AgentState::Working);
+ }
+
+ #[test]
+ fn weak_blocked_fallback_does_not_override_hook_authority() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ let change = terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Blocked,
+ false,
+ false,
+ false,
+ );
+
+ assert_eq!(terminal.fallback_state, AgentState::Blocked);
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(change.is_none());
+ }
+
+ #[test]
+ fn hook_blocked_wins_over_visible_blocker() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Blocked,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Blocked,
+ true,
+ false,
+ false,
+ );
+
+ assert_eq!(terminal.state, AgentState::Blocked);
+ assert!(terminal.hook_authority.is_some());
+ }
+
+ #[test]
+ fn visible_blocker_does_not_override_different_agent_hook() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(None, AgentState::Unknown);
+ terminal.set_hook_authority(
+ "custom:agent".into(),
+ "custom-agent".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Blocked,
+ true,
+ false,
+ false,
+ );
+
+ assert_eq!(terminal.effective_agent_label(), Some("custom-agent"));
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn visible_blocker_suppresses_stale_hook_custom_status() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
+ terminal.set_hook_authority_with_custom_status(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ Some("planning".into()),
+ None,
+ );
+
+ terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Blocked,
+ true,
+ false,
+ false,
+ );
+
+ assert_eq!(terminal.state, AgentState::Blocked);
+ assert_eq!(terminal.effective_custom_status(), None);
+ }
+
+ #[test]
+ fn visible_idle_waits_before_overriding_claude_hook_working() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Working,
+ false,
+ false,
+ true,
+ false,
+ now,
+ );
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ Some("thinking".into()),
+ None,
+ now,
+ );
+
+ let waiting = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(500),
+ );
+
+ assert!(waiting.is_none());
+ assert_eq!(terminal.fallback_state, AgentState::Idle);
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.effective_custom_status(), Some("thinking"));
+
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1),
+ );
+
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(terminal.effective_custom_status(), None);
+ assert_eq!(change.unwrap().previous_state, AgentState::Working);
+ }
+
+ #[test]
+ fn fresh_hook_working_resets_visible_idle_stale_window() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ Some("thinking".into()),
+ None,
+ now,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(500),
+ );
+
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ Some("thinking".into()),
+ Some(1),
+ now + Duration::from_millis(800),
+ );
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1),
+ );
+
+ assert!(change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn visible_working_overrides_hook_idle_for_same_agent() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle);
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Idle,
+ None,
+ None,
+ None,
+ now,
+ );
+
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Working,
+ false,
+ false,
+ true,
+ false,
+ now + Duration::from_millis(1),
+ );
+
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(change.unwrap().previous_state, AgentState::Idle);
+ }
+
+ #[test]
+ fn visible_idle_does_not_override_hook_blocked() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Blocked,
+ None,
+ None,
+ );
+
+ let change = terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ );
+
+ assert_eq!(terminal.fallback_state, AgentState::Idle);
+ assert_eq!(terminal.state, AgentState::Blocked);
+ assert!(change.is_none());
+ }
+
+ #[test]
+ fn visible_idle_does_not_override_other_agent_hook_working() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ let change = terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ );
+
+ assert_eq!(terminal.fallback_state, AgentState::Idle);
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(change.is_none());
+ }
+
+ #[test]
fn known_hook_authority_does_not_override_different_detected_agent() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Grok), AgentState::Working);
@@ -540,6 +1119,137 @@ mod tests {
}
#[test]
+ fn detected_agent_clear_clears_matching_working_hook_authority() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state(None, AgentState::Unknown);
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.effective_agent_label(), None);
+ assert_eq!(terminal.state, AgentState::Unknown);
+ }
+
+ #[test]
+ fn process_exit_clears_matching_hook_authority_before_reporting_idle() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state_with_visible_blocker(
+ Some(Agent::Codex),
+ AgentState::Idle,
+ false,
+ false,
+ true,
+ );
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, Some(Agent::Codex));
+ assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ assert_eq!(terminal.state, AgentState::Idle);
+ }
+
+ #[test]
+ fn stale_visible_screen_signal_does_not_override_newer_hook_authority() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Working,
+ false,
+ false,
+ true,
+ false,
+ observed,
+ );
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(1),
+ observed + Duration::from_secs(1),
+ );
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ observed,
+ );
+
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.stale_hook_idle_since.is_none());
+ }
+
+ #[test]
+ fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Codex),
+ AgentState::Working,
+ false,
+ false,
+ false,
+ false,
+ observed,
+ );
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(1),
+ observed,
+ );
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Working,
+ None,
+ Some("new turn".into()),
+ Some(2),
+ observed + Duration::from_secs(1),
+ );
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Codex),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ observed,
+ );
+
+ let authority = terminal.hook_authority.as_ref().expect("hook authority");
+ assert_eq!(authority.custom_status.as_deref(), Some("new turn"));
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ }
+
+ #[test]
fn detected_agent_change_clears_previous_matching_hook_authority() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);