diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:54:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:54:41 +0300 |
| commit | d477341e6af816ff4396042b084a0378ef437b4a (patch) | |
| tree | e332b6f7a0d23d9effd21c3ca8ab27698ed83444 /src/detect/agents/codex.rs | |
| parent | 8df1639048529b383358000879b5ee078a90b9f9 (diff) | |
refactor: split agent detection by agent
Move screen detection into per-agent modules and keep visible-state arbitration centralized in terminal state.
Tighten Codex prompt-scoped working detection and Claude hook/screen recovery for stale blocked and idle reports.
Add a focused just recipe for single-test runs.
refs #349
refs #352
Diffstat (limited to 'src/detect/agents/codex.rs')
| -rw-r--r-- | src/detect/agents/codex.rs | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs new file mode 100644 index 00000000..0d23d4cb --- /dev/null +++ b/src/detect/agents/codex.rs @@ -0,0 +1,158 @@ +use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState}; + +pub(super) fn detect(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + // Strong blocked patterns are structural Codex UI chrome, so they can win + // even when the prompt region is visible. + if has_codex_strong_blocked_prompt(&lower) { + return AgentState::Blocked; + } + + // Working + if has_codex_working_status_at_current_prompt(content) { + return AgentState::Working; + } + + // Idle + if has_codex_current_prompt(content) { + return AgentState::Idle; + } + + // Weak blocked patterns are too broad to scan above a visible prompt. They + // only apply when Codex does not currently show an idle prompt region. + if has_codex_weak_blocked_prompt(&lower) { + return AgentState::Blocked; + } + + // Fallback working signals for narrow captures where the footer scrolled + // out or the working row is the only Codex chrome visible. + if has_interrupt_pattern(&lower) || has_codex_working_header(content) { + return AgentState::Working; + } + + AgentState::Idle +} + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + has_codex_strong_blocked_prompt(&lower) +} + +pub(super) fn has_prompt(content: &str) -> bool { + has_codex_current_prompt(content) || content.lines().any(codex_prompt_line) +} + +pub(super) fn has_visible_working(content: &str) -> bool { + has_codex_live_working_at_current_prompt(content) + || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content)) +} + +fn has_codex_visible_working_without_prompt(content: &str) -> bool { + content + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .is_some_and(codex_live_working_line) +} + +fn has_codex_strong_blocked_prompt(lower_content: &str) -> bool { + lower_content.contains("press enter to confirm or esc to cancel") + || lower_content.contains("enter to submit answer") + || lower_content.contains("enter to submit all") + || lower_content.contains("allow command?") +} + +fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool { + lower_content.contains("[y/n]") + || lower_content.contains("yes (y)") + || has_confirmation_prompt(lower_content) +} + +fn has_codex_live_working_at_current_prompt(content: &str) -> bool { + codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line) +} + +fn has_codex_working_status_at_current_prompt(content: &str) -> bool { + codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line) +} + +fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> { + let (lines, prompt_index) = codex_current_prompt_region(content)?; + + lines[..prompt_index] + .iter() + .rev() + .find(|line| codex_block_marker_line(line)) + .copied() +} + +fn has_codex_working_header(content: &str) -> bool { + content.lines().any(codex_working_status_line) +} + +fn codex_live_working_line(line: &str) -> bool { + if codex_queued_input_header_line(line) { + return true; + } + + let trimmed = line.trim_start(); + let lower = trimmed.to_lowercase(); + codex_working_status_line(line) + && (trimmed.contains("Waiting for background terminal") + || lower.contains("esc to interrupt") + || lower.contains("esc…") + || lower.contains("background terminal running") + || lower.contains("/ps to view") + || lower.contains("/stop to close")) +} + +fn codex_working_status_line(line: &str) -> bool { + if codex_queued_input_header_line(line) { + return true; + } + + let trimmed = line.trim_start(); + trimmed.starts_with('•') + && (trimmed.contains("Working (") + || trimmed.contains("Waiting for background terminal (") + || trimmed.contains("Reviewing approval request (") + || trimmed.contains("Booting MCP server:")) +} + +fn has_codex_current_prompt(content: &str) -> bool { + codex_current_prompt_region(content).is_some() +} + +fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?; + + if lines[prompt_index + 1..] + .iter() + .any(|line| codex_block_marker_line(line)) + { + return None; + } + + Some((lines, prompt_index)) +} + +fn codex_prompt_line(line: &str) -> bool { + line == "›" || line.starts_with("› ") +} + +fn codex_block_marker_line(line: &str) -> bool { + line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓') +} + +fn codex_queued_input_header_line(line: &str) -> bool { + let trimmed = line.trim_start(); + if !trimmed.starts_with('•') { + return false; + } + + let lower = trimmed.to_lowercase(); + lower.starts_with("• queued follow-up inputs") + || lower.starts_with("• messages to be submitted after next tool call") +} |
