feat(03-01): implement CommandExecutor and OutputCapture classes
- Add CommandResult dataclass for storing execution results - Add OutputCapture class with prompt detection and stabilization - Add CommandExecutor class for command execution and error detection - Support common shell prompts ($, ❯, ➜, >) - Detect errors in both English and Chinese - Use 60s default timeout from CONTEXT.md decision - Mock-based testing (no real tmux required) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""Command execution and output capture for tmux sessions.
|
||||
|
||||
This module provides the foundational I/O layer for all GSD command bridging:
|
||||
- Send commands to tmux sessions
|
||||
- Capture output with completion detection
|
||||
- Identify errors in command output
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple, Pattern
|
||||
|
||||
import libtmux
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
output: The captured output from the command.
|
||||
completed: True if the command completed (prompt detected or output stabilized).
|
||||
has_error: True if error keywords were detected in output.
|
||||
error_type: The type of error detected, if any.
|
||||
"""
|
||||
output: str
|
||||
completed: bool
|
||||
has_error: bool
|
||||
error_type: Optional[str]
|
||||
|
||||
|
||||
class OutputCapture:
|
||||
"""Captures output from tmux panes with completion detection.
|
||||
|
||||
This class provides functionality to capture output from a tmux pane
|
||||
and detect when command execution has completed, either by detecting
|
||||
a shell prompt or by observing stable output.
|
||||
"""
|
||||
|
||||
# Regex patterns for common shell prompts
|
||||
# These patterns match lines that are ONLY a prompt (no other content)
|
||||
PROMPT_PATTERNS: List[Pattern] = [
|
||||
re.compile(r'^\$\s*$'), # Simple $ prompt
|
||||
re.compile(r'^❯\s*$'), # Starship/Powerline prompt
|
||||
re.compile(r'^➜\s*$'), # Agnoster prompt
|
||||
re.compile(r'^>\s*$'), # Simple > prompt
|
||||
]
|
||||
|
||||
def _is_prompt(self, line: str) -> bool:
|
||||
"""Check if a line is a shell prompt.
|
||||
|
||||
Args:
|
||||
line: The line to check.
|
||||
|
||||
Returns:
|
||||
True if the line matches a known prompt pattern.
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
|
||||
for pattern in self.PROMPT_PATTERNS:
|
||||
if pattern.match(stripped):
|
||||
return True
|
||||
return False
|
||||
|
||||
def capture_until_complete(
|
||||
self,
|
||||
pane: libtmux.Pane,
|
||||
timeout: float = 60.0,
|
||||
poll_interval: float = 0.1,
|
||||
stable_count: int = 3
|
||||
) -> Tuple[str, bool]:
|
||||
"""Capture output until command completes or timeout.
|
||||
|
||||
Completion is detected when either:
|
||||
1. A shell prompt appears in the output (command finished)
|
||||
2. Output stabilizes (same line count for N consecutive polls)
|
||||
|
||||
Args:
|
||||
pane: The tmux pane to capture output from.
|
||||
timeout: Maximum time to wait for completion (seconds).
|
||||
poll_interval: Time between capture polls (seconds).
|
||||
stable_count: Number of consecutive polls with same output to consider stable.
|
||||
|
||||
Returns:
|
||||
Tuple of (captured_output, completed).
|
||||
- captured_output: The captured output as a string.
|
||||
- completed: True if completion was detected, False if timeout.
|
||||
"""
|
||||
start_time = time.time()
|
||||
last_output_hash: Optional[str] = None
|
||||
stability_counter = 0
|
||||
|
||||
captured_lines: List[str] = []
|
||||
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= timeout:
|
||||
# Timeout reached
|
||||
return '\n'.join(captured_lines), False
|
||||
|
||||
# Capture current pane content
|
||||
try:
|
||||
current_lines = pane.capture_pane()
|
||||
except Exception:
|
||||
# If capture fails, wait and retry
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
# Store captured lines
|
||||
captured_lines = list(current_lines) if current_lines else []
|
||||
|
||||
# Check for prompt in last few lines
|
||||
for line in captured_lines[-5:]: # Check last 5 lines for prompt
|
||||
if self._is_prompt(line):
|
||||
return '\n'.join(captured_lines), True
|
||||
|
||||
# Check for output stabilization (same content for N consecutive polls)
|
||||
current_output = '\n'.join(captured_lines)
|
||||
current_hash = hash(current_output)
|
||||
if current_hash == last_output_hash:
|
||||
stability_counter += 1
|
||||
if stability_counter >= stable_count:
|
||||
return '\n'.join(captured_lines), True
|
||||
else:
|
||||
stability_counter = 0
|
||||
last_output_hash = current_hash
|
||||
|
||||
# Wait before next poll
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
"""Executes commands in tmux sessions and captures output.
|
||||
|
||||
This class provides the main interface for sending commands to tmux
|
||||
sessions and capturing their output with error detection.
|
||||
"""
|
||||
|
||||
# Keywords that indicate errors in command output
|
||||
ERROR_KEYWORDS: List[str] = [
|
||||
'error',
|
||||
'failed',
|
||||
'exception',
|
||||
'traceback',
|
||||
'错误',
|
||||
'失败',
|
||||
]
|
||||
|
||||
DEFAULT_TIMEOUT: float = 60.0
|
||||
|
||||
def __init__(self, output_capture: Optional[OutputCapture] = None):
|
||||
"""Initialize CommandExecutor.
|
||||
|
||||
Args:
|
||||
output_capture: Optional OutputCapture instance for dependency injection.
|
||||
"""
|
||||
self.output_capture = output_capture or OutputCapture()
|
||||
|
||||
def send_command(self, pane: libtmux.Pane, command: str) -> None:
|
||||
"""Send a command to a tmux pane.
|
||||
|
||||
Args:
|
||||
pane: The tmux pane to send the command to.
|
||||
command: The command string to execute.
|
||||
"""
|
||||
pane.send_keys(command, enter=True)
|
||||
|
||||
def _detect_errors(self, output: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Detect error keywords in output.
|
||||
|
||||
Args:
|
||||
output: The output to analyze for errors.
|
||||
|
||||
Returns:
|
||||
Tuple of (has_error, error_type).
|
||||
- has_error: True if any error keyword was found.
|
||||
- error_type: The first matching error keyword, or None.
|
||||
"""
|
||||
output_lower = output.lower()
|
||||
|
||||
# Check each keyword and return the first match
|
||||
for keyword in self.ERROR_KEYWORDS:
|
||||
keyword_lower = keyword.lower()
|
||||
# For ASCII keywords, use word boundary matching
|
||||
# For non-ASCII (e.g., Chinese), use simple substring match
|
||||
if keyword.isascii():
|
||||
if re.search(rf'\b{re.escape(keyword_lower)}\b', output_lower):
|
||||
return True, keyword
|
||||
else:
|
||||
if keyword in output:
|
||||
return True, keyword
|
||||
|
||||
return False, None
|
||||
|
||||
def execute(
|
||||
self,
|
||||
session, # TmuxSession type - avoiding circular import
|
||||
command: str,
|
||||
timeout: Optional[float] = None,
|
||||
poll_interval: float = 0.1
|
||||
) -> CommandResult:
|
||||
"""Execute a command in a tmux session and capture the result.
|
||||
|
||||
Args:
|
||||
session: The TmuxSession to execute the command in.
|
||||
command: The command string to execute.
|
||||
timeout: Maximum time to wait for completion (seconds).
|
||||
Defaults to DEFAULT_TIMEOUT (60.0).
|
||||
poll_interval: Time between capture polls (seconds).
|
||||
|
||||
Returns:
|
||||
CommandResult containing output, completion status, and error info.
|
||||
"""
|
||||
timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
|
||||
|
||||
# Get the active pane from the session
|
||||
pane = session.tmux_session.active_pane
|
||||
|
||||
# Send the command
|
||||
self.send_command(pane, command)
|
||||
|
||||
# Capture output until completion
|
||||
output, completed = self.output_capture.capture_until_complete(
|
||||
pane,
|
||||
timeout=timeout,
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
|
||||
# Detect errors in output
|
||||
has_error, error_type = self._detect_errors(output)
|
||||
|
||||
return CommandResult(
|
||||
output=output,
|
||||
completed=completed,
|
||||
has_error=has_error,
|
||||
error_type=error_type,
|
||||
)
|
||||
@@ -133,7 +133,7 @@ class TestCommandExecutorDetectErrors:
|
||||
def test_detect_errors_returns_true_for_exception_keyword(self):
|
||||
"""_detect_errors should return (True, 'exception') for exception in output."""
|
||||
executor = CommandExecutor()
|
||||
has_error, error_type = executor._detect_errors("Traceback: ValueError occurred")
|
||||
has_error, error_type = executor._detect_errors("ValueError: An exception occurred")
|
||||
|
||||
assert has_error is True
|
||||
assert error_type == "exception"
|
||||
|
||||
Reference in New Issue
Block a user