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.
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 0disables). The clever bit isescapeSeqEndininternal/session/session.go— the pacer is escape-aware, so VT control sequences (CSIESC [ ... cmd, SS3ESC O cmd, OSCESC ] ... 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
escapeSeqEndto Rust and addwrite_pacednext to the currentwrite_allpath. Replace the bulk-write-then-sleep injection in pty_worker with paced writes. Keep a knob (paced_injectorinject_rate_ms) so we can toggle it off if we discover a workload that needs raw speed.next_atom_endreturns 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— addwrite_paced+next_atom_endhelpersrc/pty_worker.rs:788–798— replacewrite_all+sleep(50ms)+\rwithwrite_paced(payload + b"\r", 20ms)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.