Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from openhands.sdk.llm import LLM, Message, TextContent
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
from openhands.sdk.llm.llm_registry import LLMRegistry
from openhands.sdk.logger import get_logger
from openhands.sdk.logger import flush_stdin, get_logger
from openhands.sdk.observability.laminar import observe
from openhands.sdk.plugin import (
Plugin,
Expand Down Expand Up @@ -658,6 +658,12 @@ def run(self) -> None:
)
iteration += 1

# Flush any pending terminal query responses that may have
# accumulated during the step (Rich display, tool execution, etc.)
# This prevents ANSI escape codes from leaking to stdin.
# See: https://github.com/OpenHands/software-agent-sdk/issues/2244
flush_stdin()

# Check for non-finished terminal conditions
# Note: We intentionally do NOT check for FINISHED status here.
# This allows concurrent user messages to be processed:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)
from openhands.sdk.event.base import Event
from openhands.sdk.event.condenser import Condensation, CondensationRequest
from openhands.sdk.logger import flush_stdin


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -248,6 +249,12 @@ def __init__(

def on_event(self, event: Event) -> None:
"""Main event handler that displays events with Rich formatting."""
# Flush any pending terminal query responses before rendering.
# This prevents ANSI escape codes from accumulating in stdin
# and corrupting subsequent input() calls.
# See: https://github.com/OpenHands/software-agent-sdk/issues/2244
flush_stdin()

output = self._create_event_block(event)
if output:
self._console.print(output)
Expand Down
6 changes: 6 additions & 0 deletions openhands-sdk/openhands/sdk/logger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
ENV_LOG_DIR,
ENV_LOG_LEVEL,
IN_CI,
clear_buffered_input,
flush_stdin,
get_buffered_input,
get_logger,
setup_logging,
)
Expand All @@ -13,6 +16,9 @@
__all__ = [
"get_logger",
"setup_logging",
"flush_stdin",
"get_buffered_input",
"clear_buffered_input",
"DEBUG",
"ENV_JSON",
"ENV_LOG_LEVEL",
Expand Down
257 changes: 257 additions & 0 deletions openhands-sdk/openhands/sdk/logger/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
logger.info("Hello from this module!")
"""

import atexit
import logging
import os
import select
from logging.handlers import TimedRotatingFileHandler

import litellm
Expand Down Expand Up @@ -193,3 +195,258 @@ def get_logger(name: str) -> logging.Logger:
# Auto-configure if desired
if ENV_AUTO_CONFIG:
setup_logging()


# ========= TERMINAL CLEANUP =========
# Prevents ANSI escape code leaks during operation and at exit.
# See: https://github.com/OpenHands/software-agent-sdk/issues/2244
#
# The issue: Terminal queries (like DSR for cursor position) get responses
# written to stdin. If not consumed, these leak as garbage to the shell or
# corrupt the next input() call in CLI applications.
#
# This implementation uses SELECTIVE FLUSHING:
# - Parse pending stdin data byte-by-byte
# - Discard only recognized escape sequences (CSI, OSC)
# - Preserve all other data (likely user typeahead) in a buffer
# - Provide get_buffered_input() for SDK to retrieve preserved data

_cleanup_registered = False
_preserved_input_buffer: bytes = b""


def _is_csi_final_byte(byte: int) -> bool:
"""Check if byte is a CSI sequence final character (0x40-0x7E).

Per ECMA-48, CSI sequences end with a byte in the range 0x40-0x7E.
Common finals: 'R' (0x52) for cursor position, 'n' for device status.
"""
return 0x40 <= byte <= 0x7E


def _find_csi_end(data: bytes, start: int) -> int:
"""Find end position of a CSI sequence.

CSI format: ESC [ [params] [intermediates] final
- Params: 0x30-0x3F (digits, semicolons, etc.)
- Intermediates: 0x20-0x2F (space, !"#$%&'()*+,-./)
- Final: 0x40-0x7E (@A-Z[\\]^_`a-z{|}~)

Args:
data: The byte buffer to parse.
start: Position of the ESC byte (start of \\x1b[ sequence).

Returns:
Position AFTER the sequence ends. If incomplete, returns start
(preserving the incomplete sequence as potential user input).
"""
pos = start + 2 # Skip \x1b[
while pos < len(data):
byte = data[pos]
if _is_csi_final_byte(byte):
return pos + 1 # Include the final byte
if byte < 0x20 or byte > 0x3F and byte < 0x40:
# Invalid byte in CSI sequence - treat as end
return pos
pos += 1
# Incomplete sequence at end of buffer - preserve it (might be user input)
return start


def _find_osc_end(data: bytes, start: int) -> int:
"""Find end position of an OSC sequence.

OSC format: ESC ] ... (BEL or ST)
- BEL terminator: 0x07
- ST terminator: ESC \\ (0x1b 0x5c)

Args:
data: The byte buffer to parse.
start: Position of the ESC byte (start of \\x1b] sequence).

Returns:
Position AFTER the sequence ends. If incomplete, returns start
(preserving the incomplete sequence as potential user input).
"""
pos = start + 2 # Skip \x1b]
while pos < len(data):
if data[pos] == 0x07: # BEL terminator
return pos + 1
if data[pos] == 0x1B and pos + 1 < len(data) and data[pos + 1] == 0x5C:
return pos + 2 # ST terminator \x1b\\
pos += 1
# Incomplete sequence - preserve it
return start


def _parse_stdin_data(data: bytes) -> tuple[bytes, int]:
"""Parse stdin data, separating escape sequences from user input.

This function implements selective flushing: it identifies and discards
terminal escape sequence responses (CSI and OSC) while preserving any
other data that may be legitimate user typeahead.

Args:
data: Raw bytes read from stdin.

Returns:
Tuple of (preserved_user_input, flushed_escape_sequence_bytes).
"""
preserved = b""
flushed = 0
i = 0

while i < len(data):
# Check for CSI sequence: \x1b[
if i + 1 < len(data) and data[i] == 0x1B and data[i + 1] == 0x5B:
end = _find_csi_end(data, i)
if end > i: # Complete sequence found
flushed += end - i
i = end
else: # Incomplete - preserve as potential user input
preserved += data[i : i + 1]
i += 1

# Check for OSC sequence: \x1b]
elif i + 1 < len(data) and data[i] == 0x1B and data[i + 1] == 0x5D:
end = _find_osc_end(data, i)
if end > i: # Complete sequence found
flushed += end - i
i = end
else: # Incomplete - preserve
preserved += data[i : i + 1]
i += 1

# Single ESC followed by another character - could be escape sequence
# Be conservative: if next byte looks like start of known sequence type,
# preserve both bytes for next iteration or as user input
elif data[i] == 0x1B and i + 1 < len(data):
next_byte = data[i + 1]
# Known sequence starters we don't fully parse: SS2, SS3, DCS, PM, APC
# SS2=N, SS3=O, DCS=P, PM=^, APC=_
if next_byte in (0x4E, 0x4F, 0x50, 0x5E, 0x5F):
# These are less common; preserve as user input
preserved += data[i : i + 1]
i += 1
else:
# Unknown escape sequence type - preserve it
preserved += data[i : i + 1]
i += 1

# Regular byte - preserve it (likely user input)
else:
preserved += data[i : i + 1]
i += 1

return preserved, flushed


def flush_stdin() -> int:
"""Flush terminal escape sequences from stdin, preserving user input.

On macOS (and some Linux terminals), terminal query responses can leak
to stdin. If not consumed before exit or between conversation turns,
they corrupt input or appear as garbage in the shell.

This function uses SELECTIVE FLUSHING:
- Only discards recognized escape sequences (CSI `\\x1b[...`, OSC `\\x1b]...`)
- Preserves all other data in an internal buffer
- Use get_buffered_input() to retrieve preserved user input

This function is called automatically:
1. At exit (registered via atexit)
2. After each agent step in LocalConversation.run()
3. Before rendering events in DefaultConversationVisualizer

It can also be called manually if needed.

Returns:
Number of escape sequence bytes flushed from stdin.
""" # noqa: E501
global _preserved_input_buffer
import sys as _sys # Import locally to avoid issues at atexit time

if not _sys.stdin.isatty():
return 0

try:
import termios
except ImportError:
return 0 # Windows

flushed = 0
old = None
try:
old = termios.tcgetattr(_sys.stdin)
# Deep copy required: old[6] is a list (cc), and list(old) only
# does a shallow copy. Without deep copy, modifying new[6][VMIN]
# would also modify old[6][VMIN], corrupting the restore.
new = [item[:] if isinstance(item, list) else item for item in old]
# termios attrs: [iflag, oflag, cflag, lflag, ispeed, ospeed, cc]
# Index 3 is lflag (int), index 6 is cc (list)
lflag = new[3]
assert isinstance(lflag, int) # Help type checker
new[3] = lflag & ~(termios.ICANON | termios.ECHO)
new[6][termios.VMIN] = 0
new[6][termios.VTIME] = 0
termios.tcsetattr(_sys.stdin, termios.TCSANOW, new)

while select.select([_sys.stdin], [], [], 0)[0]:
data = os.read(_sys.stdin.fileno(), 4096)
if not data:
break
# Parse data: discard escape sequences, preserve user input
preserved, seq_flushed = _parse_stdin_data(data)
flushed += seq_flushed
_preserved_input_buffer += preserved

except (OSError, termios.error):
pass
finally:
if old is not None:
try:
termios.tcsetattr(_sys.stdin, termios.TCSANOW, old)
except (OSError, termios.error):
pass
return flushed


def get_buffered_input() -> bytes:
"""Get any user input that was preserved during flush_stdin calls.

When flush_stdin() discards escape sequences, it preserves any other
data that might be legitimate user typeahead. This function retrieves
and clears that buffered input.

SDK components that read user input should call this function to
prepend any buffered data to their input.

Returns:
Bytes that were preserved from stdin during flush operations.
The internal buffer is cleared after this call.

Example:
>>> # In code that reads user input:
>>> buffered = get_buffered_input()
>>> user_input = buffered.decode('utf-8', errors='replace') + input()
"""
global _preserved_input_buffer
data = _preserved_input_buffer
_preserved_input_buffer = b""
return data


def clear_buffered_input() -> None:
"""Clear any buffered input without returning it.

Use this when you want to discard any preserved input, for example
at the start of a new conversation or after a timeout.
"""
global _preserved_input_buffer
_preserved_input_buffer = b""


# Register cleanup at module load time
if not _cleanup_registered:
atexit.register(flush_stdin)
_cleanup_registered = True
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
escape_bash_special_chars,
split_bash_commands,
)
from openhands.tools.terminal.utils.escape_filter import filter_terminal_queries


logger = get_logger(__name__)
Expand Down Expand Up @@ -120,7 +121,12 @@ def _get_command_output(
metadata: CmdOutputMetadata,
continue_prefix: str = "",
) -> str:
"""Get the command output with the previous command output removed."""
"""Get the command output with the previous command output removed.

Also filters terminal query sequences that could cause visible escape
code garbage when the output is displayed.
See: https://github.com/OpenHands/software-agent-sdk/issues/2244
"""
# remove the previous command output from the new output if any
if self.prev_output:
command_output = raw_command_output.removeprefix(self.prev_output)
Expand All @@ -129,6 +135,11 @@ def _get_command_output(
command_output = raw_command_output
self.prev_output = raw_command_output # update current command output anyway
command_output = _remove_command_prefix(command_output, command)

# Filter terminal query sequences that would cause the terminal to
# respond when displayed, producing visible garbage
command_output = filter_terminal_queries(command_output)

return command_output.rstrip()

def _handle_completed_command(
Expand Down
14 changes: 14 additions & 0 deletions openhands-tools/openhands/tools/terminal/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Terminal tool utilities."""

from openhands.tools.terminal.utils.command import (
escape_bash_special_chars,
split_bash_commands,
)
from openhands.tools.terminal.utils.escape_filter import filter_terminal_queries


__all__ = [
"escape_bash_special_chars",
"split_bash_commands",
"filter_terminal_queries",
]
Loading
Loading