From 8eefd6f928fa2a2a63091a9f4b4ca77bd1678097 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 22 May 2026 00:46:28 +0200 Subject: [PATCH] fix: waits for startup by markers instead of timer --- src/claude_code_api/pty.py | 42 +++++++++++++++++++++++++++++++ src/claude_code_api/turn.py | 49 +++++++++++++++++++++++++------------ 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/claude_code_api/pty.py b/src/claude_code_api/pty.py index 23775a0..fcd820f 100644 --- a/src/claude_code_api/pty.py +++ b/src/claude_code_api/pty.py @@ -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. diff --git a/src/claude_code_api/turn.py b/src/claude_code_api/turn.py index c90013a..a737c6f 100644 --- a/src/claude_code_api/turn.py +++ b/src/claude_code_api/turn.py @@ -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