fno_agents/readiness.rs
1//! Readiness detection (design module `readiness.rs`).
2//!
3//! A PTY-managed agent is "ready" when its CLI is waiting for input (prompt
4//! drawn, not mid-render, not at an auth wall). The daemon must not send `ask`
5//! input before readiness or the keystrokes land in the wrong UI state.
6//!
7//! Open Question #9 is a load-bearing constraint here: AgentRelay's "total
8//! bytes > 500 -> assume ready" generic fallback is **rejected**. A banner can
9//! emit 500 bytes without the CLI being ready, and Gemini's "Waiting for auth"
10//! is a known false-ready. So:
11//!
12//! - Per-CLI [`ReadinessDetector`] impls are mandatory; there is no generic
13//! byte-count detector.
14//! - Absence of a per-CLI signal is [`ReadinessError::UnknownReadinessSignal`],
15//! surfaced as a runtime error, never a guessed `true`.
16//!
17//! Wave 1 shipped the trait + the [`ScreenView`] seam + a fully-specified test
18//! detector. Wave 2 adds the [`ScreenView`] construction (in [`crate::screen`],
19//! backed by `alacritty_terminal`) and the real per-CLI
20//! [`CodexReadinessDetector`] / [`GeminiReadinessDetector`] impls below
21//! (Open Questions #2/#3).
22
23/// A read-only view of the terminal screen the detector inspects. It is built
24/// from the terminal grid in [`crate::screen`] after feeding it the PTY output
25/// stream; the trait depends only on this shape so the substrate stays
26/// decoupled from the terminal-emulator crate.
27#[derive(Debug, Clone, Copy)]
28pub struct ScreenView<'a> {
29 /// The visible screen contents, rows joined by `\n`, trailing blanks
30 /// trimmed. What a human would see.
31 pub visible_text: &'a str,
32 /// Cursor position (0-based). Some prompts are only distinguishable by
33 /// where the cursor rests.
34 pub cursor_row: usize,
35 pub cursor_col: usize,
36 /// Latest OSC window title (OSC 0/2), captured from the PTY byte stream
37 /// (E6.1). The manifest engine's `osc_title` region reads this; it survives
38 /// scrollback/wrap/resize where grid text does not. `None` if no title OSC
39 /// has been seen (e.g. the grid-pane scanner, which does not capture OSC).
40 pub osc_title: Option<&'a str>,
41 /// Latest OSC 9;4 progress payload (E6.1); the engine's `osc_progress`
42 /// region. `None` if none seen.
43 pub osc_progress: Option<&'a str>,
44}
45
46#[derive(Debug, thiserror::Error, PartialEq, Eq)]
47pub enum ReadinessError {
48 /// No per-CLI readiness signal is available for this provider. Surfaced as
49 /// a runtime error rather than a guessed readiness (Open Question #9).
50 #[error("no readiness signal available for provider '{provider}'; refusing to guess")]
51 UnknownReadinessSignal { provider: String },
52}
53
54/// Per-CLI readiness signal. Implementations inspect a [`ScreenView`] and
55/// return whether the CLI is waiting for input. Implementations MUST NOT infer
56/// readiness from raw byte counts.
57pub trait ReadinessDetector: Send + Sync {
58 /// `&str` (not `&'static str`) so a detector can name a provider known only
59 /// at runtime (the daemon's wildcard NoSignalDetector carries the real
60 /// provider name rather than the literal "unknown"; cv-789fdba0).
61 fn provider_name(&self) -> &str;
62
63 /// `Ok(true)` when the CLI is ready for input, `Ok(false)` when not yet,
64 /// `Err(UnknownReadinessSignal)` when this provider has no usable signal.
65 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError>;
66}
67
68/// Detector for providers that genuinely have no readiness signal yet. Always
69/// errors; exists so the daemon can register a placeholder and get the
70/// fail-loud behavior instead of a silent false-positive. (A previous design
71/// would have returned `Ok(true)` here; that is the bug Open Question #9 bans.)
72pub struct NoSignalDetector {
73 pub provider: String,
74}
75
76impl ReadinessDetector for NoSignalDetector {
77 fn provider_name(&self) -> &str {
78 &self.provider
79 }
80
81 fn is_ready(&self, _screen: &ScreenView) -> Result<bool, ReadinessError> {
82 Err(ReadinessError::UnknownReadinessSignal {
83 provider: self.provider.clone(),
84 })
85 }
86}
87
88// ---------------------------------------------------------------------------
89// Per-CLI detectors (Wave 2). Codex = Open Question #2; Gemini = Open Question
90// #3. Both follow the same discipline: never claim readiness from byte counts;
91// reject known false-ready walls (auth / trust prompts) and mid-work states;
92// claim ready only on a positive prompt-glyph signal.
93//
94// SMOKE-PINNING NOTE: the exact prompt glyphs each CLI draws when idle are
95// best-known from the providers' documentation and the US4 captures, but have
96// not been pinned against a live interactive TUI in this PR (running an
97// interactive CLI headlessly risks an auth/trust hang). The detectors are
98// CONSERVATIVE by construction: the only failure mode of a wrong glyph is a
99// false-NOT-ready (the daemon waits/retries), never a false-ready (input sent
100// into the wrong UI state) - which is exactly the bias Open Question #9
101// mandates. `cli/scripts/smoke/capture-readiness-grid.sh` regenerates the grid
102// fixtures against the live CLIs; tune `PROMPT_GLYPHS` from a capture.
103// ---------------------------------------------------------------------------
104
105/// Prompt indicators a modern CLI composer draws on its idle input line. A
106/// match on the last non-blank line's trailing glyph is the positive readiness
107/// signal. Centralized so a smoke capture tunes one place.
108pub const PROMPT_GLYPHS: &[char] = &['\u{276f}', '\u{203a}', '\u{2595}']; // ❯ › ▌
109
110/// Visible substrings that mean the CLI is mid-work and NOT accepting input,
111/// even if a prompt glyph is also on screen.
112const BUSY_MARKERS: &[&str] = &[
113 "esc to interrupt",
114 "Esc to interrupt",
115 "Working",
116 "Thinking",
117];
118
119/// Visible substrings for a blocking wall the operator must clear first (auth,
120/// trust). The Gemini "Waiting for auth" false-ready (Open Question #3) lives
121/// here.
122const WALL_MARKERS: &[&str] = &[
123 "Waiting for auth",
124 "waiting for auth",
125 "Do you trust",
126 "Login required",
127];
128
129/// How many trailing non-blank lines form the "status region" the busy/wall
130/// markers are matched against. These CLIs draw their composer + status bar
131/// (the spinner, "esc to interrupt", auth wall) in the bottom few rows; model
132/// reply text scrolls ABOVE it. Scoping the marker match to this region stops a
133/// normal reply that happens to contain "Working"/"Thinking" from pinning the
134/// detector permanently not-ready (Codex review P1).
135const STATUS_REGION_LINES: usize = 3;
136
137/// Shared readiness decision: not-ready under any wall or busy marker *in the
138/// bottom status region*; otherwise ready only when the last non-blank line
139/// ends with a recognized prompt glyph. Never guesses from byte counts (Open
140/// Question #9).
141fn prompt_ready(screen: &ScreenView, glyphs: &[char]) -> bool {
142 let nonblank: Vec<&str> = screen
143 .visible_text
144 .lines()
145 .filter(|l| !l.trim().is_empty())
146 .collect();
147 let Some(last) = nonblank.last() else {
148 return false; // blank screen: nothing drawn yet, not ready
149 };
150 // Status region = the last few non-blank lines (where the composer/status
151 // bar lives), NOT the whole scrollback.
152 let region_start = nonblank.len().saturating_sub(STATUS_REGION_LINES);
153 let region = nonblank[region_start..].join("\n");
154 if WALL_MARKERS.iter().any(|m| region.contains(m)) {
155 return false;
156 }
157 if BUSY_MARKERS.iter().any(|m| region.contains(m)) {
158 return false;
159 }
160 let tail = last.trim_end();
161 glyphs.iter().any(|g| tail.ends_with(*g))
162}
163
164/// Provider-agnostic readiness check for the grid attention scanner
165/// (fu-grid-pagination / ab-82dddd5f). codex + gemini share [`PROMPT_GLYPHS`]
166/// and the same [`prompt_ready`] logic, and the grid only ever hosts those
167/// two (claude is excluded, grid Locked Decision 6), so a single shared check
168/// is correct here. Run client-side on a pane's `Term` snapshot to flag an
169/// off-screen agent waiting for input. Inherits the same wall / busy-marker
170/// discipline as the per-CLI detectors: never a byte-count guess, and a
171/// "Waiting for auth" wall reads as not-waiting. A brief false / late badge
172/// is tolerable for an awareness hint, but a false-ready into a busy state is
173/// not - hence the shared `prompt_ready` bias.
174pub fn screen_is_waiting(screen: &ScreenView) -> bool {
175 prompt_ready(screen, PROMPT_GLYPHS)
176}
177
178/// Codex interactive-composer readiness (Open Question #2).
179pub struct CodexReadinessDetector;
180
181impl ReadinessDetector for CodexReadinessDetector {
182 fn provider_name(&self) -> &str {
183 "codex"
184 }
185
186 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
187 Ok(prompt_ready(screen, PROMPT_GLYPHS))
188 }
189}
190
191/// Gemini interactive-composer readiness (Open Question #3). Shares the prompt
192/// glyph set with codex; the load-bearing difference is rejecting the
193/// "Waiting for auth" wall, handled by the shared `WALL_MARKERS`.
194pub struct GeminiReadinessDetector;
195
196impl ReadinessDetector for GeminiReadinessDetector {
197 fn provider_name(&self) -> &str {
198 "gemini"
199 }
200
201 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
202 Ok(prompt_ready(screen, PROMPT_GLYPHS))
203 }
204}
205
206/// Agy interactive-composer readiness (Phase C, agy harness). agy runs Gemini
207/// models under the hood and draws the same prompt-glyph family as the other
208/// CLIs, so the shared [`prompt_ready`] logic applies unchanged. The
209/// conservative bias (a wrong glyph is false-NOT-ready, never false-ready) holds
210/// here as for the others. Glyphs tune against a live agy capture (deferred).
211pub struct AgyReadinessDetector;
212
213impl ReadinessDetector for AgyReadinessDetector {
214 fn provider_name(&self) -> &str {
215 "agy"
216 }
217
218 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
219 Ok(prompt_ready(screen, PROMPT_GLYPHS))
220 }
221}
222
223/// Claude interactive-composer readiness (inside-out-multiplexer E1). Claude's
224/// TUI composer draws the same prompt-glyph family as codex/gemini and its
225/// mid-turn "esc to interrupt" status line is already in [`BUSY_MARKERS`], so
226/// the shared [`prompt_ready`] logic applies unchanged. Threshold tuning against
227/// a live capture is Claude's Discretion (the design's readiness-detector
228/// bullet); the conservative bias (a wrong glyph is false-NOT-ready, never
229/// false-ready) holds here as for the other CLIs.
230pub struct ClaudeReadinessDetector;
231
232impl ReadinessDetector for ClaudeReadinessDetector {
233 fn provider_name(&self) -> &str {
234 "claude"
235 }
236
237 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
238 Ok(prompt_ready(screen, PROMPT_GLYPHS))
239 }
240}
241
242/// opencode TUI readiness (x-51f6 US3). Markers pinned from a LIVE capture
243/// (opencode 1.14.50, mux-pane hosted), not guessed:
244///
245/// - **idle**: the composer box draws a `┃` (U+2503) left edge; the footer
246/// hint row reads "tab agents ctrl+p commands". The LAST line is a status
247/// bar (`~/path:branch … 1.14.50`), so the shared trailing-glyph
248/// [`prompt_ready`] can never fire for opencode — a positive signal needs
249/// the composer edge instead.
250/// - **working**: the footer swaps to a progress fill (`⬝⬝⬝⬝■■■■`) plus an
251/// "esc interrupt" hint. NOTE: live 1.14.50 says "esc interrupt" (no "to"),
252/// which the shared `BUSY_MARKERS` miss — matched here on "interrupt".
253/// - **blocked**: the "Permission required" dialog (same marker family as
254/// `manifests/opencode.toml`) and the shared auth [`WALL_MARKERS`].
255///
256/// Conservative bias holds (readiness OQ#9): the composer edge is required
257/// AND every busy/blocked marker vetoes, so a UI change degrades to
258/// false-NOT-ready (footnote waits), never a false-ready dispatch.
259pub struct OpencodeReadinessDetector;
260
261/// Bottom non-blank lines forming opencode's status region: the composer box
262/// (edge rows + model line), the hint row, and the status bar — deeper than
263/// the shared 3-line region because opencode's composer is a multi-row box.
264const OPENCODE_STATUS_REGION_LINES: usize = 8;
265
266impl ReadinessDetector for OpencodeReadinessDetector {
267 fn provider_name(&self) -> &str {
268 "opencode"
269 }
270
271 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
272 let nonblank: Vec<&str> = screen
273 .visible_text
274 .lines()
275 .filter(|l| !l.trim().is_empty())
276 .collect();
277 if nonblank.is_empty() {
278 return Ok(false); // blank screen: nothing drawn yet
279 }
280 let start = nonblank.len().saturating_sub(OPENCODE_STATUS_REGION_LINES);
281 let region = nonblank[start..].join("\n");
282 if WALL_MARKERS.iter().any(|m| region.contains(m)) {
283 return Ok(false);
284 }
285 // "interrupt" covers "esc interrupt" (live 1.14.50) and the "esc to
286 // interrupt"/"ctrl+c to interrupt" variants; the ⬝/■ runs are the
287 // progress fill; "Permission required" is the blocked dialog.
288 const OPENCODE_NOT_READY: &[&str] = &[
289 "interrupt",
290 "Permission required",
291 "\u{25a0}\u{25a0}\u{25a0}\u{25a0}",
292 "\u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}",
293 ];
294 if OPENCODE_NOT_READY.iter().any(|m| region.contains(m)) {
295 return Ok(false);
296 }
297 // Positive signal: the composer's `┃` left edge in the status region.
298 // A dialog overlay or half-drawn screen has no edge -> not ready.
299 Ok(nonblank[start..]
300 .iter()
301 .any(|l| l.trim_start().starts_with('\u{2503}')))
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 /// A minimal grid-pattern detector to exercise the trait. Real Codex/Gemini
310 /// detectors (Wave 2) match smoke-captured prompts; this stand-in matches a
311 /// trailing prompt glyph on the cursor row and rejects a known false-ready
312 /// auth banner, mirroring the discipline the real impls must follow.
313 struct PromptGlyphDetector;
314 impl ReadinessDetector for PromptGlyphDetector {
315 fn provider_name(&self) -> &str {
316 "test-cli"
317 }
318 fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
319 if screen.visible_text.contains("Waiting for auth") {
320 return Ok(false); // known false-ready (the Gemini trap)
321 }
322 Ok(screen.visible_text.trim_end().ends_with('\u{276f}')) // matches "❯"
323 }
324 }
325
326 fn view(text: &str) -> ScreenView<'_> {
327 ScreenView {
328 visible_text: text,
329 cursor_row: 0,
330 cursor_col: 0,
331 osc_title: None,
332 osc_progress: None,
333 }
334 }
335
336 #[test]
337 fn ready_on_prompt_glyph() {
338 let d = PromptGlyphDetector;
339 assert_eq!(d.is_ready(&view("project \u{276f}")), Ok(true));
340 assert_eq!(d.is_ready(&view("loading...")), Ok(false));
341 }
342
343 #[test]
344 fn auth_wall_is_not_ready() {
345 let d = PromptGlyphDetector;
346 // Even with a trailing glyph, the auth banner must report not-ready.
347 assert_eq!(d.is_ready(&view("Waiting for auth \u{276f}")), Ok(false));
348 }
349
350 // ---- opencode (x-51f6 US3): fixtures condensed from a live 1.14.50
351 // mux-pane capture (capture method: `fno mux pane run -- opencode` +
352 // `pane read`), per the markers-from-capture locked decision. ----
353
354 fn opencode_screen(footer_hint: &str) -> String {
355 format!(
356 " \u{2503}\n \u{2503} Ask anything... \"Fix a TODO in the codebase\"\n \u{2503}\n \u{2503} Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n \u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n{footer_hint}\n ~/proj:main \u{2299} 6 MCP /status 1.14.50"
357 )
358 }
359
360 #[test]
361 fn opencode_idle_composer_is_ready() {
362 let d = OpencodeReadinessDetector;
363 let screen = opencode_screen(" tab agents ctrl+p commands");
364 assert_eq!(d.is_ready(&view(&screen)), Ok(true));
365 assert_eq!(d.provider_name(), "opencode");
366 }
367
368 #[test]
369 fn opencode_progress_fill_or_interrupt_hint_is_busy() {
370 let d = OpencodeReadinessDetector;
371 // Live 1.14.50 working footer: fill run + "esc interrupt" (no "to").
372 let working = opencode_screen(
373 " \u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}\u{25a0}\u{25a0}\u{25a0}\u{25a0} esc interrupt tab agents ctrl+p commands",
374 );
375 assert_eq!(d.is_ready(&view(&working)), Ok(false));
376 // The shared "esc to interrupt" spelling stays busy too.
377 let alt = opencode_screen(" esc to interrupt");
378 assert_eq!(d.is_ready(&view(&alt)), Ok(false));
379 }
380
381 #[test]
382 fn opencode_permission_dialog_and_auth_wall_are_not_ready() {
383 let d = OpencodeReadinessDetector;
384 let perm = opencode_screen(" \u{25b3} Permission required");
385 assert_eq!(d.is_ready(&view(&perm)), Ok(false));
386 let wall = opencode_screen(" Login required");
387 assert_eq!(d.is_ready(&view(&wall)), Ok(false));
388 }
389
390 #[test]
391 fn opencode_stale_blocker_above_the_8_line_window_does_not_block() {
392 // A "Permission required" / progress-fill marker that has scrolled
393 // ABOVE the trailing OPENCODE_STATUS_REGION_LINES(8) window (into old
394 // reply text) must not veto readiness - only the bottom composer/
395 // status region gates it (mirrors busy_word_in_model_reply_above_
396 // status_region_does_not_block for the shared 3-line window). This
397 // fixture has 11 non-blank lines; the last 8 exclude both noise lines.
398 let d = OpencodeReadinessDetector;
399 let screen = "Permission required somewhere in old reply\n\
400 \u{25a0}\u{25a0}\u{25a0}\u{25a0} progress marker from old scrollback\n\
401 filler line one\n\
402 filler line two\n\
403 \u{2503}\n\
404 \u{2503} Ask anything... \"Fix a TODO in the codebase\"\n\
405 \u{2503}\n\
406 \u{2503} Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n\
407 \u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n\
408 tab agents ctrl+p commands\n\
409 ~/proj:main \u{2299} 6 MCP /status 1.14.50";
410 assert_eq!(d.is_ready(&view(screen)), Ok(true));
411 }
412
413 #[test]
414 fn opencode_no_composer_edge_is_not_ready() {
415 let d = OpencodeReadinessDetector;
416 // A half-drawn screen / full-screen dialog with no `┃` edge must read
417 // not-ready (conservative bias), and a blank screen too.
418 assert_eq!(d.is_ready(&view("loading opencode...")), Ok(false));
419 assert_eq!(d.is_ready(&view("")), Ok(false));
420 }
421
422 #[test]
423 fn no_signal_detector_errors_never_guesses() {
424 // "aider" is the canonical genuinely-unhosted CLI (opencode graduated
425 // to a real detector at x-51f6).
426 let d = NoSignalDetector {
427 provider: "aider".to_string(),
428 };
429 // The detector reports the real provider name (not the literal
430 // "unknown") in both provider_name() and the error (cv-789fdba0).
431 assert_eq!(d.provider_name(), "aider");
432 assert_eq!(
433 d.is_ready(&view("anything at all, 9999 bytes of banner")),
434 Err(ReadinessError::UnknownReadinessSignal {
435 provider: "aider".into()
436 })
437 );
438 }
439
440 #[test]
441 fn codex_detector_ready_on_idle_prompt() {
442 let d = CodexReadinessDetector;
443 assert_eq!(d.provider_name(), "codex");
444 // Idle composer: last non-blank line ends with a prompt glyph.
445 assert_eq!(
446 d.is_ready(&view(
447 "codex 0.130\n\n build feature X\n\u{276f} ".trim_end()
448 )),
449 Ok(true)
450 );
451 }
452
453 #[test]
454 fn codex_detector_not_ready_while_working() {
455 let d = CodexReadinessDetector;
456 // Busy marker overrides any prompt glyph also on screen.
457 assert_eq!(
458 d.is_ready(&view("running tool...\nEsc to interrupt\n\u{276f}")),
459 Ok(false)
460 );
461 }
462
463 #[test]
464 fn codex_detector_not_ready_without_prompt_signal() {
465 let d = CodexReadinessDetector;
466 // No prompt glyph anywhere: refuse to claim ready (no byte-count guess).
467 assert_eq!(
468 d.is_ready(&view("loading a 5000 byte banner of text")),
469 Ok(false)
470 );
471 }
472
473 #[test]
474 fn gemini_detector_rejects_waiting_for_auth_false_ready() {
475 let d = GeminiReadinessDetector;
476 assert_eq!(d.provider_name(), "gemini");
477 // The documented Gemini trap: prompt glyph present but auth wall up.
478 assert_eq!(
479 d.is_ready(&view("Waiting for auth...\n\u{276f}")),
480 Ok(false)
481 );
482 }
483
484 #[test]
485 fn gemini_detector_ready_on_idle_prompt() {
486 let d = GeminiReadinessDetector;
487 assert_eq!(
488 d.is_ready(&view("Gemini ready\n\u{203a} ".trim_end())),
489 Ok(true)
490 );
491 }
492
493 #[test]
494 fn busy_word_in_model_reply_above_status_region_does_not_block() {
495 // A normal reply mentioning "Working" / "Thinking" scrolls ABOVE the
496 // composer; only the bottom status region gates readiness (Codex P1).
497 let d = CodexReadinessDetector;
498 let screen = "I am Working on the Thinking task you asked about.\n\
499 Here is a long reply that mentions Working again.\n\
500 filler line\n\
501 another filler\n\
502 \u{276f} ";
503 assert_eq!(d.is_ready(&view(screen.trim_end())), Ok(true));
504 }
505}