Skip to content

broker: escape-aware paced injection (steal from ht) #801

@willwashburn

Description

@willwashburn

Problem

Today, the broker injects messages into a CLI by writing the entire payload in one write_all, sleeping 50ms, then writing \r (src/pty_worker.rs:788–798). That works most of the time, but we have a recurring class of bug where Claude appears to batch or eat characters during injection — likely a consequence of writing too fast for the child to process between read calls.

Symptoms include: occasional dropped characters at the start of an injection, chat input that arrives as a single garbled blob, and inconsistencies under heavy concurrent load.

Prior art

montanaflynn/headless-terminal defaults to pacing: writing one keystroke at a time with a 20ms gap (configurable --rate, --rate 0 disables). The clever bit is escapeSeqEnd in internal/session/session.go — the pacer is escape-aware, so VT control sequences (CSI ESC [ ... cmd, SS3 ESC O cmd, OSC ESC ] ... ST) and multi-byte UTF-8 codepoints are kept atomic instead of being split mid-sequence. Without that, you'd corrupt arrow keys, modified function keys, etc.

Proposal

Port escapeSeqEnd to Rust and add write_paced next to the current write_all path. Replace the bulk-write-then-sleep injection in pty_worker with paced writes. Keep a knob (paced_inject or inject_rate_ms) so we can toggle it off if we discover a workload that needs raw speed.

// src/pty.rs (sketch)
pub async fn write_paced(&self, bytes: &[u8], rate: Duration) -> io::Result<()> {
    let mut i = 0;
    while i < bytes.len() {
        let end = next_atom_end(&bytes[i..]);  // ports escapeSeqEnd
        self.write_all(&bytes[i..i + end]).await?;
        i += end;
        if i < bytes.len() { tokio::time::sleep(rate).await; }
    }
    Ok(())
}

next_atom_end returns the length of the next "atom" — one ASCII char, one CSI/SS3/OSC sequence, or one UTF-8 codepoint.

Files to touch

  • src/pty.rs — add write_paced + next_atom_end helper
  • src/pty_worker.rs:788–798 — replace write_all + sleep(50ms) + \r with write_paced(payload + b"\r", 20ms)
  • Tests: feed a payload containing CSI/SS3/OSC + multi-byte UTF-8 and assert the chunks emitted are atomic

Effort

Small. ~50 lines of logic + tests. Pure Rust, no new deps.

Why now

Likely fixes the "Claude occasionally batches/eats characters" class of bug we have today. Also makes our injection observably closer to a real human typing, which keeps Claude (and any other prompt-detection in CLIs) on its happy path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions