fix: waits for startup by markers instead of timer

This commit is contained in:
h
2026-05-22 00:46:28 +02:00
parent 9a18091cbb
commit 8eefd6f928
2 changed files with 76 additions and 15 deletions
+42
View File
@@ -313,6 +313,48 @@ class PtyClaudeProcess:
return False
await asyncio.sleep(poll)
async def wait_for_quiet(
self,
*,
idle: float,
timeout: float,
poll: float = 0.05,
) -> bool:
"""Wait until PTY output stalls for `idle` consecutive seconds.
Polls ``len(captured_output())`` and resets a stopwatch each time
the buffer grows; returns True once the stopwatch reaches `idle`,
False on `timeout`. Intended as a "TUI fully rendered" signal:
the bracketed-paste-enable byte (`\\x1b[?2004h`) fires when the
terminal mode is set up, but claude's TUI keeps rendering status
bar / input box for another few seconds afterwards. Waiting for
a quiet period after the marker is a cheap, version-agnostic way
to know the UI has actually settled.
"""
if idle < 0:
msg = f"idle must be non-negative, got {idle!r}"
raise ValueError(msg)
if timeout < 0:
msg = f"timeout must be non-negative, got {timeout!r}"
raise ValueError(msg)
if poll <= 0:
msg = f"poll must be positive, got {poll!r}"
raise ValueError(msg)
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
last_len = len(self.captured_output())
last_change = loop.time()
while True:
if loop.time() - last_change >= idle:
return True
if loop.time() >= deadline:
return False
await asyncio.sleep(poll)
cur_len = len(self.captured_output())
if cur_len != last_len:
last_len = cur_len
last_change = loop.time()
async def write(self, data: str | bytes, *, newline: bool = True) -> int:
r"""Write bytes to the child's stdin.
+34 -15
View File
@@ -51,13 +51,15 @@ _DEFAULT_FILE_WAIT_TIMEOUT = 30.0
_DEFAULT_TURN_DURATION_TIMEOUT = 5.0
_DEFAULT_STARTUP_DELAY = 10.0
# DECSET 2004 — claude's TUI enables bracketed paste once it is ready to
# accept pasted input. Writing the user prompt before this byte sequence
# arrives causes the paste-mode markers to be parsed as raw escape codes
# and the message is silently dropped. We poll for it in `start()` instead
# of a fixed sleep so a slow box (e.g. a Raspberry Pi where claude's
# initial render takes ~2-3s) doesn't lose the first turn.
# DECSET 2004 — claude's TUI enables bracketed paste mode early in init.
# This is necessary (without it our paste markers parse as raw ANSI and
# the message is dropped) but NOT sufficient: the marker arrives ~2s on
# a slow Pi, but the input box / status bar continue rendering for
# another few seconds. Writing during that window also loses the paste.
# So we wait for the marker AND then for a quiet period — `_TUI_QUIET_PERIOD`
# seconds with no new PTY bytes — to know the TUI has truly settled.
_TUI_READY_MARKER: bytes = b"\x1b[?2004h"
_TUI_QUIET_PERIOD: float = 1.5
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
@@ -135,20 +137,37 @@ class TurnManager:
async def start(self) -> None:
"""Spawn the PTY and let claude's TUI settle. Idempotent.
`startup_delay` is now a cap, not a fixed sleep: we wait for
claude's bracketed-paste-enable byte (DECSET 2004) to appear in
PTY output and return as soon as it does — typically faster than
`startup_delay`, but on a slow host where the marker takes longer
we still bound the wait. Fake PTYs in tests may not expose
`wait_for_output`; we fall back to a plain sleep then.
`startup_delay` is the **total** cap on waiting for the TUI to be
ready, in two phases bounded by it:
1. Wait for `_TUI_READY_MARKER` (DECSET 2004 / bracketed-paste-on)
— confirms claude has set up its terminal modes.
2. Wait for `_TUI_QUIET_PERIOD` consecutive seconds of no new PTY
bytes — confirms the initial render burst (status bar / input
box) has finished. Writing earlier loses the paste even though
bracketed-paste mode is on.
Fake PTYs in tests may not expose these helpers; we fall back to
a plain sleep then.
"""
if self._started:
return
await self._pty.start()
if self._startup_delay > 0:
wait = getattr(self._pty, "wait_for_output", None)
if wait is not None:
await wait(_TUI_READY_MARKER, timeout=self._startup_delay)
loop = asyncio.get_running_loop()
deadline = loop.time() + self._startup_delay
wait_marker = getattr(self._pty, "wait_for_output", None)
wait_quiet = getattr(self._pty, "wait_for_quiet", None)
if wait_marker is not None:
# Phase 1: bracketed-paste-enable byte tells us terminal
# mode is set up. Necessary but not sufficient.
await wait_marker(_TUI_READY_MARKER, timeout=self._startup_delay)
# Phase 2: wait for the TUI render burst to settle —
# consecutive seconds of no new PTY bytes. Bounded by what's
# left of `startup_delay`.
remaining = max(0.0, deadline - loop.time())
if wait_quiet is not None and remaining > 0:
await wait_quiet(idle=_TUI_QUIET_PERIOD, timeout=remaining)
else:
await asyncio.sleep(self._startup_delay)
self._started = True