mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 02:45:31 +02:00
448 lines
16 KiB
Python
448 lines
16 KiB
Python
import logging
|
|
import re
|
|
import time
|
|
import uuid
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import libtmux
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BashCommandStatus(Enum):
|
|
CONTINUE = "continue"
|
|
COMPLETED = "completed"
|
|
NO_CHANGE_TIMEOUT = "no_change_timeout"
|
|
HARD_TIMEOUT = "hard_timeout"
|
|
|
|
|
|
def _remove_command_prefix(command_output: str, command: str) -> str:
|
|
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
|
|
|
|
|
class TerminalSession:
|
|
POLL_INTERVAL = 0.5
|
|
HISTORY_LIMIT = 10_000
|
|
PS1_END = "]$ "
|
|
|
|
def __init__(self, session_id: str, work_dir: str = "/workspace") -> None:
|
|
self.session_id = session_id
|
|
self.work_dir = str(Path(work_dir).resolve())
|
|
self._closed = False
|
|
self._cwd = self.work_dir
|
|
|
|
self.server: libtmux.Server | None = None
|
|
self.session: libtmux.Session | None = None
|
|
self.window: libtmux.Window | None = None
|
|
self.pane: libtmux.Pane | None = None
|
|
|
|
self.prev_status: BashCommandStatus | None = None
|
|
self.prev_output: str = ""
|
|
self._initialized = False
|
|
|
|
self.initialize()
|
|
|
|
@property
|
|
def PS1(self) -> str: # noqa: N802
|
|
return r"[STRIX_$?]$ "
|
|
|
|
@property
|
|
def PS1_PATTERN(self) -> str: # noqa: N802
|
|
return r"\[STRIX_(\d+)\]"
|
|
|
|
def initialize(self) -> None:
|
|
self.server = libtmux.Server()
|
|
|
|
session_name = f"strix-{self.session_id}-{uuid.uuid4()}"
|
|
self.session = self.server.new_session(
|
|
session_name=session_name,
|
|
start_directory=self.work_dir,
|
|
kill_session=True,
|
|
x=120,
|
|
y=30,
|
|
)
|
|
|
|
self.session.set_option("history-limit", str(self.HISTORY_LIMIT))
|
|
self.session.history_limit = self.HISTORY_LIMIT
|
|
|
|
_initial_window = self.session.active_window
|
|
self.window = self.session.new_window(
|
|
window_name="bash",
|
|
window_shell="/bin/bash",
|
|
start_directory=self.work_dir,
|
|
)
|
|
self.pane = self.window.active_pane
|
|
_initial_window.kill()
|
|
|
|
self.pane.send_keys(f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""')
|
|
time.sleep(0.1)
|
|
self._clear_screen()
|
|
|
|
self.prev_status = None
|
|
self.prev_output = ""
|
|
self._closed = False
|
|
|
|
self._cwd = str(Path(self.work_dir).resolve())
|
|
self._initialized = True
|
|
|
|
assert self.server is not None
|
|
assert self.session is not None
|
|
assert self.window is not None
|
|
assert self.pane is not None
|
|
|
|
def _get_pane_content(self) -> str:
|
|
if not self.pane:
|
|
raise RuntimeError("Terminal session not properly initialized")
|
|
return "\n".join(
|
|
line.rstrip() for line in self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout
|
|
)
|
|
|
|
def _clear_screen(self) -> None:
|
|
if not self.pane:
|
|
raise RuntimeError("Terminal session not properly initialized")
|
|
self.pane.send_keys("C-l", enter=False)
|
|
time.sleep(0.1)
|
|
self.pane.cmd("clear-history")
|
|
|
|
def _is_control_key(self, command: str) -> bool:
|
|
return (
|
|
(command.startswith("C-") and len(command) >= 3)
|
|
or (command.startswith("^") and len(command) >= 2)
|
|
or (command.startswith("S-") and len(command) >= 3)
|
|
or (command.startswith("M-") and len(command) >= 3)
|
|
)
|
|
|
|
def _is_function_key(self, command: str) -> bool:
|
|
if not command.startswith("F") or len(command) > 3:
|
|
return False
|
|
try:
|
|
num_part = command[1:]
|
|
return num_part.isdigit() and 1 <= int(num_part) <= 12
|
|
except (ValueError, IndexError):
|
|
return False
|
|
|
|
def _is_navigation_or_special_key(self, command: str) -> bool:
|
|
navigation_keys = {"Up", "Down", "Left", "Right", "Home", "End"}
|
|
special_keys = {"BSpace", "BTab", "DC", "Enter", "Escape", "IC", "Space", "Tab"}
|
|
page_keys = {"NPage", "PageDown", "PgDn", "PPage", "PageUp", "PgUp"}
|
|
|
|
return command in navigation_keys or command in special_keys or command in page_keys
|
|
|
|
def _is_complex_modifier_key(self, command: str) -> bool:
|
|
return "-" in command and any(
|
|
command.startswith(prefix)
|
|
for prefix in ["C-S-", "C-M-", "S-M-", "M-S-", "M-C-", "S-C-"]
|
|
)
|
|
|
|
def _is_special_key(self, command: str) -> bool:
|
|
_command = command.strip()
|
|
|
|
if not _command:
|
|
return False
|
|
|
|
return (
|
|
self._is_control_key(_command)
|
|
or self._is_function_key(_command)
|
|
or self._is_navigation_or_special_key(_command)
|
|
or self._is_complex_modifier_key(_command)
|
|
)
|
|
|
|
def _matches_ps1_metadata(self, content: str) -> list[re.Match[str]]:
|
|
return list(re.finditer(self.PS1_PATTERN + r"\]\$ ", content))
|
|
|
|
def _get_command_output(
|
|
self,
|
|
command: str,
|
|
raw_command_output: str,
|
|
continue_prefix: str = "",
|
|
) -> str:
|
|
if self.prev_output:
|
|
command_output = raw_command_output.removeprefix(self.prev_output)
|
|
if continue_prefix:
|
|
command_output = continue_prefix + command_output
|
|
else:
|
|
command_output = raw_command_output
|
|
self.prev_output = raw_command_output
|
|
command_output = _remove_command_prefix(command_output, command)
|
|
return command_output.rstrip()
|
|
|
|
def _combine_outputs_between_matches(
|
|
self,
|
|
pane_content: str,
|
|
ps1_matches: list[re.Match[str]],
|
|
get_content_before_last_match: bool = False,
|
|
) -> str:
|
|
if len(ps1_matches) == 1:
|
|
if get_content_before_last_match:
|
|
return pane_content[: ps1_matches[0].start()]
|
|
return pane_content[ps1_matches[0].end() + 1 :]
|
|
if len(ps1_matches) == 0:
|
|
return pane_content
|
|
|
|
combined_output = ""
|
|
for i in range(len(ps1_matches) - 1):
|
|
output_segment = pane_content[ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()]
|
|
combined_output += output_segment + "\n"
|
|
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
|
|
return combined_output
|
|
|
|
def _extract_exit_code_from_matches(self, ps1_matches: list[re.Match[str]]) -> int | None:
|
|
if not ps1_matches:
|
|
return None
|
|
|
|
last_match = ps1_matches[-1]
|
|
try:
|
|
return int(last_match.group(1))
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
def _handle_empty_command(
|
|
self,
|
|
cur_pane_output: str,
|
|
ps1_matches: list[re.Match[str]],
|
|
is_command_running: bool,
|
|
timeout: float,
|
|
) -> dict[str, Any]:
|
|
if not is_command_running:
|
|
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
|
|
command_output = self._get_command_output("", raw_command_output)
|
|
return {
|
|
"content": command_output,
|
|
"status": "completed",
|
|
"exit_code": 0,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
start_time = time.time()
|
|
last_pane_output = cur_pane_output
|
|
|
|
while True:
|
|
cur_pane_output = self._get_pane_content()
|
|
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
|
|
|
if cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0:
|
|
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
|
raw_command_output = self._combine_outputs_between_matches(
|
|
cur_pane_output, ps1_matches
|
|
)
|
|
command_output = self._get_command_output("", raw_command_output)
|
|
self.prev_status = BashCommandStatus.COMPLETED
|
|
self.prev_output = ""
|
|
self._ready_for_next_command()
|
|
return {
|
|
"content": command_output,
|
|
"status": "completed",
|
|
"exit_code": exit_code or 0,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
elapsed_time = time.time() - start_time
|
|
if elapsed_time >= timeout:
|
|
raw_command_output = self._combine_outputs_between_matches(
|
|
cur_pane_output, ps1_matches
|
|
)
|
|
command_output = self._get_command_output("", raw_command_output)
|
|
return {
|
|
"content": command_output
|
|
+ f"\n[Command still running after {timeout}s - showing output so far]",
|
|
"status": "running",
|
|
"exit_code": None,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
if cur_pane_output != last_pane_output:
|
|
last_pane_output = cur_pane_output
|
|
|
|
time.sleep(self.POLL_INTERVAL)
|
|
|
|
def _handle_input_command(
|
|
self, command: str, no_enter: bool, is_command_running: bool
|
|
) -> dict[str, Any]:
|
|
if not is_command_running:
|
|
return {
|
|
"content": "No command is currently running. Cannot send input.",
|
|
"status": "error",
|
|
"exit_code": None,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
if not self.pane:
|
|
raise RuntimeError("Terminal session not properly initialized")
|
|
|
|
is_special_key = self._is_special_key(command)
|
|
should_add_enter = not is_special_key and not no_enter
|
|
self.pane.send_keys(command, enter=should_add_enter)
|
|
|
|
time.sleep(2)
|
|
cur_pane_output = self._get_pane_content()
|
|
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
|
raw_command_output = self._combine_outputs_between_matches(cur_pane_output, ps1_matches)
|
|
command_output = self._get_command_output(command, raw_command_output)
|
|
|
|
is_still_running = not (
|
|
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
|
|
)
|
|
|
|
if is_still_running:
|
|
return {
|
|
"content": command_output,
|
|
"status": "running",
|
|
"exit_code": None,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
|
self.prev_status = BashCommandStatus.COMPLETED
|
|
self.prev_output = ""
|
|
self._ready_for_next_command()
|
|
return {
|
|
"content": command_output,
|
|
"status": "completed",
|
|
"exit_code": exit_code or 0,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
def _execute_new_command(self, command: str, no_enter: bool, timeout: float) -> dict[str, Any]:
|
|
if not self.pane:
|
|
raise RuntimeError("Terminal session not properly initialized")
|
|
|
|
initial_pane_output = self._get_pane_content()
|
|
initial_ps1_matches = self._matches_ps1_metadata(initial_pane_output)
|
|
initial_ps1_count = len(initial_ps1_matches)
|
|
|
|
start_time = time.time()
|
|
last_pane_output = initial_pane_output
|
|
|
|
is_special_key = self._is_special_key(command)
|
|
should_add_enter = not is_special_key and not no_enter
|
|
self.pane.send_keys(command, enter=should_add_enter)
|
|
|
|
while True:
|
|
cur_pane_output = self._get_pane_content()
|
|
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
|
current_ps1_count = len(ps1_matches)
|
|
|
|
if cur_pane_output != last_pane_output:
|
|
last_pane_output = cur_pane_output
|
|
|
|
if current_ps1_count > initial_ps1_count or cur_pane_output.rstrip().endswith(
|
|
self.PS1_END.rstrip()
|
|
):
|
|
exit_code = self._extract_exit_code_from_matches(ps1_matches)
|
|
|
|
get_content_before_last_match = bool(len(ps1_matches) == 1)
|
|
raw_command_output = self._combine_outputs_between_matches(
|
|
cur_pane_output,
|
|
ps1_matches,
|
|
get_content_before_last_match=get_content_before_last_match,
|
|
)
|
|
|
|
command_output = self._get_command_output(command, raw_command_output)
|
|
self.prev_status = BashCommandStatus.COMPLETED
|
|
self.prev_output = ""
|
|
self._ready_for_next_command()
|
|
|
|
return {
|
|
"content": command_output,
|
|
"status": "completed",
|
|
"exit_code": exit_code or 0,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
elapsed_time = time.time() - start_time
|
|
if elapsed_time >= timeout:
|
|
raw_command_output = self._combine_outputs_between_matches(
|
|
cur_pane_output, ps1_matches
|
|
)
|
|
command_output = self._get_command_output(
|
|
command,
|
|
raw_command_output,
|
|
continue_prefix="[Below is the output of the previous command.]\n",
|
|
)
|
|
self.prev_status = BashCommandStatus.CONTINUE
|
|
|
|
timeout_msg = (
|
|
f"\n[Command still running after {timeout}s - showing output so far. "
|
|
"Use C-c to interrupt if needed.]"
|
|
)
|
|
return {
|
|
"content": command_output + timeout_msg,
|
|
"status": "running",
|
|
"exit_code": None,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
time.sleep(self.POLL_INTERVAL)
|
|
|
|
def execute(
|
|
self, command: str, is_input: bool = False, timeout: float = 10.0, no_enter: bool = False
|
|
) -> dict[str, Any]:
|
|
if not self._initialized:
|
|
raise RuntimeError("Bash session is not initialized")
|
|
|
|
cur_pane_output = self._get_pane_content()
|
|
ps1_matches = self._matches_ps1_metadata(cur_pane_output)
|
|
is_command_running = not (
|
|
cur_pane_output.rstrip().endswith(self.PS1_END.rstrip()) or len(ps1_matches) > 0
|
|
)
|
|
|
|
if command.strip() == "":
|
|
return self._handle_empty_command(
|
|
cur_pane_output, ps1_matches, is_command_running, timeout
|
|
)
|
|
|
|
is_special_key = self._is_special_key(command)
|
|
|
|
if is_input:
|
|
return self._handle_input_command(command, no_enter, is_command_running)
|
|
|
|
if is_special_key and is_command_running:
|
|
return self._handle_input_command(command, no_enter, is_command_running)
|
|
|
|
if is_command_running:
|
|
return {
|
|
"content": (
|
|
"A command is already running. Use is_input=true to send input to it, "
|
|
"or interrupt it first (e.g., with C-c)."
|
|
),
|
|
"status": "error",
|
|
"exit_code": None,
|
|
"working_dir": self._cwd,
|
|
}
|
|
|
|
return self._execute_new_command(command, no_enter, timeout)
|
|
|
|
def _ready_for_next_command(self) -> None:
|
|
self._clear_screen()
|
|
|
|
def is_running(self) -> bool:
|
|
if self._closed or not self.session:
|
|
return False
|
|
try:
|
|
return self.session.id in [s.id for s in self.server.sessions] if self.server else False
|
|
except (AttributeError, OSError) as e:
|
|
logger.debug("Error checking if session is running: %s", e)
|
|
return False
|
|
|
|
def get_working_dir(self) -> str:
|
|
return self._cwd
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
|
|
if self.session:
|
|
try:
|
|
self.session.kill()
|
|
except (AttributeError, OSError) as e:
|
|
logger.debug("Error closing terminal session: %s", e)
|
|
|
|
self._closed = True
|
|
self.server = None
|
|
self.session = None
|
|
self.window = None
|
|
self.pane = None
|