Skip to content

broker: composable wait-conditions for CLI readiness (steal from ht) #800

Description

@willwashburn

Problem

Today, every CLI we drive in the broker (Claude, Codex, Gemini, …) needs a bespoke "is it ready for input?" detector in src/helpers.rs::detect_cli_ready — Claude wants "Welcome back" + a bare line, Gemini wants "Type your message or @path/to/file", codex wants >, etc. Idle detection is a separate, coarse "no output for N seconds" timer (reset_idle_on_output in src/pty_worker.rs).

Two problems:

  1. Adding a new CLI = adding new regex/substring rules in helpers.rs. There's no shared vocabulary for "ready."
  2. Real readiness is usually a conjunction of conditions ("the welcome banner has been drawn AND output has been quiet for 200ms"), but our code can only express one at a time.

Prior art

montanaflynn/headless-terminal (internal/wait/wait.go, ~270 LOC) exposes a small composable taxonomy of wait conditions:

  • --wait-text REGEX — wait until the screen contains a match
  • --wait-cursor R,C — wait until the cursor lands at a row/col
  • --wait-idle DUR — output has been quiet for DUR
  • --wait-change — any output change since send
  • --wait-exit — process has exited

All AND-composed: a single start-time predicate plus a reset-on-chunk idle timer, racing on Done(). This is the part agents (and we) get wrong; ht has thought through it more carefully than we have.

Proposal

Port the wait taxonomy to Rust and replace the per-CLI ready hacks. Sketch:

// new module, e.g. src/wait.rs
pub enum WaitCondition {
    Text(Regex),
    Cursor { row: u16, col: u16 },   // requires #3 (VT grid) for cursor
    Idle(Duration),
    Change,
    Exit,
}

pub struct WaitSet(Vec<WaitCondition>);  // AND-composed

Then "Claude is ready" becomes WaitSet::new().text("Welcome back").idle(Duration::from_millis(200)) instead of bespoke detection in helpers.rs.

Files to touch

  • New: src/wait.rs
  • Replace: src/helpers.rs::detect_cli_ready (currently per-CLI string matching)
  • Update: src/pty_worker.rs injection path to use the new primitive instead of the existing idle-only model
  • The Cursor variant depends on having a real VT grid — see follow-up issue for that. Ship Text/Idle/Change/Exit first; Cursor lands once vt100 is wired up.

Effort

Medium. Pure logic port, no new deps. Tests can run against recorded byte streams from real Claude/Codex sessions.

Why now

Removes per-CLI fragility, gives us a single primitive for "wait for X" that integration tests, the steer mode, and SDK-exposed waits can all share. Also a prerequisite for cleaning up the inject + readiness loop in pty_worker.rs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions