fno-agents 0.3.1

PTY supervisor substrate for persistent, attachable multi-CLI coding agents (codex, gemini, claude)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! Readiness detection (design module `readiness.rs`).
//!
//! A PTY-managed agent is "ready" when its CLI is waiting for input (prompt
//! drawn, not mid-render, not at an auth wall). The daemon must not send `ask`
//! input before readiness or the keystrokes land in the wrong UI state.
//!
//! Open Question #9 is a load-bearing constraint here: AgentRelay's "total
//! bytes > 500 -> assume ready" generic fallback is **rejected**. A banner can
//! emit 500 bytes without the CLI being ready, and Gemini's "Waiting for auth"
//! is a known false-ready. So:
//!
//! - Per-CLI [`ReadinessDetector`] impls are mandatory; there is no generic
//!   byte-count detector.
//! - Absence of a per-CLI signal is [`ReadinessError::UnknownReadinessSignal`],
//!   surfaced as a runtime error, never a guessed `true`.
//!
//! Wave 1 shipped the trait + the [`ScreenView`] seam + a fully-specified test
//! detector. Wave 2 adds the [`ScreenView`] construction (in [`crate::screen`],
//! backed by `alacritty_terminal`) and the real per-CLI
//! [`CodexReadinessDetector`] / [`GeminiReadinessDetector`] impls below
//! (Open Questions #2/#3).

/// A read-only view of the terminal screen the detector inspects. It is built
/// from the terminal grid in [`crate::screen`] after feeding it the PTY output
/// stream; the trait depends only on this shape so the substrate stays
/// decoupled from the terminal-emulator crate.
#[derive(Debug, Clone, Copy)]
pub struct ScreenView<'a> {
    /// The visible screen contents, rows joined by `\n`, trailing blanks
    /// trimmed. What a human would see.
    pub visible_text: &'a str,
    /// Cursor position (0-based). Some prompts are only distinguishable by
    /// where the cursor rests.
    pub cursor_row: usize,
    pub cursor_col: usize,
    /// Latest OSC window title (OSC 0/2), captured from the PTY byte stream
    /// (E6.1). The manifest engine's `osc_title` region reads this; it survives
    /// scrollback/wrap/resize where grid text does not. `None` if no title OSC
    /// has been seen (e.g. the grid-pane scanner, which does not capture OSC).
    pub osc_title: Option<&'a str>,
    /// Latest OSC 9;4 progress payload (E6.1); the engine's `osc_progress`
    /// region. `None` if none seen.
    pub osc_progress: Option<&'a str>,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ReadinessError {
    /// No per-CLI readiness signal is available for this provider. Surfaced as
    /// a runtime error rather than a guessed readiness (Open Question #9).
    #[error("no readiness signal available for provider '{provider}'; refusing to guess")]
    UnknownReadinessSignal { provider: String },
}

/// Per-CLI readiness signal. Implementations inspect a [`ScreenView`] and
/// return whether the CLI is waiting for input. Implementations MUST NOT infer
/// readiness from raw byte counts.
pub trait ReadinessDetector: Send + Sync {
    /// `&str` (not `&'static str`) so a detector can name a provider known only
    /// at runtime (the daemon's wildcard NoSignalDetector carries the real
    /// provider name rather than the literal "unknown"; cv-789fdba0).
    fn provider_name(&self) -> &str;

    /// `Ok(true)` when the CLI is ready for input, `Ok(false)` when not yet,
    /// `Err(UnknownReadinessSignal)` when this provider has no usable signal.
    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError>;
}

/// Detector for providers that genuinely have no readiness signal yet. Always
/// errors; exists so the daemon can register a placeholder and get the
/// fail-loud behavior instead of a silent false-positive. (A previous design
/// would have returned `Ok(true)` here; that is the bug Open Question #9 bans.)
pub struct NoSignalDetector {
    pub provider: String,
}

impl ReadinessDetector for NoSignalDetector {
    fn provider_name(&self) -> &str {
        &self.provider
    }

    fn is_ready(&self, _screen: &ScreenView) -> Result<bool, ReadinessError> {
        Err(ReadinessError::UnknownReadinessSignal {
            provider: self.provider.clone(),
        })
    }
}

// ---------------------------------------------------------------------------
// Per-CLI detectors (Wave 2). Codex = Open Question #2; Gemini = Open Question
// #3. Both follow the same discipline: never claim readiness from byte counts;
// reject known false-ready walls (auth / trust prompts) and mid-work states;
// claim ready only on a positive prompt-glyph signal.
//
// SMOKE-PINNING NOTE: the exact prompt glyphs each CLI draws when idle are
// best-known from the providers' documentation and the US4 captures, but have
// not been pinned against a live interactive TUI in this PR (running an
// interactive CLI headlessly risks an auth/trust hang). The detectors are
// CONSERVATIVE by construction: the only failure mode of a wrong glyph is a
// false-NOT-ready (the daemon waits/retries), never a false-ready (input sent
// into the wrong UI state) - which is exactly the bias Open Question #9
// mandates. `cli/scripts/smoke/capture-readiness-grid.sh` regenerates the grid
// fixtures against the live CLIs; tune `PROMPT_GLYPHS` from a capture.
// ---------------------------------------------------------------------------

/// Prompt indicators a modern CLI composer draws on its idle input line. A
/// match on the last non-blank line's trailing glyph is the positive readiness
/// signal. Centralized so a smoke capture tunes one place.
pub const PROMPT_GLYPHS: &[char] = &['\u{276f}', '\u{203a}', '\u{2595}']; // ❯ › ▌

/// Visible substrings that mean the CLI is mid-work and NOT accepting input,
/// even if a prompt glyph is also on screen.
const BUSY_MARKERS: &[&str] = &[
    "esc to interrupt",
    "Esc to interrupt",
    "Working",
    "Thinking",
];

/// Visible substrings for a blocking wall the operator must clear first (auth,
/// trust). The Gemini "Waiting for auth" false-ready (Open Question #3) lives
/// here.
const WALL_MARKERS: &[&str] = &[
    "Waiting for auth",
    "waiting for auth",
    "Do you trust",
    "Login required",
];

/// How many trailing non-blank lines form the "status region" the busy/wall
/// markers are matched against. These CLIs draw their composer + status bar
/// (the spinner, "esc to interrupt", auth wall) in the bottom few rows; model
/// reply text scrolls ABOVE it. Scoping the marker match to this region stops a
/// normal reply that happens to contain "Working"/"Thinking" from pinning the
/// detector permanently not-ready (Codex review P1).
const STATUS_REGION_LINES: usize = 3;

/// Shared readiness decision: not-ready under any wall or busy marker *in the
/// bottom status region*; otherwise ready only when the last non-blank line
/// ends with a recognized prompt glyph. Never guesses from byte counts (Open
/// Question #9).
fn prompt_ready(screen: &ScreenView, glyphs: &[char]) -> bool {
    let nonblank: Vec<&str> = screen
        .visible_text
        .lines()
        .filter(|l| !l.trim().is_empty())
        .collect();
    let Some(last) = nonblank.last() else {
        return false; // blank screen: nothing drawn yet, not ready
    };
    // Status region = the last few non-blank lines (where the composer/status
    // bar lives), NOT the whole scrollback.
    let region_start = nonblank.len().saturating_sub(STATUS_REGION_LINES);
    let region = nonblank[region_start..].join("\n");
    if WALL_MARKERS.iter().any(|m| region.contains(m)) {
        return false;
    }
    if BUSY_MARKERS.iter().any(|m| region.contains(m)) {
        return false;
    }
    let tail = last.trim_end();
    glyphs.iter().any(|g| tail.ends_with(*g))
}

/// Provider-agnostic readiness check for the grid attention scanner
/// (fu-grid-pagination / ab-82dddd5f). codex + gemini share [`PROMPT_GLYPHS`]
/// and the same [`prompt_ready`] logic, and the grid only ever hosts those
/// two (claude is excluded, grid Locked Decision 6), so a single shared check
/// is correct here. Run client-side on a pane's `Term` snapshot to flag an
/// off-screen agent waiting for input. Inherits the same wall / busy-marker
/// discipline as the per-CLI detectors: never a byte-count guess, and a
/// "Waiting for auth" wall reads as not-waiting. A brief false / late badge
/// is tolerable for an awareness hint, but a false-ready into a busy state is
/// not - hence the shared `prompt_ready` bias.
pub fn screen_is_waiting(screen: &ScreenView) -> bool {
    prompt_ready(screen, PROMPT_GLYPHS)
}

/// Codex interactive-composer readiness (Open Question #2).
pub struct CodexReadinessDetector;

impl ReadinessDetector for CodexReadinessDetector {
    fn provider_name(&self) -> &str {
        "codex"
    }

    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
        Ok(prompt_ready(screen, PROMPT_GLYPHS))
    }
}

/// Gemini interactive-composer readiness (Open Question #3). Shares the prompt
/// glyph set with codex; the load-bearing difference is rejecting the
/// "Waiting for auth" wall, handled by the shared `WALL_MARKERS`.
pub struct GeminiReadinessDetector;

impl ReadinessDetector for GeminiReadinessDetector {
    fn provider_name(&self) -> &str {
        "gemini"
    }

    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
        Ok(prompt_ready(screen, PROMPT_GLYPHS))
    }
}

/// Agy interactive-composer readiness (Phase C, agy harness). agy runs Gemini
/// models under the hood and draws the same prompt-glyph family as the other
/// CLIs, so the shared [`prompt_ready`] logic applies unchanged. The
/// conservative bias (a wrong glyph is false-NOT-ready, never false-ready) holds
/// here as for the others. Glyphs tune against a live agy capture (deferred).
pub struct AgyReadinessDetector;

impl ReadinessDetector for AgyReadinessDetector {
    fn provider_name(&self) -> &str {
        "agy"
    }

    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
        Ok(prompt_ready(screen, PROMPT_GLYPHS))
    }
}

/// Claude interactive-composer readiness (inside-out-multiplexer E1). Claude's
/// TUI composer draws the same prompt-glyph family as codex/gemini and its
/// mid-turn "esc to interrupt" status line is already in [`BUSY_MARKERS`], so
/// the shared [`prompt_ready`] logic applies unchanged. Threshold tuning against
/// a live capture is Claude's Discretion (the design's readiness-detector
/// bullet); the conservative bias (a wrong glyph is false-NOT-ready, never
/// false-ready) holds here as for the other CLIs.
pub struct ClaudeReadinessDetector;

impl ReadinessDetector for ClaudeReadinessDetector {
    fn provider_name(&self) -> &str {
        "claude"
    }

    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
        Ok(prompt_ready(screen, PROMPT_GLYPHS))
    }
}

/// opencode TUI readiness (x-51f6 US3). Markers pinned from a LIVE capture
/// (opencode 1.14.50, mux-pane hosted), not guessed:
///
/// - **idle**: the composer box draws a `┃` (U+2503) left edge; the footer
///   hint row reads "tab agents  ctrl+p commands". The LAST line is a status
///   bar (`~/path:branch … 1.14.50`), so the shared trailing-glyph
///   [`prompt_ready`] can never fire for opencode — a positive signal needs
///   the composer edge instead.
/// - **working**: the footer swaps to a progress fill (`⬝⬝⬝⬝■■■■`) plus an
///   "esc interrupt" hint. NOTE: live 1.14.50 says "esc interrupt" (no "to"),
///   which the shared `BUSY_MARKERS` miss — matched here on "interrupt".
/// - **blocked**: the "Permission required" dialog (same marker family as
///   `manifests/opencode.toml`) and the shared auth [`WALL_MARKERS`].
///
/// Conservative bias holds (readiness OQ#9): the composer edge is required
/// AND every busy/blocked marker vetoes, so a UI change degrades to
/// false-NOT-ready (footnote waits), never a false-ready dispatch.
pub struct OpencodeReadinessDetector;

/// Bottom non-blank lines forming opencode's status region: the composer box
/// (edge rows + model line), the hint row, and the status bar — deeper than
/// the shared 3-line region because opencode's composer is a multi-row box.
const OPENCODE_STATUS_REGION_LINES: usize = 8;

impl ReadinessDetector for OpencodeReadinessDetector {
    fn provider_name(&self) -> &str {
        "opencode"
    }

    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
        let nonblank: Vec<&str> = screen
            .visible_text
            .lines()
            .filter(|l| !l.trim().is_empty())
            .collect();
        if nonblank.is_empty() {
            return Ok(false); // blank screen: nothing drawn yet
        }
        let start = nonblank.len().saturating_sub(OPENCODE_STATUS_REGION_LINES);
        let region = nonblank[start..].join("\n");
        if WALL_MARKERS.iter().any(|m| region.contains(m)) {
            return Ok(false);
        }
        // "interrupt" covers "esc interrupt" (live 1.14.50) and the "esc to
        // interrupt"/"ctrl+c to interrupt" variants; the ⬝/■ runs are the
        // progress fill; "Permission required" is the blocked dialog.
        const OPENCODE_NOT_READY: &[&str] = &[
            "interrupt",
            "Permission required",
            "\u{25a0}\u{25a0}\u{25a0}\u{25a0}",
            "\u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}",
        ];
        if OPENCODE_NOT_READY.iter().any(|m| region.contains(m)) {
            return Ok(false);
        }
        // Positive signal: the composer's `┃` left edge in the status region.
        // A dialog overlay or half-drawn screen has no edge -> not ready.
        Ok(nonblank[start..]
            .iter()
            .any(|l| l.trim_start().starts_with('\u{2503}')))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A minimal grid-pattern detector to exercise the trait. Real Codex/Gemini
    /// detectors (Wave 2) match smoke-captured prompts; this stand-in matches a
    /// trailing prompt glyph on the cursor row and rejects a known false-ready
    /// auth banner, mirroring the discipline the real impls must follow.
    struct PromptGlyphDetector;
    impl ReadinessDetector for PromptGlyphDetector {
        fn provider_name(&self) -> &str {
            "test-cli"
        }
        fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
            if screen.visible_text.contains("Waiting for auth") {
                return Ok(false); // known false-ready (the Gemini trap)
            }
            Ok(screen.visible_text.trim_end().ends_with('\u{276f}')) // matches "❯"
        }
    }

    fn view(text: &str) -> ScreenView<'_> {
        ScreenView {
            visible_text: text,
            cursor_row: 0,
            cursor_col: 0,
            osc_title: None,
            osc_progress: None,
        }
    }

    #[test]
    fn ready_on_prompt_glyph() {
        let d = PromptGlyphDetector;
        assert_eq!(d.is_ready(&view("project \u{276f}")), Ok(true));
        assert_eq!(d.is_ready(&view("loading...")), Ok(false));
    }

    #[test]
    fn auth_wall_is_not_ready() {
        let d = PromptGlyphDetector;
        // Even with a trailing glyph, the auth banner must report not-ready.
        assert_eq!(d.is_ready(&view("Waiting for auth \u{276f}")), Ok(false));
    }

    // ---- opencode (x-51f6 US3): fixtures condensed from a live 1.14.50
    // mux-pane capture (capture method: `fno mux pane run -- opencode` +
    // `pane read`), per the markers-from-capture locked decision. ----

    fn opencode_screen(footer_hint: &str) -> String {
        format!(
            "   \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"
        )
    }

    #[test]
    fn opencode_idle_composer_is_ready() {
        let d = OpencodeReadinessDetector;
        let screen = opencode_screen("                 tab agents  ctrl+p commands");
        assert_eq!(d.is_ready(&view(&screen)), Ok(true));
        assert_eq!(d.provider_name(), "opencode");
    }

    #[test]
    fn opencode_progress_fill_or_interrupt_hint_is_busy() {
        let d = OpencodeReadinessDetector;
        // Live 1.14.50 working footer: fill run + "esc interrupt" (no "to").
        let working = opencode_screen(
            "   \u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}\u{25a0}\u{25a0}\u{25a0}\u{25a0}  esc interrupt      tab agents  ctrl+p commands",
        );
        assert_eq!(d.is_ready(&view(&working)), Ok(false));
        // The shared "esc to interrupt" spelling stays busy too.
        let alt = opencode_screen("   esc to interrupt");
        assert_eq!(d.is_ready(&view(&alt)), Ok(false));
    }

    #[test]
    fn opencode_permission_dialog_and_auth_wall_are_not_ready() {
        let d = OpencodeReadinessDetector;
        let perm = opencode_screen("   \u{25b3} Permission required");
        assert_eq!(d.is_ready(&view(&perm)), Ok(false));
        let wall = opencode_screen("   Login required");
        assert_eq!(d.is_ready(&view(&wall)), Ok(false));
    }

    #[test]
    fn opencode_stale_blocker_above_the_8_line_window_does_not_block() {
        // A "Permission required" / progress-fill marker that has scrolled
        // ABOVE the trailing OPENCODE_STATUS_REGION_LINES(8) window (into old
        // reply text) must not veto readiness - only the bottom composer/
        // status region gates it (mirrors busy_word_in_model_reply_above_
        // status_region_does_not_block for the shared 3-line window). This
        // fixture has 11 non-blank lines; the last 8 exclude both noise lines.
        let d = OpencodeReadinessDetector;
        let screen = "Permission required somewhere in old reply\n\
                      \u{25a0}\u{25a0}\u{25a0}\u{25a0} progress marker from old scrollback\n\
                      filler line one\n\
                      filler line two\n\
                      \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\
                                       tab agents  ctrl+p commands\n\
                      ~/proj:main  \u{2299} 6 MCP /status   1.14.50";
        assert_eq!(d.is_ready(&view(screen)), Ok(true));
    }

    #[test]
    fn opencode_no_composer_edge_is_not_ready() {
        let d = OpencodeReadinessDetector;
        // A half-drawn screen / full-screen dialog with no `┃` edge must read
        // not-ready (conservative bias), and a blank screen too.
        assert_eq!(d.is_ready(&view("loading opencode...")), Ok(false));
        assert_eq!(d.is_ready(&view("")), Ok(false));
    }

    #[test]
    fn no_signal_detector_errors_never_guesses() {
        // "aider" is the canonical genuinely-unhosted CLI (opencode graduated
        // to a real detector at x-51f6).
        let d = NoSignalDetector {
            provider: "aider".to_string(),
        };
        // The detector reports the real provider name (not the literal
        // "unknown") in both provider_name() and the error (cv-789fdba0).
        assert_eq!(d.provider_name(), "aider");
        assert_eq!(
            d.is_ready(&view("anything at all, 9999 bytes of banner")),
            Err(ReadinessError::UnknownReadinessSignal {
                provider: "aider".into()
            })
        );
    }

    #[test]
    fn codex_detector_ready_on_idle_prompt() {
        let d = CodexReadinessDetector;
        assert_eq!(d.provider_name(), "codex");
        // Idle composer: last non-blank line ends with a prompt glyph.
        assert_eq!(
            d.is_ready(&view(
                "codex 0.130\n\n  build feature X\n\u{276f} ".trim_end()
            )),
            Ok(true)
        );
    }

    #[test]
    fn codex_detector_not_ready_while_working() {
        let d = CodexReadinessDetector;
        // Busy marker overrides any prompt glyph also on screen.
        assert_eq!(
            d.is_ready(&view("running tool...\nEsc to interrupt\n\u{276f}")),
            Ok(false)
        );
    }

    #[test]
    fn codex_detector_not_ready_without_prompt_signal() {
        let d = CodexReadinessDetector;
        // No prompt glyph anywhere: refuse to claim ready (no byte-count guess).
        assert_eq!(
            d.is_ready(&view("loading a 5000 byte banner of text")),
            Ok(false)
        );
    }

    #[test]
    fn gemini_detector_rejects_waiting_for_auth_false_ready() {
        let d = GeminiReadinessDetector;
        assert_eq!(d.provider_name(), "gemini");
        // The documented Gemini trap: prompt glyph present but auth wall up.
        assert_eq!(
            d.is_ready(&view("Waiting for auth...\n\u{276f}")),
            Ok(false)
        );
    }

    #[test]
    fn gemini_detector_ready_on_idle_prompt() {
        let d = GeminiReadinessDetector;
        assert_eq!(
            d.is_ready(&view("Gemini ready\n\u{203a} ".trim_end())),
            Ok(true)
        );
    }

    #[test]
    fn busy_word_in_model_reply_above_status_region_does_not_block() {
        // A normal reply mentioning "Working" / "Thinking" scrolls ABOVE the
        // composer; only the bottom status region gates readiness (Codex P1).
        let d = CodexReadinessDetector;
        let screen = "I am Working on the Thinking task you asked about.\n\
                      Here is a long reply that mentions Working again.\n\
                      filler line\n\
                      another filler\n\
                      \u{276f} ";
        assert_eq!(d.is_ready(&view(screen.trim_end())), Ok(true));
    }
}