mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-29 02:55:49 +00:00
732 lines
27 KiB
Python
732 lines
27 KiB
Python
"""Bash session management using tmux for persistent shell environments.
|
||
|
||
This module provides the BashSession class which creates and manages persistent
|
||
shell sessions using tmux, inspired by OpenHands' BashSession implementation.
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import time
|
||
import uuid
|
||
from typing import Any, final
|
||
|
||
import bashlex # type: ignore
|
||
import libtmux
|
||
|
||
from hanzo_mcp.tools.shell.base import (
|
||
BashCommandStatus,
|
||
CommandResult,
|
||
)
|
||
|
||
|
||
def split_bash_commands(commands: str) -> list[str]:
|
||
"""Split bash commands using bashlex parser.
|
||
|
||
Args:
|
||
commands: The command string to split
|
||
|
||
Returns:
|
||
List of individual commands
|
||
"""
|
||
if not commands.strip():
|
||
return [""]
|
||
try:
|
||
parsed = bashlex.parse(commands)
|
||
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
|
||
# If parsing fails, return the original commands
|
||
return [commands]
|
||
|
||
result: list[str] = []
|
||
last_end = 0
|
||
|
||
for node in parsed:
|
||
start, end = node.pos
|
||
|
||
# Include any text between the last command and this one
|
||
if start > last_end:
|
||
between = commands[last_end:start]
|
||
if result:
|
||
result[-1] += between.rstrip()
|
||
elif between.strip():
|
||
result.append(between.rstrip())
|
||
|
||
# Extract the command, preserving original formatting
|
||
command = commands[start:end].rstrip()
|
||
result.append(command)
|
||
|
||
last_end = end
|
||
|
||
# Add any remaining text after the last command to the last command
|
||
remaining = commands[last_end:].rstrip()
|
||
if last_end < len(commands) and result:
|
||
result[-1] += remaining
|
||
elif last_end < len(commands):
|
||
if remaining:
|
||
result.append(remaining)
|
||
return result
|
||
|
||
|
||
def escape_bash_special_chars(command: str) -> str:
|
||
"""Escape characters that have different interpretations in bash vs python.
|
||
|
||
Args:
|
||
command: The command to escape
|
||
|
||
Returns:
|
||
Escaped command string
|
||
"""
|
||
if command.strip() == "":
|
||
return ""
|
||
|
||
try:
|
||
parts = []
|
||
last_pos = 0
|
||
|
||
def visit_node(node: Any) -> None:
|
||
nonlocal last_pos
|
||
if (
|
||
node.kind == "redirect"
|
||
and hasattr(node, "heredoc")
|
||
and node.heredoc is not None
|
||
):
|
||
# We're entering a heredoc - preserve everything as-is until we see EOF
|
||
between = command[last_pos : node.pos[0]]
|
||
parts.append(between)
|
||
# Add the heredoc start marker
|
||
parts.append(command[node.pos[0] : node.heredoc.pos[0]])
|
||
# Add the heredoc content as-is
|
||
parts.append(command[node.heredoc.pos[0] : node.heredoc.pos[1]])
|
||
last_pos = node.pos[1]
|
||
return
|
||
|
||
if node.kind == "word":
|
||
# Get the raw text between the last position and current word
|
||
between = command[last_pos : node.pos[0]]
|
||
word_text = command[node.pos[0] : node.pos[1]]
|
||
|
||
# Add the between text, escaping special characters
|
||
between = re.sub(r"\\([;&|><])", r"\\\\\1", between)
|
||
parts.append(between)
|
||
|
||
# Check if word_text is a quoted string or command substitution
|
||
if (
|
||
(word_text.startswith('"') and word_text.endswith('"'))
|
||
or (word_text.startswith("'") and word_text.endswith("'"))
|
||
or (word_text.startswith("$(") and word_text.endswith(")"))
|
||
or (word_text.startswith("`") and word_text.endswith("`"))
|
||
):
|
||
# Preserve quoted strings, command substitutions, and heredoc content as-is
|
||
parts.append(word_text)
|
||
else:
|
||
# Escape special chars in unquoted text
|
||
word_text = re.sub(r"\\([;&|><])", r"\\\\\1", word_text)
|
||
parts.append(word_text)
|
||
|
||
last_pos = node.pos[1]
|
||
return
|
||
|
||
# Visit child nodes
|
||
if hasattr(node, "parts"):
|
||
for part in node.parts:
|
||
visit_node(part)
|
||
|
||
# Process all nodes in the AST
|
||
nodes = list(bashlex.parse(command))
|
||
for node in nodes:
|
||
between = command[last_pos : node.pos[0]]
|
||
between = re.sub(r"\\([;&|><])", r"\\\\\1", between)
|
||
parts.append(between)
|
||
last_pos = node.pos[0]
|
||
visit_node(node)
|
||
|
||
# Handle any remaining text after the last word
|
||
remaining = command[last_pos:]
|
||
parts.append(remaining)
|
||
return "".join(parts)
|
||
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
|
||
return command
|
||
|
||
|
||
def _remove_command_prefix(command_output: str, command: str) -> str:
|
||
"""Remove the command prefix from output."""
|
||
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
||
|
||
|
||
@final
|
||
class BashSession:
|
||
"""Persistent bash session using tmux.
|
||
|
||
This class provides a persistent shell environment where commands maintain
|
||
shared history, environment variables, and working directory state.
|
||
"""
|
||
|
||
HISTORY_LIMIT = 10_000
|
||
# Use simple PS1 for now to avoid shell compatibility issues
|
||
PS1 = "$ " # Simple PS1 for better compatibility
|
||
|
||
def __init__(
|
||
self,
|
||
id: str,
|
||
work_dir: str,
|
||
username: str | None = None,
|
||
no_change_timeout_seconds: int = 30,
|
||
max_memory_mb: int | None = None,
|
||
poll_interval: float = 0.5,
|
||
):
|
||
"""Initialize a bash session.
|
||
|
||
Args:
|
||
work_dir: Working directory for the session
|
||
username: Username to run commands as
|
||
no_change_timeout_seconds: Timeout for commands with no output changes
|
||
max_memory_mb: Memory limit (not implemented yet)
|
||
poll_interval: Interval between polls in seconds (default 0.5, use 0.1 for tests)
|
||
"""
|
||
self.POLL_INTERVAL = poll_interval
|
||
self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
|
||
self.id = id
|
||
self.work_dir = work_dir
|
||
self.username = username
|
||
self._initialized = False
|
||
self.max_memory_mb = max_memory_mb
|
||
|
||
# Session state
|
||
self.prev_status: BashCommandStatus | None = None
|
||
self.prev_output: str = ""
|
||
self._closed: bool = False
|
||
self._cwd = os.path.abspath(work_dir)
|
||
|
||
# tmux components
|
||
self.server: libtmux.Server | None = None
|
||
self.session: libtmux.Session | None = None
|
||
self.window: libtmux.Window | None = None
|
||
self.pane: libtmux.Pane | None = None
|
||
|
||
def initialize(self) -> None:
|
||
"""Initialize the tmux session."""
|
||
if self._initialized:
|
||
return
|
||
|
||
self.server = libtmux.Server()
|
||
# Use the user's current shell, fallback to /bin/bash
|
||
user_shell = os.environ.get("SHELL", "/bin/bash")
|
||
_shell_command = user_shell
|
||
|
||
if self.username in ["root"]:
|
||
# This starts a non-login (new) shell for the given user
|
||
_shell_command = f"su {self.username} -"
|
||
|
||
window_command = _shell_command
|
||
session_name = f"hanzo-mcp-{self.username or 'default'}-{uuid.uuid4()}"
|
||
|
||
self.session = self.server.new_session(
|
||
session_name=session_name,
|
||
start_directory=self.work_dir,
|
||
kill_session=True,
|
||
x=1000,
|
||
y=1000,
|
||
)
|
||
|
||
# Set history limit to a large number to avoid losing history
|
||
self.session.set_option("history-limit", str(self.HISTORY_LIMIT), global_=True)
|
||
self.session.history_limit = str(self.HISTORY_LIMIT)
|
||
|
||
# We need to create a new pane because the initial pane's history limit is (default) 2000
|
||
_initial_window = self.session.active_window
|
||
self.window = self.session.new_window(
|
||
window_name="bash",
|
||
window_shell=window_command,
|
||
start_directory=self.work_dir,
|
||
)
|
||
self.pane = self.window.active_pane
|
||
_initial_window.kill_window()
|
||
|
||
assert self.pane
|
||
|
||
# Configure bash to use simple PS1 and disable PS2
|
||
# Use a simpler PS1 that works reliably across different shells
|
||
self.pane.send_keys('export PS1="$ "')
|
||
# Set PS2 to empty
|
||
self.pane.send_keys('export PS2=""')
|
||
# For zsh, also set PROMPT and disable themes
|
||
self.pane.send_keys('export PROMPT="$ "')
|
||
self.pane.send_keys("unset ZSH_THEME")
|
||
self._clear_screen()
|
||
|
||
self._initialized = True
|
||
|
||
def __del__(self) -> None:
|
||
"""Ensure the session is closed when the object is destroyed."""
|
||
self.close()
|
||
|
||
def _get_pane_content(self) -> str:
|
||
"""Capture the current pane content."""
|
||
if not self.pane:
|
||
return ""
|
||
|
||
content = "\n".join(
|
||
map(
|
||
lambda line: line.rstrip(),
|
||
self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout,
|
||
)
|
||
)
|
||
return content
|
||
|
||
def close(self) -> None:
|
||
"""Clean up the session."""
|
||
if self._closed or not self.session:
|
||
return
|
||
try:
|
||
self.session.kill_session()
|
||
except Exception:
|
||
pass # Ignore cleanup errors
|
||
self._closed = True
|
||
|
||
@property
|
||
def cwd(self) -> str:
|
||
"""Get current working directory."""
|
||
return self._cwd
|
||
|
||
def _is_special_key(self, command: str) -> bool:
|
||
"""Check if the command is a special key."""
|
||
_command = command.strip()
|
||
return _command.startswith("C-") and len(_command) == 3
|
||
|
||
def _clear_screen(self) -> None:
|
||
"""Clear the tmux pane screen and history."""
|
||
if not self.pane:
|
||
return
|
||
self.pane.send_keys("C-l", enter=False)
|
||
time.sleep(0.1)
|
||
self.pane.cmd("clear-history")
|
||
|
||
def execute(
|
||
self,
|
||
command: str,
|
||
is_input: bool = False,
|
||
blocking: bool = False,
|
||
timeout: float | None = None,
|
||
) -> CommandResult:
|
||
"""Execute a command in the bash session.
|
||
|
||
Args:
|
||
command: Command to execute
|
||
is_input: Whether this is input to a running process
|
||
blocking: Whether to run in blocking mode
|
||
timeout: Hard timeout for command execution
|
||
|
||
Returns:
|
||
CommandResult with execution results
|
||
"""
|
||
if not self._initialized:
|
||
self.initialize()
|
||
|
||
# Strip the command of any leading/trailing whitespace
|
||
command = command.strip()
|
||
|
||
# If the previous command is not completed, check if we can proceed
|
||
if self.prev_status not in {
|
||
BashCommandStatus.CONTINUE,
|
||
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||
BashCommandStatus.HARD_TIMEOUT,
|
||
}:
|
||
if is_input:
|
||
return CommandResult(
|
||
return_code=1,
|
||
error_message="ERROR: No previous running command to interact with.",
|
||
command=command,
|
||
status=BashCommandStatus.COMPLETED,
|
||
session_id=self.id,
|
||
)
|
||
if command == "":
|
||
return CommandResult(
|
||
return_code=1,
|
||
error_message="ERROR: No previous running command to retrieve logs from.",
|
||
command=command,
|
||
status=BashCommandStatus.COMPLETED,
|
||
session_id=self.id,
|
||
)
|
||
|
||
# Check if the command is a single command or multiple commands
|
||
splited_commands = split_bash_commands(command)
|
||
if len(splited_commands) > 1:
|
||
return CommandResult(
|
||
return_code=1,
|
||
error_message=(
|
||
f"ERROR: Cannot execute multiple commands at once.\n"
|
||
f"Please run each command separately OR chain them into a single command via && or ;\n"
|
||
f"Provided commands:\n{'\n'.join(f'({i + 1}) {cmd}' for i, cmd in enumerate(splited_commands))}"
|
||
),
|
||
command=command,
|
||
status=BashCommandStatus.COMPLETED,
|
||
session_id=self.id,
|
||
)
|
||
|
||
# Get initial state before sending command
|
||
initial_pane_output = self._get_pane_content()
|
||
|
||
start_time = time.time()
|
||
last_change_time = start_time
|
||
last_pane_output = initial_pane_output
|
||
|
||
assert self.pane
|
||
|
||
# When prev command is still running and we're trying to send a new command
|
||
if (
|
||
self.prev_status
|
||
in {
|
||
BashCommandStatus.HARD_TIMEOUT,
|
||
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||
}
|
||
and not is_input
|
||
and command != ""
|
||
):
|
||
return self._handle_command_conflict(command, last_pane_output)
|
||
|
||
# Send actual command/inputs to the pane
|
||
if command != "":
|
||
is_special_key = self._is_special_key(command)
|
||
if is_input:
|
||
self.pane.send_keys(command, enter=not is_special_key)
|
||
else:
|
||
# Escape command for bash
|
||
command_escaped = escape_bash_special_chars(command)
|
||
self.pane.send_keys(command_escaped, enter=not is_special_key)
|
||
|
||
# Loop until the command completes or times out
|
||
while True:
|
||
time.sleep(self.POLL_INTERVAL)
|
||
cur_pane_output = self._get_pane_content()
|
||
|
||
if cur_pane_output != last_pane_output:
|
||
last_pane_output = cur_pane_output
|
||
last_change_time = time.time()
|
||
|
||
# 1) Execution completed: Use broader prompt detection
|
||
# Check for various prompt patterns that might be used
|
||
prompt_patterns = [
|
||
"$ ", # bash default
|
||
"$", # bash without space
|
||
"% ", # zsh default
|
||
"%", # zsh without space
|
||
"❯ ", # oh-my-zsh
|
||
"❯", # oh-my-zsh without space
|
||
"> ", # generic
|
||
">", # generic without space
|
||
]
|
||
output_ends_with_prompt = any(
|
||
cur_pane_output.rstrip().endswith(pattern)
|
||
for pattern in prompt_patterns
|
||
)
|
||
|
||
# Also check for username@hostname pattern (common in many shells)
|
||
has_user_host_pattern = "@" in cur_pane_output and any(
|
||
cur_pane_output.rstrip().endswith(indicator)
|
||
for indicator in prompt_patterns
|
||
)
|
||
|
||
if output_ends_with_prompt or has_user_host_pattern:
|
||
return self._fallback_completion_detection(command, cur_pane_output)
|
||
|
||
# 2) No-change timeout (only if not blocking)
|
||
time_since_last_change = time.time() - last_change_time
|
||
if (
|
||
not blocking
|
||
and time_since_last_change >= self.NO_CHANGE_TIMEOUT_SECONDS
|
||
):
|
||
# Extract current output
|
||
lines = cur_pane_output.strip().split("\n")
|
||
output = "\n".join(lines)
|
||
output = _remove_command_prefix(output, command)
|
||
|
||
return CommandResult(
|
||
return_code=-1,
|
||
stdout=output.strip(),
|
||
stderr="",
|
||
error_message=f"no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds",
|
||
command=command,
|
||
status=BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||
session_id=self.id,
|
||
)
|
||
|
||
# 3) Hard timeout
|
||
elapsed_time = time.time() - start_time
|
||
if timeout and elapsed_time >= timeout:
|
||
lines = cur_pane_output.strip().split("\n")
|
||
output = "\n".join(lines)
|
||
output = _remove_command_prefix(output, command)
|
||
|
||
return CommandResult(
|
||
return_code=-1,
|
||
stdout=output.strip(),
|
||
stderr="",
|
||
error_message=f"Command timed out after {timeout} seconds",
|
||
command=command,
|
||
status=BashCommandStatus.HARD_TIMEOUT,
|
||
session_id=self.id,
|
||
)
|
||
|
||
def _handle_command_conflict(self, command: str, pane_output: str) -> CommandResult:
|
||
"""Handle conflicts when trying to send a new command while previous is running."""
|
||
# Extract current output directly
|
||
lines = pane_output.strip().split("\n")
|
||
raw_command_output = "\n".join(lines)
|
||
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
||
|
||
command_output = self._get_command_output(
|
||
command,
|
||
raw_command_output,
|
||
continue_prefix="[Below is the output of the previous command.]\n",
|
||
)
|
||
|
||
# Add suffix message about command conflict
|
||
command_output += (
|
||
f'\n[Your command "{command}" is NOT executed. '
|
||
f"The previous command is still running - You CANNOT send new commands until the previous command is completed. "
|
||
"By setting `is_input` to `true`, you can interact with the current process: "
|
||
"You may wait longer to see additional output of the previous command by sending empty command '', "
|
||
"send other commands to interact with the current process, "
|
||
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
|
||
)
|
||
|
||
return CommandResult(
|
||
return_code=1,
|
||
stdout=command_output,
|
||
command=command,
|
||
status=BashCommandStatus.CONTINUE,
|
||
session_id=self.id,
|
||
)
|
||
|
||
def _handle_nochange_timeout_command(
|
||
self, command: str, pane_content: str
|
||
) -> CommandResult:
|
||
"""Handle a command that timed out due to no output changes."""
|
||
self.prev_status = BashCommandStatus.NO_CHANGE_TIMEOUT
|
||
|
||
# Extract current output directly
|
||
lines = pane_content.strip().split("\n")
|
||
raw_command_output = "\n".join(lines)
|
||
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
||
|
||
command_output = self._get_command_output(
|
||
command,
|
||
raw_command_output,
|
||
continue_prefix="[Below is the output of the previous command.]\n",
|
||
)
|
||
|
||
# Add timeout message
|
||
command_output += (
|
||
f"\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. "
|
||
"You may wait longer to see additional output by sending empty command '', "
|
||
"send other commands to interact with the current process, "
|
||
"or send keys to interrupt/kill the command.]"
|
||
)
|
||
|
||
return CommandResult(
|
||
return_code=-1,
|
||
stdout=command_output,
|
||
command=command,
|
||
status=BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||
session_id=self.id,
|
||
)
|
||
|
||
def _handle_hard_timeout_command(
|
||
self, command: str, pane_content: str, timeout: float
|
||
) -> CommandResult:
|
||
"""Handle a command that hit the hard timeout."""
|
||
self.prev_status = BashCommandStatus.HARD_TIMEOUT
|
||
|
||
# Extract current output directly
|
||
lines = pane_content.strip().split("\n")
|
||
raw_command_output = "\n".join(lines)
|
||
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
||
|
||
command_output = self._get_command_output(
|
||
command,
|
||
raw_command_output,
|
||
continue_prefix="[Below is the output of the previous command.]\n",
|
||
)
|
||
|
||
# Add timeout message
|
||
command_output += (
|
||
f"\n[The command timed out after {timeout} seconds. "
|
||
"You may wait longer to see additional output by sending empty command '', "
|
||
"send other commands to interact with the current process, "
|
||
"or send keys to interrupt/kill the command.]"
|
||
)
|
||
|
||
return CommandResult(
|
||
return_code=-1,
|
||
stdout=command_output,
|
||
command=command,
|
||
status=BashCommandStatus.HARD_TIMEOUT,
|
||
session_id=self.id,
|
||
)
|
||
|
||
def _fallback_completion_detection(
|
||
self, command: str, pane_content: str
|
||
) -> CommandResult:
|
||
"""Fallback completion detection when PS1 metadata is not available."""
|
||
# Use the old logic as fallback
|
||
self.pane.send_keys("echo EXIT_CODE:$?", enter=True)
|
||
time.sleep(0.1)
|
||
|
||
exit_code_output = self._get_pane_content()
|
||
exit_code = 0
|
||
for line in exit_code_output.split("\n"):
|
||
if line.strip().startswith("EXIT_CODE:"):
|
||
try:
|
||
exit_code = int(line.split(":")[1].strip())
|
||
except (ValueError, IndexError):
|
||
exit_code = 0
|
||
break
|
||
|
||
# Improved output extraction for complex shells like oh-my-zsh
|
||
output = self._extract_clean_output(pane_content, command)
|
||
|
||
self.prev_status = BashCommandStatus.COMPLETED # Set prev_status
|
||
self.prev_output = "" # Reset previous command output
|
||
|
||
# Clear screen and history to prevent output accumulation
|
||
self._ready_for_next_command()
|
||
|
||
return CommandResult(
|
||
return_code=exit_code,
|
||
stdout=output.strip(),
|
||
stderr="",
|
||
command=command,
|
||
status=BashCommandStatus.COMPLETED,
|
||
session_id=self.id,
|
||
)
|
||
|
||
def _get_command_output(
|
||
self,
|
||
command: str,
|
||
raw_command_output: str,
|
||
continue_prefix: str = "",
|
||
) -> str:
|
||
"""Get the command output with the previous command output removed."""
|
||
# Remove the previous command output from the new output if any
|
||
if self.prev_output:
|
||
command_output = raw_command_output.removeprefix(self.prev_output)
|
||
# Add continue prefix if we're continuing from previous output
|
||
if continue_prefix:
|
||
command_output = continue_prefix + command_output
|
||
else:
|
||
command_output = raw_command_output
|
||
|
||
self.prev_output = raw_command_output # Update current command output
|
||
command_output = _remove_command_prefix(command_output, command)
|
||
return command_output.rstrip()
|
||
|
||
def _ready_for_next_command(self) -> None:
|
||
"""Reset the content buffer for a new command."""
|
||
self._clear_screen()
|
||
|
||
def _extract_clean_output(self, pane_content: str, command: str) -> str:
|
||
"""Extract clean command output from pane content, handling complex shells like oh-my-zsh."""
|
||
lines = pane_content.split("\n")
|
||
|
||
# Find lines that contain the actual command execution
|
||
command_line_indices = []
|
||
for i, line in enumerate(lines):
|
||
# Look for lines that contain the command (after prompt symbols)
|
||
stripped_line = line.strip()
|
||
# Check if line contains the command after removing common prompt symbols
|
||
clean_line = stripped_line
|
||
for prompt_symbol in ["❯", "$", "%", ">"]:
|
||
if clean_line.startswith(prompt_symbol):
|
||
clean_line = clean_line[len(prompt_symbol) :].strip()
|
||
break
|
||
|
||
if clean_line == command.strip():
|
||
command_line_indices.append(i)
|
||
|
||
if not command_line_indices:
|
||
# Fallback to simple extraction if we can't find the command
|
||
return self._simple_output_extraction(lines, command)
|
||
|
||
# Take the output after the last command line
|
||
last_command_index = command_line_indices[-1]
|
||
output_lines = []
|
||
decorative_line_count = 0
|
||
max_decorative_lines = 2 # Allow a few decorative lines before stopping
|
||
|
||
# Look for output lines immediately after the command
|
||
for i in range(last_command_index + 1, len(lines)):
|
||
line = lines[i]
|
||
stripped_line = line.strip()
|
||
|
||
# Stop if we hit a new prompt line
|
||
if self._is_prompt_line(stripped_line):
|
||
break
|
||
|
||
# Handle decorative lines more intelligently
|
||
if self._is_decorative_line(stripped_line):
|
||
decorative_line_count += 1
|
||
# If we haven't found any output yet, or we've seen too many decorative lines, stop
|
||
if not output_lines or decorative_line_count > max_decorative_lines:
|
||
if output_lines: # We have some output, stop here
|
||
break
|
||
else: # No output yet, but too many decorative lines, give up
|
||
continue
|
||
else:
|
||
# We have some output and this is an occasional decorative line, include it
|
||
output_lines.append(line.rstrip())
|
||
continue
|
||
else:
|
||
# Reset decorative line count when we see non-decorative content
|
||
decorative_line_count = 0
|
||
|
||
# Skip empty lines at the beginning only
|
||
if not output_lines and not stripped_line:
|
||
continue
|
||
|
||
# Add the line to output (preserve original formatting)
|
||
output_lines.append(line.rstrip())
|
||
|
||
return "\n".join(output_lines).rstrip()
|
||
|
||
def _simple_output_extraction(self, lines: list[str], command: str) -> str:
|
||
"""Simple fallback output extraction."""
|
||
if len(lines) > 1:
|
||
output = "\n".join(lines[:-1])
|
||
return _remove_command_prefix(output, command)
|
||
return ""
|
||
|
||
def _is_prompt_line(self, line: str) -> bool:
|
||
"""Check if a line looks like a shell prompt."""
|
||
stripped = line.strip()
|
||
|
||
# Check for common prompt patterns
|
||
prompt_indicators = ["❯", "$", "%", ">"]
|
||
for indicator in prompt_indicators:
|
||
if stripped.startswith(indicator) and len(stripped) > len(indicator):
|
||
return True
|
||
|
||
# Check for user@host patterns
|
||
if "@" in stripped and any(stripped.endswith(ind) for ind in prompt_indicators):
|
||
return True
|
||
|
||
return False
|
||
|
||
def _is_decorative_line(self, line: str) -> bool:
|
||
"""Check if a line is decorative (like oh-my-zsh decorations)."""
|
||
stripped = line.strip()
|
||
|
||
# Check for lines that are mostly special characters or dots
|
||
if len(stripped) > 20: # Long lines are likely decorative
|
||
special_chars = sum(1 for c in stripped if c in "·─═━┌┐└┘├┤┬┴┼▄▀█░▒▓")
|
||
if special_chars > len(stripped) * 0.5: # More than 50% special chars
|
||
return True
|
||
|
||
# Check for lines containing time stamps or status info
|
||
if "at " in stripped and ("AM" in stripped or "PM" in stripped):
|
||
return True
|
||
|
||
# Check for virtual environment deactivation messages
|
||
if "Deactivating:" in line or "_default_venv:" in line:
|
||
return True
|
||
|
||
return False
|