This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kb-skills/gsd-openclaw/src/gsd_openclaw/command_executor.py
T
starmen1265 b817b457e9 feat(04-03): add long command detection to CommandExecutor
- Add LONG_COMMANDS set with execute-phase, plan-phase, discuss-phase, new-project
- Add _is_long_command method to detect long-running commands
- Add TestLongCommands test class with 5 test cases
- Long commands will use no timeout in execute()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:11:32 +08:00

264 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
# Commands that require no timeout (long-running operations)
LONG_COMMANDS: set = {'execute-phase', 'plan-phase', 'discuss-phase', 'new-project'}
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 _is_long_command(self, command: str) -> bool:
"""Check if command requires no timeout.
Long commands like execute-phase, plan-phase, discuss-phase, and new-project
can take an extended time to complete and should not have a timeout limit.
Args:
command: The full command string (e.g., '/gsd:execute-phase').
Returns:
True if command is in LONG_COMMANDS, False otherwise.
"""
# Extract command name from /gsd:command format
match = re.match(r'^/gsd:(\S+)', command)
if match:
command_name = match.group(1)
return command_name in self.LONG_COMMANDS
return False
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,
)