From 5281c8528f8ccc60646ade2cae7f72f23be2ba8b Mon Sep 17 00:00:00 2001 From: signet-bot-claude <261785442+signet-bot-claude@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:39:12 -0400 Subject: [PATCH 1/3] Codex: clear a stale approval latch once the idle composer is back, even while /voice keeps output churning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 73bbaa3 (in 0.7.30) a Codex approval clears only through the screen: the latch drops when the scrape is negative and output has been quiet for APPROVAL_SCRAPE_CLEAR_MS. With Codex's /voice on, its footer meters redraw continuously, so that quiet never comes. Observed in a live session: an answered approval stayed latched for almost two hours; every idle-time delivery attempt logged "Gate blocked: approval" (2,204 times in 46 minutes), 17 messages piled up, and only a manual `hcom term inject` woke the session. Codex draws its idle composer (empty, with the `? for shortcuts` hint) only when no approval or other modal holds the bottom pane. ScreenTracker::is_codex_idle_composer detects it (not is_ready(), which a dialog's `›` option rows or history also satisfy); once it has stood for CODEX_COMPOSER_CLEAR_MS (1.5 s), a negative scrape clears the latch as quiet output would. A positive scrape still latches at once; Cursor and other tools are unchanged. Tests: the idle /voice screen (a real capture, text anonymized) is an idle composer; an approval dialog with history `›` rows and a draft are not; the composer settle timing; and update_delivery_state clears the latch on a churning idle screen (fails without this change). Co-Authored-By: Claude Opus 5.5 --- src/delivery.rs | 50 ++++++++++++++++++++++++++++ src/pty/screen.rs | 63 +++++++++++++++++++++++++++++++++++ src/pty/shared.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 194 insertions(+), 4 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index 383d0dc3..cf0348f8 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -863,6 +863,12 @@ pub struct ScreenState { /// once output has settled. Antigravity keeps its immediate scrape. /// See `APPROVAL_SCRAPE_CLEAR_MS`. pub approval_scrape_latched: bool, + /// Since when Codex has shown its idle composer without a break (`None` + /// while it is not drawn; `ScreenTracker::is_codex_idle_composer`). Codex + /// draws it only when no approval or other modal holds the bottom pane, so a + /// composer seen for `CODEX_COMPOSER_CLEAR_MS` lets a negative approval scrape + /// clear the latch even while output never settles. See `codex_composer_settled`. + pub codex_composer_since: Option, /// A Claude TUI overlay is focused whose input box is NOT the current /// session's root prompt — the subagent navigator (a human may be typing /// into a subagent's box) or the `←` session switcher (input box is a @@ -890,6 +896,7 @@ impl Default for ScreenState { cols: 80, last_prompt_submit: None, approval_scrape_latched: false, + codex_composer_since: None, nav_overlay: false, startup_loading: false, } @@ -918,6 +925,22 @@ pub(crate) fn pre_enter_settle(tool: &str) -> Option { /// through the burst so the gate reports `approval`, not `prompt_has_text`. pub(crate) const APPROVAL_SCRAPE_CLEAR_MS: u64 = 400; +/// How long Codex's idle composer must stay drawn before it is trusted as proof +/// that no approval dialog is up. Output churn alone can never clear the latch +/// while Codex's /voice footer is on: its live mic/codex meters redraw the screen +/// continuously, so `APPROVAL_SCRAPE_CLEAR_MS` of quiet never comes (observed: an +/// answered approval stayed latched for almost two hours and every +/// idle-time message waited behind "Gate blocked: approval"). Long enough to ride +/// out a redraw that briefly shows the composer; far shorter than any real wait. +pub(crate) const CODEX_COMPOSER_CLEAR_MS: u64 = 1500; + +/// Whether Codex's idle composer has been drawn for `CODEX_COMPOSER_CLEAR_MS`. +pub(crate) fn codex_composer_settled(composer_since: Option, now: Instant) -> bool { + composer_since.is_some_and(|t| { + now.saturating_duration_since(t).as_millis() >= CODEX_COMPOSER_CLEAR_MS as u128 + }) +} + /// Latch decision for screen-scraped approval (cursor). A partial-render frame /// mid-redraw scrapes as "no approval"; holding the previous latch through such /// transient false reads keeps the gate reporting `approval` instead of falling @@ -2786,6 +2809,7 @@ mod tests { cols: 80, last_prompt_submit: None, approval_scrape_latched: false, + codex_composer_since: None, nav_overlay: false, startup_loading: false, } @@ -3399,6 +3423,32 @@ mod tests { assert!(!latch_scraped_approval(false, false, true)); } + #[test] + fn codex_composer_settles_only_after_a_steady_stretch() { + let now = Instant::now(); + assert!(!codex_composer_settled(None, now), "no composer drawn"); + assert!( + !codex_composer_settled(Some(now), now), + "just appeared: a redraw may be passing" + ); + let short = now - Duration::from_millis(CODEX_COMPOSER_CLEAR_MS - 100); + assert!(!codex_composer_settled(Some(short), now)); + let long = now - Duration::from_millis(CODEX_COMPOSER_CLEAR_MS); + assert!(codex_composer_settled(Some(long), now)); + // The settled composer stands in for quiet output: a negative scrape clears. + assert!(!latch_scraped_approval( + true, + false, + codex_composer_settled(Some(long), now) + )); + // A positive scrape still latches, composer or not. + assert!(latch_scraped_approval( + false, + true, + codex_composer_settled(Some(long), now) + )); + } + // ---- Lookup functions ---- #[test] diff --git a/src/pty/screen.rs b/src/pty/screen.rs index a5ba589d..34dca748 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -520,6 +520,20 @@ impl ScreenTracker { && (visible.contains("yes") || visible.contains('❯'))) } + /// Codex at rest: its composer is drawn and empty with the `? for shortcuts` + /// hint beside it, and no approval dialog is visible. Codex replaces that + /// composer and hint while an approval or other modal holds the bottom pane + /// (whose `›` option rows can still satisfy the ready pattern and the input + /// scrape), so this, not `is_ready()`, is the evidence that a dialog left. + pub fn is_codex_idle_composer(&self) -> bool { + self.get_codex_input_text().is_some_and(|t| t.is_empty()) + && self + .get_screen_lines() + .iter() + .any(|line| line.trim_start().starts_with("? for shortcuts")) + && !self.is_codex_approval_visible() + } + /// Antigravity-specific approval detection: the agy TUI renders permission /// prompts as plain text in the prompt area ("Requesting permission for: …", /// a per-kind question such as "Run this command?", a "1. Yes … 4. No" menu @@ -1656,6 +1670,55 @@ mod tests { assert!(t.is_codex_approval_visible()); } + /// A real Codex screen (Codex 26.x, /voice on but muted, custom status + /// line), text anonymized: idle, nothing to approve, yet hcom had held its + /// approval latch for almost two hours. + fn idle_voice_screen() -> &'static str { + "\u{2022} Done: the change is in and the targeted tests pass.\r\n\r\n\ + \x20voice \u{25cc} muted \u{2303}x unmute /voice stop\r\n\ + \x20 mic \u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\u{2581} codex \u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\r\n\r\n\ + \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n\ + \x20 ~/src/project \u{b7} main \u{b7} gpt-model high \u{b7} Context 45% used\r\n\ + \x20 ? for shortcuts \u{26a0} 1 warning \u{b7} f2 to view\r\n" + } + + #[test] + fn codex_idle_composer_on_an_idle_voice_screen() { + let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); + t.process(idle_voice_screen().as_bytes()); + assert!(t.is_ready()); + assert!(!t.is_codex_approval_visible()); + assert!(t.is_codex_idle_composer()); + } + + #[test] + fn codex_idle_composer_is_not_an_approval_dialog_or_a_draft() { + // History `›` rows and the dialog's own `›` option both satisfy Codex's + // ready pattern and the input scrape, but the dialog replaced the idle + // composer and its `? for shortcuts` hint. + let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); + t.process( + "\u{203a} Check the service once it is up\r\n\r\n\ + \x20 Would you like to run the following command?\r\n\r\n $ curl -s https://example.com/health\r\n\r\n\ + \u{203a} 1. Yes, proceed (y)\r\n 2. No, and tell Codex what to do differently (esc)\r\n\r\n\ + \x20 Press enter to confirm or esc to cancel\r\n" + .as_bytes(), + ); + assert!( + t.is_ready(), + "the `\u{203a}` rows satisfy the ready pattern" + ); + assert!(t.is_codex_approval_visible()); + assert!(!t.is_codex_idle_composer()); + + let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); + t.process("\u{203a} half-typed draft\r\n\r\n ? for shortcuts\r\n".as_bytes()); + assert!( + !t.is_codex_idle_composer(), + "a draft is not an idle composer" + ); + } + #[test] fn codex_transcript_viewer_is_not_approval() { let mut t = make_tracker(24, 80, ""); diff --git a/src/pty/shared.rs b/src/pty/shared.rs index 8fcb503c..94decb25 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -19,8 +19,8 @@ use anyhow::Result; use crate::config::Config; use crate::db::HcomDb; use crate::delivery::{ - APPROVAL_SCRAPE_CLEAR_MS, DeliveryState, ScreenState, ToolConfig, latch_scraped_approval, - run_delivery_loop, + APPROVAL_SCRAPE_CLEAR_MS, DeliveryState, ScreenState, ToolConfig, codex_composer_settled, + latch_scraped_approval, run_delivery_loop, }; use crate::log::{log_error, log_info, log_warn}; use crate::notify::NotifyServer; @@ -189,10 +189,24 @@ pub(super) fn update_delivery_state( Some(Tool::Cursor) => screen.is_cursor_approval_visible(), _ => false, }; + // Codex draws its idle composer (empty, `? for shortcuts`) only when no + // approval or other modal holds the bottom pane. Seen for a while, that is + // evidence enough to trust a negative scrape: with /voice on, its meters + // keep output churning and the quiet-output test alone never clears. + let now = Instant::now(); + let input_text = screen.get_input_box_text(target.name()); + let codex_composer = + matches!(target.known_tool(), Some(Tool::Codex)) && screen.is_codex_idle_composer(); + state.codex_composer_since = if codex_composer { + Some(state.codex_composer_since.unwrap_or(now)) + } else { + None + }; state.approval_scrape_latched = latch_scraped_approval( state.approval_scrape_latched, scraped_approval, - screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS), + screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS) + || codex_composer_settled(state.codex_composer_since, now), ); let approval = (scrape_latched_tool && state.approval_scrape_latched) || (target.name() == "antigravity" && screen.is_antigravity_approval_visible()); @@ -200,7 +214,6 @@ pub(super) fn update_delivery_state( approval_changed = Some(approval); } state.approval = approval; - let input_text = screen.get_input_box_text(target.name()); let new_prompt_empty = input_text.as_ref().is_some_and(|t| t.is_empty()); // Stamp submit-edge cooldown when input transitions from a known // non-empty value to empty or briefly undetected. Guards against @@ -1934,6 +1947,70 @@ No session found matching 'never-saved'" assert_eq!(calls.get(), 0, "cursor keystroke must not publish"); } + #[test] + fn codex_approval_clears_on_its_idle_composer_while_voice_meters_churn() { + // Observed: an answered approval stayed latched for almost two + // hours. Codex's /voice footer redraws its meters continuously, so output + // never stayed quiet for APPROVAL_SCRAPE_CLEAR_MS, and every idle-time + // message waited behind "Gate blocked: approval" until a manual inject. + let target = PtyTarget::Known(Tool::Codex); + let patterns: Vec = Tool::Codex + .ready_patterns() + .iter() + .map(|p| p.to_string()) + .collect(); + let mut screen = ScreenTracker::new_with_instance(24, 100, &patterns, None); + let state = Arc::new(RwLock::new(ScreenState::default())); + let launch = Arc::new(AtomicBool::new(false)); + let edges = std::cell::RefCell::new(Vec::new()); + let publish = |a: bool| edges.borrow_mut().push(a); + let idle = |screen: &mut ScreenTracker, level: char| { + screen.process( + format!( + "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n mic {level}{level}{level} codex \u{2581}\u{2581}\r\n\r\n\ + \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n ? for shortcuts\r\n" + ) + .as_bytes(), + ) + }; + + screen.process( + "\u{203a} 1. Yes, proceed (y)\r\n 2. No (esc)\r\n\r\n Press enter to confirm or esc to cancel\r\n".as_bytes(), + ); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!( + state.read().unwrap().approval, + "the dialog latches approval" + ); + + // Answered: the idle composer is back, but the meters keep redrawing. + idle(&mut screen, '\u{2581}'); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!( + state.read().unwrap().approval, + "a composer just drawn may be a passing redraw" + ); + + // Still churning, but the composer has stood for CODEX_COMPOSER_CLEAR_MS. + { + let mut s = state.write().unwrap(); + s.codex_composer_since = Some( + Instant::now() - Duration::from_millis(crate::delivery::CODEX_COMPOSER_CLEAR_MS), + ); + } + idle(&mut screen, '\u{2582}'); + assert!( + !screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS), + "output is still churning" + ); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!( + !state.read().unwrap().approval, + "the idle composer clears the latch" + ); + assert_eq!(*edges.borrow(), vec![true, false]); + } + #[test] fn codex_input_preserves_approval_until_screen_clears() { let target = PtyTarget::Known(Tool::Codex); From 1a109d3346f7b0400cd0a9e8c140eb23fb5cd8e4 Mon Sep 17 00:00:00 2001 From: signet-bot-claude <261785442+signet-bot-claude@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:15:48 -0400 Subject: [PATCH 2/3] Codex: require the composer's prompt row above its hint before trusting an idle composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_codex_idle_composer accepted an empty input scrape plus a `? for shortcuts` line anywhere on screen. With no prompt row drawn, the input scrape falls back to the ready pattern, which any `› ` inside a line satisfies (a modal breadcrumb such as `Settings › Model`), and a hint line quoted in history above that modal then passed as the composer's hint. An unrecognized modal could thus start codex_composer_since and clear the approval latch after CODEX_COMPOSER_CLEAR_MS. Require the bottom-most prompt row and the hint below it. Also test that a draft or dialog interrupting the idle composer restarts the full wait. Co-Authored-By: Claude Opus 5.5 --- src/pty/screen.rs | 33 ++++++++++++++++++++++-- src/pty/shared.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 34dca748..697d7971 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -525,10 +525,20 @@ impl ScreenTracker { /// composer and hint while an approval or other modal holds the bottom pane /// (whose `›` option rows can still satisfy the ready pattern and the input /// scrape), so this, not `is_ready()`, is the evidence that a dialog left. + /// + /// The prompt row must be drawn and the hint must sit below it: an empty + /// input scrape alone can come from the ready-pattern fallback with no + /// prompt row, and a hint line can linger in history above a modal. pub fn is_codex_idle_composer(&self) -> bool { + let lines = self.get_screen_lines(); + let Some(prompt_row) = lines.iter().rposition(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with('›') || trimmed.starts_with('»') + }) else { + return false; + }; self.get_codex_input_text().is_some_and(|t| t.is_empty()) - && self - .get_screen_lines() + && lines[prompt_row + 1..] .iter() .any(|line| line.trim_start().starts_with("? for shortcuts")) && !self.is_codex_approval_visible() @@ -1691,6 +1701,25 @@ mod tests { assert!(t.is_codex_idle_composer()); } + #[test] + fn codex_idle_composer_needs_its_prompt_row_above_the_hint() { + // An unrecognized modal (no prompt row) whose breadcrumb contains `› ` + // satisfies the ready pattern, so the input scrape falls back to an + // empty prompt; a `? for shortcuts` line quoted in history above it is + // not the composer's hint. + let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); + t.process( + "\u{2022} Press ? for shortcuts in the composer.\r\n\ + \x20 ? for shortcuts\r\n\r\n\ + \x20 Settings \u{203a} Model\r\n\r\n\ + \x20 gpt-model high\r\n gpt-model low\r\n" + .as_bytes(), + ); + assert!(!t.is_codex_approval_visible()); + assert_eq!(t.get_codex_input_text().as_deref(), Some("")); + assert!(!t.is_codex_idle_composer()); + } + #[test] fn codex_idle_composer_is_not_an_approval_dialog_or_a_draft() { // History `›` rows and the dialog's own `›` option both satisfy Codex's diff --git a/src/pty/shared.rs b/src/pty/shared.rs index 94decb25..ffec96c9 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -2011,6 +2011,71 @@ No session found matching 'never-saved'" assert_eq!(*edges.borrow(), vec![true, false]); } + #[test] + fn codex_interrupted_idle_composer_restarts_its_wait() { + // The idle composer must stand unbroken for CODEX_COMPOSER_CLEAR_MS: a + // draft or a dialog in between restarts the wait. + let target = PtyTarget::Known(Tool::Codex); + let patterns: Vec = Tool::Codex + .ready_patterns() + .iter() + .map(|p| p.to_string()) + .collect(); + let mut screen = ScreenTracker::new_with_instance(24, 100, &patterns, None); + let state = Arc::new(RwLock::new(ScreenState::default())); + let launch = Arc::new(AtomicBool::new(false)); + let publish = |_: bool| {}; + let dialog = "\x1b[2J\x1b[H\u{203a} 1. Yes, proceed (y)\r\n 2. No (esc)\r\n\r\n Press enter to confirm or esc to cancel\r\n"; + let idle = |screen: &mut ScreenTracker, level: char| { + screen.process( + format!( + "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n mic {level}{level}{level} codex \u{2581}\u{2581}\r\n\r\n\ + \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n ? for shortcuts\r\n" + ) + .as_bytes(), + ) + }; + let backdate = |state: &Arc>| { + let mut s = state.write().unwrap(); + assert!(s.codex_composer_since.is_some(), "the composer is timed"); + s.codex_composer_since = Some( + Instant::now() - Duration::from_millis(crate::delivery::CODEX_COMPOSER_CLEAR_MS), + ); + }; + + screen.process(dialog.as_bytes()); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!(state.read().unwrap().approval); + + for interruption in [ + "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n\r\n\u{203a} half-typed draft\r\n\r\n ? for shortcuts\r\n", + dialog, + ] { + idle(&mut screen, '\u{2581}'); + update_delivery_state(&state, &screen, &target, &launch, &publish); + backdate(&state); + + screen.process(interruption.as_bytes()); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!(state.read().unwrap().codex_composer_since.is_none()); + assert!(state.read().unwrap().approval); + + // Shown again while the meters churn: the full wait starts over. + idle(&mut screen, '\u{2582}'); + assert!(!screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS)); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!( + state.read().unwrap().approval, + "a composer back after an interruption waits again" + ); + } + + backdate(&state); + idle(&mut screen, '\u{2583}'); + update_delivery_state(&state, &screen, &target, &launch, &publish); + assert!(!state.read().unwrap().approval); + } + #[test] fn codex_input_preserves_approval_until_screen_clears() { let target = PtyTarget::Known(Tool::Codex); From 46113b9aa84680db55fbd2629ed7aad748a1acec Mon Sep 17 00:00:00 2001 From: aannoo Date: Tue, 20 Oct 2026 02:42:28 +0100 Subject: [PATCH 3/3] Clear a latched approval once the scrape stays negative Replace the Codex idle-composer detector with a tool-agnostic rule: a latched Codex/Cursor approval clears once the scrape has been negative for APPROVAL_SCRAPE_CLEAR_MS, not only after output goes quiet. A TUI that never stops redrawing (Codex /voice meters, animated status lines) held answered approvals forever under the quiet-output rule. Also cap the Unix poll timeout while an approval is latched. Once the dialog left and the tool went quiet, the latch was only re-evaluated on the next 10s poll timeout, so delivery waited up to 10s after every approval (measured live: 10s -> 0.6s). Windows already re-evaluates on its ~120ms quiet timeout. --- src/delivery.rs | 87 +++++++++------------------ src/pty/mod.rs | 10 ++++ src/pty/screen.rs | 92 ----------------------------- src/pty/shared.rs | 146 +++++++++++----------------------------------- 4 files changed, 71 insertions(+), 264 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index cf0348f8..90bf5af5 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -860,15 +860,11 @@ pub struct ScreenState { /// Latched Cursor/Codex approval signal. Their TUI redraws can briefly erase /// both the dialog and title, which would flicker `approval` false while the /// prompt is still up. Latch true on any positive detection and only clear - /// once output has settled. Antigravity keeps its immediate scrape. - /// See `APPROVAL_SCRAPE_CLEAR_MS`. + /// once the scrape has stayed negative. Antigravity keeps its immediate + /// scrape. See `APPROVAL_SCRAPE_CLEAR_MS`. pub approval_scrape_latched: bool, - /// Since when Codex has shown its idle composer without a break (`None` - /// while it is not drawn; `ScreenTracker::is_codex_idle_composer`). Codex - /// draws it only when no approval or other modal holds the bottom pane, so a - /// composer seen for `CODEX_COMPOSER_CLEAR_MS` lets a negative approval scrape - /// clear the latch even while output never settles. See `codex_composer_settled`. - pub codex_composer_since: Option, + /// When the approval scrape last turned negative (`None` while positive). + pub approval_scrape_negative_since: Option, /// A Claude TUI overlay is focused whose input box is NOT the current /// session's root prompt — the subagent navigator (a human may be typing /// into a subagent's box) or the `←` session switcher (input box is a @@ -896,7 +892,7 @@ impl Default for ScreenState { cols: 80, last_prompt_submit: None, approval_scrape_latched: false, - codex_composer_since: None, + approval_scrape_negative_since: None, nav_overlay: false, startup_loading: false, } @@ -918,38 +914,33 @@ pub(crate) fn pre_enter_settle(tool: &str) -> Option { (tool == "qoder").then(|| Duration::from_millis(250)) } -/// How long screen output must be quiet before a negative approval scrape is -/// trusted to clear the latched signal. Redraw bursts (cursor's approval prompt -/// animating its selection / spinner) emit partial frames that scrape as "no -/// approval"; requiring a settled screen before clearing keeps the latch up -/// through the burst so the gate reports `approval`, not `prompt_has_text`. +/// How long a negative approval scrape must hold before it clears the latched +/// signal. Redraw bursts (cursor's approval prompt animating its selection / +/// spinner, Codex on focus changes) emit partial frames that scrape as "no +/// approval"; the next full frame shows the prompt again and restarts the wait, +/// so the gate keeps reporting `approval`, not `prompt_has_text`. +/// +/// The wait is measured on the scrape, not on quiet output: a TUI that never +/// stops redrawing (Codex's /voice meters, an animated status line) would +/// otherwise hold an answered approval forever. pub(crate) const APPROVAL_SCRAPE_CLEAR_MS: u64 = 400; -/// How long Codex's idle composer must stay drawn before it is trusted as proof -/// that no approval dialog is up. Output churn alone can never clear the latch -/// while Codex's /voice footer is on: its live mic/codex meters redraw the screen -/// continuously, so `APPROVAL_SCRAPE_CLEAR_MS` of quiet never comes (observed: an -/// answered approval stayed latched for almost two hours and every -/// idle-time message waited behind "Gate blocked: approval"). Long enough to ride -/// out a redraw that briefly shows the composer; far shorter than any real wait. -pub(crate) const CODEX_COMPOSER_CLEAR_MS: u64 = 1500; - -/// Whether Codex's idle composer has been drawn for `CODEX_COMPOSER_CLEAR_MS`. -pub(crate) fn codex_composer_settled(composer_since: Option, now: Instant) -> bool { - composer_since.is_some_and(|t| { - now.saturating_duration_since(t).as_millis() >= CODEX_COMPOSER_CLEAR_MS as u128 +/// Whether a negative approval scrape has held for `APPROVAL_SCRAPE_CLEAR_MS`. +pub(crate) fn approval_scrape_cleared(negative_since: Option, now: Instant) -> bool { + negative_since.is_some_and(|t| { + now.saturating_duration_since(t) >= Duration::from_millis(APPROVAL_SCRAPE_CLEAR_MS) }) } -/// Latch decision for screen-scraped approval (cursor). A partial-render frame -/// mid-redraw scrapes as "no approval"; holding the previous latch through such -/// transient false reads keeps the gate reporting `approval` instead of falling -/// through to `prompt_has_text`. The latch only clears once `output_settled` -/// (no redraw churn) confirms the prompt has genuinely left the screen. -pub(crate) fn latch_scraped_approval(prev: bool, scraped: bool, output_settled: bool) -> bool { +/// Latch decision for screen-scraped approval (Cursor, Codex). A partial-render +/// frame mid-redraw scrapes as "no approval"; holding the previous latch through +/// such transient false reads keeps the gate reporting `approval` instead of +/// falling through to `prompt_has_text`. The latch only clears once `settled` +/// confirms the prompt has genuinely left the screen. +pub(crate) fn latch_scraped_approval(prev: bool, scraped: bool, settled: bool) -> bool { if scraped { true - } else if output_settled { + } else if settled { false } else { prev @@ -2809,7 +2800,7 @@ mod tests { cols: 80, last_prompt_submit: None, approval_scrape_latched: false, - codex_composer_since: None, + approval_scrape_negative_since: None, nav_overlay: false, startup_loading: false, } @@ -3423,32 +3414,6 @@ mod tests { assert!(!latch_scraped_approval(false, false, true)); } - #[test] - fn codex_composer_settles_only_after_a_steady_stretch() { - let now = Instant::now(); - assert!(!codex_composer_settled(None, now), "no composer drawn"); - assert!( - !codex_composer_settled(Some(now), now), - "just appeared: a redraw may be passing" - ); - let short = now - Duration::from_millis(CODEX_COMPOSER_CLEAR_MS - 100); - assert!(!codex_composer_settled(Some(short), now)); - let long = now - Duration::from_millis(CODEX_COMPOSER_CLEAR_MS); - assert!(codex_composer_settled(Some(long), now)); - // The settled composer stands in for quiet output: a negative scrape clears. - assert!(!latch_scraped_approval( - true, - false, - codex_composer_settled(Some(long), now) - )); - // A positive scrape still latches, composer or not. - assert!(latch_scraped_approval( - false, - true, - codex_composer_settled(Some(long), now) - )); - } - // ---- Lookup functions ---- #[test] diff --git a/src/pty/mod.rs b/src/pty/mod.rs index fb223c4c..e4385285 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -1007,6 +1007,16 @@ impl Proxy { if child_exited { poll_timeout = poll_timeout.min(100); } + // A latched approval clears on a timer (`APPROVAL_SCRAPE_CLEAR_MS`), + // and once the dialog is gone the tool may go quiet. Wake to + // re-evaluate instead of holding delivery until the next 10s timeout. + if self + .delivery_state + .read() + .is_ok_and(|s| s.approval_scrape_latched) + { + poll_timeout = poll_timeout.min(crate::delivery::APPROVAL_SCRAPE_CLEAR_MS as u16); + } match poll(&mut poll_fds, PollTimeout::from(poll_timeout)) { Ok(0) => { if !ready_signaled { diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 697d7971..a5ba589d 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -520,30 +520,6 @@ impl ScreenTracker { && (visible.contains("yes") || visible.contains('❯'))) } - /// Codex at rest: its composer is drawn and empty with the `? for shortcuts` - /// hint beside it, and no approval dialog is visible. Codex replaces that - /// composer and hint while an approval or other modal holds the bottom pane - /// (whose `›` option rows can still satisfy the ready pattern and the input - /// scrape), so this, not `is_ready()`, is the evidence that a dialog left. - /// - /// The prompt row must be drawn and the hint must sit below it: an empty - /// input scrape alone can come from the ready-pattern fallback with no - /// prompt row, and a hint line can linger in history above a modal. - pub fn is_codex_idle_composer(&self) -> bool { - let lines = self.get_screen_lines(); - let Some(prompt_row) = lines.iter().rposition(|line| { - let trimmed = line.trim_start(); - trimmed.starts_with('›') || trimmed.starts_with('»') - }) else { - return false; - }; - self.get_codex_input_text().is_some_and(|t| t.is_empty()) - && lines[prompt_row + 1..] - .iter() - .any(|line| line.trim_start().starts_with("? for shortcuts")) - && !self.is_codex_approval_visible() - } - /// Antigravity-specific approval detection: the agy TUI renders permission /// prompts as plain text in the prompt area ("Requesting permission for: …", /// a per-kind question such as "Run this command?", a "1. Yes … 4. No" menu @@ -1680,74 +1656,6 @@ mod tests { assert!(t.is_codex_approval_visible()); } - /// A real Codex screen (Codex 26.x, /voice on but muted, custom status - /// line), text anonymized: idle, nothing to approve, yet hcom had held its - /// approval latch for almost two hours. - fn idle_voice_screen() -> &'static str { - "\u{2022} Done: the change is in and the targeted tests pass.\r\n\r\n\ - \x20voice \u{25cc} muted \u{2303}x unmute /voice stop\r\n\ - \x20 mic \u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\u{2581} codex \u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\u{2581}\r\n\r\n\ - \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n\ - \x20 ~/src/project \u{b7} main \u{b7} gpt-model high \u{b7} Context 45% used\r\n\ - \x20 ? for shortcuts \u{26a0} 1 warning \u{b7} f2 to view\r\n" - } - - #[test] - fn codex_idle_composer_on_an_idle_voice_screen() { - let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); - t.process(idle_voice_screen().as_bytes()); - assert!(t.is_ready()); - assert!(!t.is_codex_approval_visible()); - assert!(t.is_codex_idle_composer()); - } - - #[test] - fn codex_idle_composer_needs_its_prompt_row_above_the_hint() { - // An unrecognized modal (no prompt row) whose breadcrumb contains `› ` - // satisfies the ready pattern, so the input scrape falls back to an - // empty prompt; a `? for shortcuts` line quoted in history above it is - // not the composer's hint. - let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); - t.process( - "\u{2022} Press ? for shortcuts in the composer.\r\n\ - \x20 ? for shortcuts\r\n\r\n\ - \x20 Settings \u{203a} Model\r\n\r\n\ - \x20 gpt-model high\r\n gpt-model low\r\n" - .as_bytes(), - ); - assert!(!t.is_codex_approval_visible()); - assert_eq!(t.get_codex_input_text().as_deref(), Some("")); - assert!(!t.is_codex_idle_composer()); - } - - #[test] - fn codex_idle_composer_is_not_an_approval_dialog_or_a_draft() { - // History `›` rows and the dialog's own `›` option both satisfy Codex's - // ready pattern and the input scrape, but the dialog replaced the idle - // composer and its `? for shortcuts` hint. - let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); - t.process( - "\u{203a} Check the service once it is up\r\n\r\n\ - \x20 Would you like to run the following command?\r\n\r\n $ curl -s https://example.com/health\r\n\r\n\ - \u{203a} 1. Yes, proceed (y)\r\n 2. No, and tell Codex what to do differently (esc)\r\n\r\n\ - \x20 Press enter to confirm or esc to cancel\r\n" - .as_bytes(), - ); - assert!( - t.is_ready(), - "the `\u{203a}` rows satisfy the ready pattern" - ); - assert!(t.is_codex_approval_visible()); - assert!(!t.is_codex_idle_composer()); - - let mut t = make_tool_tracker(24, 100, crate::tool::Tool::Codex); - t.process("\u{203a} half-typed draft\r\n\r\n ? for shortcuts\r\n".as_bytes()); - assert!( - !t.is_codex_idle_composer(), - "a draft is not an idle composer" - ); - } - #[test] fn codex_transcript_viewer_is_not_approval() { let mut t = make_tracker(24, 80, ""); diff --git a/src/pty/shared.rs b/src/pty/shared.rs index ffec96c9..1a367837 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -19,7 +19,7 @@ use anyhow::Result; use crate::config::Config; use crate::db::HcomDb; use crate::delivery::{ - APPROVAL_SCRAPE_CLEAR_MS, DeliveryState, ScreenState, ToolConfig, codex_composer_settled, + APPROVAL_SCRAPE_CLEAR_MS, DeliveryState, ScreenState, ToolConfig, approval_scrape_cleared, latch_scraped_approval, run_delivery_loop, }; use crate::log::{log_error, log_info, log_warn}; @@ -182,31 +182,25 @@ pub(super) fn update_delivery_state( state.ready = screen.is_ready(); // Cursor and Codex can briefly erase their approval surfaces during // redraws (Codex does this on focus changes). Latch positive detection - // until output settles so a partial frame cannot clear blocked status. + // until the scrape stays negative (or output goes quiet on a negative + // frame) so a partial frame cannot clear blocked status. let scrape_latched_tool = matches!(target.known_tool(), Some(Tool::Codex | Tool::Cursor)); let scraped_approval = match target.known_tool() { Some(Tool::Codex) => screen.is_waiting_approval() || screen.is_codex_approval_visible(), Some(Tool::Cursor) => screen.is_cursor_approval_visible(), _ => false, }; - // Codex draws its idle composer (empty, `? for shortcuts`) only when no - // approval or other modal holds the bottom pane. Seen for a while, that is - // evidence enough to trust a negative scrape: with /voice on, its meters - // keep output churning and the quiet-output test alone never clears. let now = Instant::now(); - let input_text = screen.get_input_box_text(target.name()); - let codex_composer = - matches!(target.known_tool(), Some(Tool::Codex)) && screen.is_codex_idle_composer(); - state.codex_composer_since = if codex_composer { - Some(state.codex_composer_since.unwrap_or(now)) - } else { + state.approval_scrape_negative_since = if scraped_approval { None + } else { + Some(state.approval_scrape_negative_since.unwrap_or(now)) }; state.approval_scrape_latched = latch_scraped_approval( state.approval_scrape_latched, scraped_approval, screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS) - || codex_composer_settled(state.codex_composer_since, now), + || approval_scrape_cleared(state.approval_scrape_negative_since, now), ); let approval = (scrape_latched_tool && state.approval_scrape_latched) || (target.name() == "antigravity" && screen.is_antigravity_approval_visible()); @@ -214,6 +208,7 @@ pub(super) fn update_delivery_state( approval_changed = Some(approval); } state.approval = approval; + let input_text = screen.get_input_box_text(target.name()); let new_prompt_empty = input_text.as_ref().is_some_and(|t| t.is_empty()); // Stamp submit-edge cooldown when input transitions from a known // non-empty value to empty or briefly undetected. Guards against @@ -1948,132 +1943,61 @@ No session found matching 'never-saved'" } #[test] - fn codex_approval_clears_on_its_idle_composer_while_voice_meters_churn() { - // Observed: an answered approval stayed latched for almost two - // hours. Codex's /voice footer redraws its meters continuously, so output - // never stayed quiet for APPROVAL_SCRAPE_CLEAR_MS, and every idle-time - // message waited behind "Gate blocked: approval" until a manual inject. + fn approval_clears_while_output_keeps_churning() { + // Codex's /voice meters redraw continuously, so output is never quiet. + // An answered approval must still clear once the scrape stays negative. let target = PtyTarget::Known(Tool::Codex); - let patterns: Vec = Tool::Codex - .ready_patterns() - .iter() - .map(|p| p.to_string()) - .collect(); - let mut screen = ScreenTracker::new_with_instance(24, 100, &patterns, None); + let mut screen = ScreenTracker::new_with_instance(24, 100, &[], None); let state = Arc::new(RwLock::new(ScreenState::default())); let launch = Arc::new(AtomicBool::new(false)); let edges = std::cell::RefCell::new(Vec::new()); let publish = |a: bool| edges.borrow_mut().push(a); + let dialog = "\x1b[2J\x1b[H\u{203a} 1. Yes, proceed (y)\r\n 2. No (esc)\r\n\r\n Press enter to confirm or esc to cancel\r\n"; let idle = |screen: &mut ScreenTracker, level: char| { screen.process( format!( - "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n mic {level}{level}{level} codex \u{2581}\u{2581}\r\n\r\n\ - \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n ? for shortcuts\r\n" + "\x1b[2J\x1b[H voice \u{25cc} muted\r\n mic {level}{level}{level}\r\n\r\n\u{203a} Ask Codex to do anything\r\n\r\n ? for shortcuts\r\n" ) .as_bytes(), ) }; + let backdate = |state: &Arc>| { + let mut s = state.write().unwrap(); + s.approval_scrape_negative_since = + Some(Instant::now() - Duration::from_millis(APPROVAL_SCRAPE_CLEAR_MS)); + }; - screen.process( - "\u{203a} 1. Yes, proceed (y)\r\n 2. No (esc)\r\n\r\n Press enter to confirm or esc to cancel\r\n".as_bytes(), - ); + screen.process(dialog.as_bytes()); update_delivery_state(&state, &screen, &target, &launch, &publish); - assert!( - state.read().unwrap().approval, - "the dialog latches approval" - ); + assert!(state.read().unwrap().approval); - // Answered: the idle composer is back, but the meters keep redrawing. + // A negative frame alone may be a partial redraw. idle(&mut screen, '\u{2581}'); update_delivery_state(&state, &screen, &target, &launch, &publish); - assert!( - state.read().unwrap().approval, - "a composer just drawn may be a passing redraw" - ); + assert!(state.read().unwrap().approval); - // Still churning, but the composer has stood for CODEX_COMPOSER_CLEAR_MS. - { - let mut s = state.write().unwrap(); - s.codex_composer_since = Some( - Instant::now() - Duration::from_millis(crate::delivery::CODEX_COMPOSER_CLEAR_MS), - ); - } - idle(&mut screen, '\u{2582}'); - assert!( - !screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS), - "output is still churning" - ); + // The dialog coming back restarts the wait. + backdate(&state); + screen.process(dialog.as_bytes()); update_delivery_state(&state, &screen, &target, &launch, &publish); assert!( - !state.read().unwrap().approval, - "the idle composer clears the latch" + state + .read() + .unwrap() + .approval_scrape_negative_since + .is_none() ); - assert_eq!(*edges.borrow(), vec![true, false]); - } - - #[test] - fn codex_interrupted_idle_composer_restarts_its_wait() { - // The idle composer must stand unbroken for CODEX_COMPOSER_CLEAR_MS: a - // draft or a dialog in between restarts the wait. - let target = PtyTarget::Known(Tool::Codex); - let patterns: Vec = Tool::Codex - .ready_patterns() - .iter() - .map(|p| p.to_string()) - .collect(); - let mut screen = ScreenTracker::new_with_instance(24, 100, &patterns, None); - let state = Arc::new(RwLock::new(ScreenState::default())); - let launch = Arc::new(AtomicBool::new(false)); - let publish = |_: bool| {}; - let dialog = "\x1b[2J\x1b[H\u{203a} 1. Yes, proceed (y)\r\n 2. No (esc)\r\n\r\n Press enter to confirm or esc to cancel\r\n"; - let idle = |screen: &mut ScreenTracker, level: char| { - screen.process( - format!( - "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n mic {level}{level}{level} codex \u{2581}\u{2581}\r\n\r\n\ - \u{203a} \x1b[2mAsk Codex to do anything\x1b[0m\r\n\r\n ? for shortcuts\r\n" - ) - .as_bytes(), - ) - }; - let backdate = |state: &Arc>| { - let mut s = state.write().unwrap(); - assert!(s.codex_composer_since.is_some(), "the composer is timed"); - s.codex_composer_since = Some( - Instant::now() - Duration::from_millis(crate::delivery::CODEX_COMPOSER_CLEAR_MS), - ); - }; - - screen.process(dialog.as_bytes()); + idle(&mut screen, '\u{2582}'); update_delivery_state(&state, &screen, &target, &launch, &publish); assert!(state.read().unwrap().approval); - for interruption in [ - "\x1b[2J\x1b[H \x20voice \u{25cc} muted\r\n\r\n\u{203a} half-typed draft\r\n\r\n ? for shortcuts\r\n", - dialog, - ] { - idle(&mut screen, '\u{2581}'); - update_delivery_state(&state, &screen, &target, &launch, &publish); - backdate(&state); - - screen.process(interruption.as_bytes()); - update_delivery_state(&state, &screen, &target, &launch, &publish); - assert!(state.read().unwrap().codex_composer_since.is_none()); - assert!(state.read().unwrap().approval); - - // Shown again while the meters churn: the full wait starts over. - idle(&mut screen, '\u{2582}'); - assert!(!screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS)); - update_delivery_state(&state, &screen, &target, &launch, &publish); - assert!( - state.read().unwrap().approval, - "a composer back after an interruption waits again" - ); - } - + // Negative for the full wait while the meters still churn: clear. backdate(&state); idle(&mut screen, '\u{2583}'); + assert!(!screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS)); update_delivery_state(&state, &screen, &target, &launch, &publish); assert!(!state.read().unwrap().approval); + assert_eq!(*edges.borrow(), vec![true, false]); } #[test]