自动同步: 2026-03-12 03:00

This commit is contained in:
2026-03-12 03:00:29 +08:00
parent a078c575b9
commit deff5877b4
70 changed files with 8684 additions and 367 deletions
@@ -10,6 +10,11 @@ __version__ = "0.1.0"
from gsd_openclaw.state_manager import SessionState, StateManager, save_state, load_state
from gsd_openclaw.workspace import Workspace, WorkspaceManager
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager, SessionInfo
from gsd_openclaw.command_executor import CommandResult, OutputCapture, CommandExecutor
from gsd_openclaw.approval_handler import ApprovalHandler, ApprovalState
from gsd_openclaw.progress_monitor import ProgressMonitor, ProgressInfo
from gsd_openclaw.orchestrator import Orchestrator
from gsd_openclaw.user_context import UserContext, UserContextManager
__all__ = [
"SessionState",
@@ -21,4 +26,14 @@ __all__ = [
"TmuxSession",
"TmuxSessionManager",
"SessionInfo",
"CommandResult",
"OutputCapture",
"CommandExecutor",
"ApprovalHandler",
"ApprovalState",
"ProgressMonitor",
"ProgressInfo",
"Orchestrator",
"UserContext",
"UserContextManager",
]
@@ -0,0 +1,243 @@
"""
Approval workflow handler for interactive GSD operations.
This module provides:
- Detection of approval requests in Claude CLI output
- Creation of approval cards with confirm/reject buttons
- Callback handling for approval actions
- Persistence of approval state for crash recovery
"""
from __future__ import annotations
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Pattern
from gsd_openclaw.response_formatter import FeishuResponseFormatter, CardButton
@dataclass
class ApprovalState:
"""Represents the state of a pending approval.
Attributes:
approval_id: Unique identifier for this approval.
session_id: ID of the GSD session this approval belongs to.
project_name: Name of the project.
context: Additional context about the approval request.
created_at: Unix timestamp when the approval was created.
"""
approval_id: str
session_id: str
project_name: str
context: Dict[str, Any]
created_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
"""Convert ApprovalState to a dictionary for persistence."""
return {
"approval_id": self.approval_id,
"session_id": self.session_id,
"project_name": self.project_name,
"context": self.context,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> ApprovalState:
"""Create ApprovalState from a dictionary."""
return cls(
approval_id=data["approval_id"],
session_id=data["session_id"],
project_name=data["project_name"],
context=data.get("context", {}),
created_at=data.get("created_at", time.time()),
)
class ApprovalHandler:
"""Handles approval workflow for interactive GSD operations.
This class provides functionality to:
- Detect approval requests in Claude CLI output
- Create Feishu cards with confirm/reject buttons
- Handle button callbacks
- Persist approval state for crash recovery
Attributes:
APPROVAL_PATTERNS: List of patterns that indicate an approval request.
pending_approvals: In-memory cache of pending approvals.
"""
# Patterns that indicate Claude is asking for user confirmation
APPROVAL_PATTERNS: List[str] = [
'AskUserQuestion',
'ExitPlanMode',
'需要确认',
'Please confirm',
]
def __init__(self, formatter: FeishuResponseFormatter):
"""Initialize ApprovalHandler.
Args:
formatter: FeishuResponseFormatter instance for creating cards.
"""
self.formatter = formatter
self.pending_approvals: Dict[str, ApprovalState] = {}
def detect_approval_request(self, output: str) -> Optional[Dict[str, Any]]:
"""Detect if output contains an approval request.
Scans the output for known approval patterns and returns context
about the approval request if found.
Args:
output: The output text to scan for approval patterns.
Returns:
Dictionary with approval context if found, None otherwise.
The dictionary includes:
- type: The matched pattern type
- matched_text: The text that was matched
"""
for pattern in self.APPROVAL_PATTERNS:
if pattern in output:
# Extract some context around the match
idx = output.find(pattern)
start = max(0, idx - 50)
end = min(len(output), idx + len(pattern) + 50)
matched_text = output[start:end].strip()
return {
"type": pattern,
"matched_text": matched_text,
}
return None
def create_approval_card(
self,
context: Dict[str, Any],
session_id: str,
project_name: str
) -> dict:
"""Create a Feishu approval card with confirm/reject buttons.
Args:
context: Context dictionary from detect_approval_request.
session_id: ID of the GSD session.
project_name: Name of the project.
Returns:
Feishu Card JSON dict with confirm/reject buttons.
"""
# Generate unique approval ID
approval_id = str(uuid.uuid4())
# Create approval state and store it
approval_state = ApprovalState(
approval_id=approval_id,
session_id=session_id,
project_name=project_name,
context=context,
)
self.pending_approvals[approval_id] = approval_state
# Extract question text for display
question_text = context.get("matched_text", "是否继续?")
# Create buttons
confirm_button = CardButton(
text="确认",
value={
"action": "approve",
"approval_id": approval_id,
"project": project_name,
},
button_type="primary",
)
reject_button = CardButton(
text="取消",
value={
"action": "reject",
"approval_id": approval_id,
"project": project_name,
},
button_type="danger",
)
# Format card using formatter
card = self.formatter.format_info(
title="需要确认",
content=question_text,
buttons=[confirm_button, reject_button],
)
return card
def handle_callback(self, callback_data: Dict[str, Any]) -> str:
"""Handle a button callback from the approval card.
Args:
callback_data: Dictionary containing:
- action: "approve" or "reject"
- approval_id: The ID of the approval
- project: The project name
Returns:
"approved" if action was approve,
"rejected" if action was reject,
"error: ..." if approval not found or invalid.
"""
action = callback_data.get("action", "")
approval_id = callback_data.get("approval_id", "")
# Check if approval exists
if approval_id not in self.pending_approvals:
return "error: approval not found or expired"
# Remove the approval from pending (one-time use)
approval = self.pending_approvals.pop(approval_id)
if action == "approve":
return "approved"
elif action == "reject":
return "rejected"
else:
return f"error: unknown action '{action}'"
def persist_approval(self, state_manager, approval: ApprovalState) -> None:
"""Persist approval state to StateManager for crash recovery.
Args:
state_manager: StateManager instance for persistence.
approval: The ApprovalState to persist.
"""
# Load current state
current_state = state_manager.load()
if current_state is None:
return # No session to persist to
# Store approval in state (would need SessionState extension)
# For now, we keep approvals in memory only as per plan
pass
def load_approval(
self,
state_manager,
approval_id: str
) -> Optional[ApprovalState]:
"""Load approval state from StateManager.
Args:
state_manager: StateManager instance for persistence.
approval_id: The approval ID to load.
Returns:
ApprovalState if found, None otherwise.
"""
# For now, return from in-memory cache
return self.pending_approvals.get(approval_id)
@@ -0,0 +1,263 @@
"""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,
)
@@ -0,0 +1,403 @@
"""
Main command router and orchestrator for OpenClaw skill invocation.
This module provides the central command routing system that:
- Receives chat messages via handle_message()
- Routes commands through session management and command execution
- Handles approval workflows and progress updates
- Returns formatted Feishu card responses
"""
from __future__ import annotations
import re
from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager
from gsd_openclaw.command_executor import CommandExecutor, CommandResult
from gsd_openclaw.approval_handler import ApprovalHandler
from gsd_openclaw.progress_monitor import ProgressMonitor
from gsd_openclaw.response_formatter import FeishuResponseFormatter, CardButton
from gsd_openclaw.user_context import UserContextManager
class Orchestrator:
"""Central command router for OpenClaw skill invocation.
This class provides the main entry point for handling GSD commands
from chat messages, integrating session management, command execution,
approval handling, and response formatting.
Attributes:
tmux_manager: TmuxSessionManager for session lifecycle.
formatter: FeishuResponseFormatter for card creation.
executor: CommandExecutor for running commands.
approval_handler: ApprovalHandler for interactive approvals.
progress_monitor: ProgressMonitor for progress updates.
"""
# Regex pattern for /gsd:* command parsing
COMMAND_PATTERN = re.compile(r'^/gsd:(\S+)\s*(.*)$')
# Maximum characters for card content (Feishu limit)
MAX_OUTPUT_LENGTH = 2000
# Commands that require long-running sessions (no timeout)
LONG_COMMANDS = {'execute-phase', 'plan-phase', 'discuss-phase', 'new-project'}
def __init__(
self,
tmux_manager: Optional[TmuxSessionManager] = None,
formatter: Optional[FeishuResponseFormatter] = None,
users_root: Optional["Path"] = None
):
"""Initialize Orchestrator.
Args:
tmux_manager: Optional TmuxSessionManager instance.
If not provided, a new one will be created.
formatter: Optional FeishuResponseFormatter instance.
If not provided, a new one will be created.
users_root: Optional custom root for user contexts (useful for testing).
"""
self.tmux_manager = tmux_manager or TmuxSessionManager()
self.formatter = formatter or FeishuResponseFormatter()
self.executor = CommandExecutor()
self.approval_handler = ApprovalHandler(self.formatter)
self.progress_monitor = ProgressMonitor(self.formatter)
self.user_context_managers: Dict[str, UserContextManager] = {}
self._users_root = users_root
def _get_user_context_manager(self, user_id: str) -> UserContextManager:
"""Get or create UserContextManager for a user.
Args:
user_id: The user identifier.
Returns:
UserContextManager instance for the user.
"""
if user_id not in self.user_context_managers:
self.user_context_managers[user_id] = UserContextManager(user_id, self._users_root)
return self.user_context_managers[user_id]
def _is_long_command(self, command: str) -> bool:
"""Check if command requires no timeout.
Args:
command: The command name (e.g., 'execute-phase', 'status').
Returns:
True if command is in LONG_COMMANDS, False otherwise.
"""
return command in self.LONG_COMMANDS
def _handle_new_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
"""Handle /gsd:new-project command.
Pre-creates a tmux session before executing the command,
then updates user context to track the new project.
Args:
user_id: ID of the user sending the command.
project_name: Name of the project to create.
Returns:
Feishu Card JSON dict as response.
"""
# Validate project name
if not project_name or not project_name.strip():
return self.formatter.format_error(
title="Project name required",
content="Please provide a project name: /gsd:new-project <name>"
)
project_name = project_name.strip()
try:
# Create session first
session = self.tmux_manager.create_session(project_name)
# Build and execute the new-project command
full_command = f"/gsd:new-project {project_name}"
# Execute with no timeout for long-running commands
result = self.executor.execute(session, full_command, timeout=None)
# Update user context
user_ctx = self._get_user_context_manager(user_id)
user_ctx.set_current_project(project_name)
# Truncate output if needed
output = result.output
if len(output) > self.MAX_OUTPUT_LENGTH:
output = output[-self.MAX_OUTPUT_LENGTH:]
# Return appropriate response
if result.has_error:
return self.formatter.format_error(
title="Project creation failed",
content=output
)
else:
return self.formatter.format_success(
title="Project created",
content=f"Project '{project_name}' created successfully.\n\n{output}"
)
except Exception as e:
return self.formatter.format_error(
title="Project creation failed",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
def _handle_use_project(self, user_id: str, project_name: str) -> Dict[str, Any]:
"""Handle /gsd:use command.
Switches the user's current project context. Shows error with
"Create New Project" button if the project doesn't exist.
Args:
user_id: ID of the user sending the command.
project_name: Name of the project to switch to.
Returns:
Feishu Card JSON dict as response.
"""
# Validate project name
if not project_name or not project_name.strip():
return self.formatter.format_error(
title="Project name required",
content="Please provide a project name: /gsd:use <name>"
)
project_name = project_name.strip()
try:
# Check if project session exists
session = self.tmux_manager.get_session(project_name)
if session is not None:
# Project exists - update user context and return success
user_ctx = self._get_user_context_manager(user_id)
user_ctx.set_current_project(project_name)
return self.formatter.format_success(
title="Project switched",
content=f"Switched to project '{project_name}'"
)
else:
# Project doesn't exist - return error with Create button
create_button = CardButton(
text="Create New Project",
value={"action": "new-project", "name": project_name},
button_type="primary"
)
return self.formatter.format_error(
title="Project not found",
content=f"Project '{project_name}' does not exist",
buttons=[create_button]
)
except Exception as e:
return self.formatter.format_error(
title="Switch failed",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
def parse_command(self, message: str) -> Optional[Tuple[str, str]]:
"""Parse a message for /gsd:* command pattern.
Args:
message: The message text to parse.
Returns:
Tuple of (command, args) if matched, None otherwise.
"""
match = self.COMMAND_PATTERN.match(message.strip())
if match:
command = match.group(1)
args = match.group(2).strip()
return (command, args)
return None
def handle_message(
self,
user_id: str,
message: str,
channel: str,
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Handle an incoming chat message.
This is the main entry point for processing GSD commands.
It handles:
1. Command parsing
2. Session management (auto-create if needed)
3. Command execution
4. Approval detection
5. Response formatting
Args:
user_id: ID of the user sending the message.
message: The message text.
channel: The channel where the message was sent.
context: Additional context including project name.
Returns:
Feishu Card JSON dict as response.
"""
try:
# Parse command
parsed = self.parse_command(message)
if parsed is None:
return self.formatter.format_info(
title="无效命令",
content="请使用 /gsd:* 格式的命令。例如: /gsd:status"
)
command, args = parsed
# Route special commands
if command == 'new-project':
return self._handle_new_project(user_id, args)
if command == 'use':
return self._handle_use_project(user_id, args)
# Generic commands - get project from user context first
user_ctx = self._get_user_context_manager(user_id)
project_name = user_ctx.get_current_project()
# Fall back to context param or default
if not project_name:
project_name = context.get("project", "default")
# Get or create session (auto-create per CONTEXT.md)
session = self.tmux_manager.get_session(project_name)
if session is None:
session = self.tmux_manager.create_session(project_name)
# Build the full command
full_command = f"/gsd:{command}"
if args:
full_command = f"{full_command} {args}"
# Determine timeout based on command type
timeout = None if self._is_long_command(command) else self.executor.DEFAULT_TIMEOUT
# Execute command with adaptive timeout
result = self.executor.execute(session, full_command, timeout=timeout)
# Truncate output if needed
output = result.output
if len(output) > self.MAX_OUTPUT_LENGTH:
output = output[-self.MAX_OUTPUT_LENGTH:]
# Check for approval request
approval_context = self.approval_handler.detect_approval_request(result.output)
if approval_context:
return self.approval_handler.create_approval_card(
context=approval_context,
session_id=session.session_id,
project_name=project_name
)
# Return appropriate response based on result
if result.has_error:
return self.formatter.format_error(
title="命令执行失败",
content=output
)
else:
return self.formatter.format_success(
title="执行成功",
content=output
)
except Exception as e:
return self.formatter.format_error(
title="命令执行失败",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
def handle_callback(self, callback_data: Dict[str, Any]) -> Dict[str, Any]:
"""Handle a button callback from a card.
This processes various actions including:
- approve/reject: For approval workflows
- new-project: For creating new projects from error cards
Args:
callback_data: Dictionary containing:
- action: "approve", "reject", or "new-project"
- For approvals: approval_id, project
- For new-project: name, user_id
Returns:
Feishu Card JSON dict as confirmation response.
"""
try:
action = callback_data.get("action", "")
# Handle new-project action from error card button
if action == "new-project":
project_name = callback_data.get("name", "")
user_id = callback_data.get("user_id", "")
if not project_name:
return self.formatter.format_error(
title="Invalid Request",
content="Project name is required"
)
return self._handle_new_project(user_id, project_name)
# Handle approval/reject actions
approval_id = callback_data.get("approval_id", "")
project_name = callback_data.get("project", "default")
# Handle callback via approval handler
result = self.approval_handler.handle_callback(callback_data)
if result.startswith("error:"):
return self.formatter.format_error(
title="操作失败",
content=result
)
# Get session
session = self.tmux_manager.get_session(project_name)
if session is None:
return self.formatter.format_error(
title="会话不存在",
content="无法找到对应的会话"
)
# Send response to tmux
if result == "approved":
response_text = "y" # Standard confirmation
self.executor.send_command(session.tmux_session.active_pane, response_text)
return self.formatter.format_success(
title="已确认",
content="操作已继续执行"
)
elif result == "rejected":
response_text = "n" # Standard rejection
self.executor.send_command(session.tmux_session.active_pane, response_text)
return self.formatter.format_info(
title="已取消",
content="操作已取消"
)
else:
return self.formatter.format_error(
title="未知结果",
content=f"处理结果: {result}"
)
except Exception as e:
return self.formatter.format_error(
title="操作失败",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
@@ -0,0 +1,164 @@
"""
Progress monitor for tracking and reporting GSD operation progress.
This module provides:
- Detection of progress indicators in Claude CLI output
- Throttled progress updates (1 minute intervals)
- Progress card creation for Feishu messages
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple, Pattern
from gsd_openclaw.response_formatter import FeishuResponseFormatter
@dataclass
class ProgressInfo:
"""Represents extracted progress information.
Attributes:
type: Type of progress indicator ('percentage', 'fraction', 'phase', 'completion').
value: Numeric value (for percentage or phase).
current: Current count (for fraction type).
total: Total count (for fraction type).
"""
type: str
value: Optional[int] = None
current: Optional[int] = None
total: Optional[int] = None
class ProgressMonitor:
"""Monitors and reports progress for GSD operations.
This class provides functionality to:
- Extract progress indicators from output text
- Throttle progress updates to specified intervals
- Create progress cards for Feishu messages
Attributes:
PROGRESS_PATTERNS: List of (pattern, type) tuples for progress detection.
update_interval: Minimum seconds between progress updates (default 60.0).
last_update_time: Unix timestamp of last update.
"""
# Patterns for detecting progress in output
# Each tuple is (regex pattern, progress type)
PROGRESS_PATTERNS: List[Tuple[Pattern, str]] = [
(re.compile(r'(\d+)%'), 'percentage'),
(re.compile(r'\[(\d+)/(\d+)(?:[^\]]*)\]'), 'fraction'), # Allow content after fraction
(re.compile(r'Phase\s+(\d+)', re.IGNORECASE), 'phase'),
(re.compile(r'完成|completed|done', re.IGNORECASE), 'completion'),
]
def __init__(
self,
formatter: FeishuResponseFormatter,
update_interval: float = 60.0
):
"""Initialize ProgressMonitor.
Args:
formatter: FeishuResponseFormatter instance for creating cards.
update_interval: Minimum seconds between progress updates (default 60.0).
"""
self.formatter = formatter
self.update_interval = update_interval
self.last_update_time: float = 0.0
def extract_progress(self, output: str) -> Optional[ProgressInfo]:
"""Extract progress information from output text.
Scans output for known progress patterns and returns the first match.
Args:
output: The output text to scan for progress indicators.
Returns:
ProgressInfo if progress pattern found, None otherwise.
"""
for pattern, progress_type in self.PROGRESS_PATTERNS:
match = pattern.search(output)
if match:
if progress_type == 'percentage':
return ProgressInfo(
type='percentage',
value=int(match.group(1)),
)
elif progress_type == 'fraction':
return ProgressInfo(
type='fraction',
current=int(match.group(1)),
total=int(match.group(2)),
)
elif progress_type == 'phase':
return ProgressInfo(
type='phase',
value=int(match.group(1)),
)
elif progress_type == 'completion':
return ProgressInfo(
type='completion',
value=100, # Completion means 100%
)
return None
def should_send_update(self) -> bool:
"""Check if enough time has elapsed for a progress update.
Returns:
True if update_interval has elapsed since last update, False otherwise.
Returns False if no update has been sent yet (first call).
"""
# If no update has been sent yet (last_update_time is 0), return False
if self.last_update_time == 0.0:
return False
current_time = time.time()
elapsed = current_time - self.last_update_time
return elapsed >= self.update_interval
def create_progress_card(
self,
progress: ProgressInfo,
message: str
) -> dict:
"""Create a Feishu progress card.
Args:
progress: ProgressInfo with extracted progress data.
message: Additional message to include in the card.
Returns:
Feishu Card JSON dict with progress information.
"""
# Format progress content
if progress.type == 'percentage':
progress_text = f"进度: {progress.value}%"
elif progress.type == 'fraction':
progress_text = f"进度: [{progress.current}/{progress.total}]"
elif progress.type == 'phase':
progress_text = f"阶段: {progress.value}"
elif progress.type == 'completion':
progress_text = "完成"
else:
progress_text = f"进度: {progress.value}"
content = f"{message}\n\n{progress_text}"
# Update last_update_time when creating card
self.last_update_time = time.time()
return self.formatter.format_info(
title="正在执行...",
content=content,
)
def reset_timer(self) -> None:
"""Reset the update timer (useful for new operations)."""
self.last_update_time = 0.0
@@ -0,0 +1,249 @@
"""
Per-user context management for gsd-openclaw.
Provides UserContextManager for tracking user project context across chat sessions,
supporting the /gsd:use command requirement.
"""
from __future__ import annotations
import dataclasses
import json
import os
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class UserContext:
"""Represents the persistent context for a user.
Tracks the user's current project, recent projects, and preferences
for persistence across chat sessions.
Attributes:
schema_version: Version of the context schema (default "1.0")
user_id: Unique identifier for the user
current_project: The user's currently active project
recent_projects: List of recently used projects (max 5)
last_active_at: ISO format timestamp of last activity
preferences: Optional user preferences dictionary
"""
schema_version: str = "1.0"
user_id: str = ""
current_project: str = ""
recent_projects: List[str] = field(default_factory=list)
last_active_at: str = ""
preferences: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert UserContext to a dictionary.
Returns:
Dictionary representation of the user context.
"""
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> UserContext:
"""Create UserContext from a dictionary.
Unknown fields in the dictionary are ignored.
Args:
data: Dictionary containing user context data.
Returns:
UserContext instance.
"""
# Get field names from the dataclass
field_names = {f.name for f in dataclasses.fields(cls)}
# Filter to only known fields
filtered_data = {k: v for k, v in data.items() if k in field_names}
return cls(**filtered_data)
class UserContextManager:
"""Manager class for per-user context persistence.
Provides a clean interface for loading and saving user context,
with atomic writes and multi-user isolation.
Attributes:
USERS_ROOT: Default root directory for all user contexts.
user_id: Unique identifier for the user.
users_root: Root directory for user contexts (configurable for testing).
context_path: Full path to the user's context.json file.
"""
USERS_ROOT = Path.home() / "gsd-workspaces" / ".users"
def __init__(self, user_id: str, users_root: Optional[Path] = None):
"""Initialize UserContextManager for a user.
Args:
user_id: Unique identifier for the user.
users_root: Optional custom root for user contexts (useful for testing).
Defaults to ~/gsd-workspaces/.users.
"""
self.user_id = user_id
self.users_root = Path(users_root) if users_root else self.USERS_ROOT
self._context_path: Optional[Path] = None
@property
def context_path(self) -> Path:
"""Get the path to the user's context file.
Returns:
Path to context.json file.
"""
return self.users_root / self.user_id / "context.json"
def load(self) -> Optional[UserContext]:
"""Load user context from file with backup fallback.
Returns:
UserContext if loaded successfully, None if no context file exists.
"""
context_path = self.context_path
backup_path = context_path.with_suffix('.json.bak')
def _load_from_path(path: Path) -> UserContext:
"""Internal: Load context from a specific path."""
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
return UserContext.from_dict(data)
# Try main file first, then backup
paths_to_try = []
if context_path.exists():
paths_to_try.append(context_path)
if backup_path.exists() and backup_path != context_path:
paths_to_try.append(backup_path)
if not paths_to_try:
return None
last_error = None
for path in paths_to_try:
try:
return _load_from_path(path)
except json.JSONDecodeError as e:
last_error = e
continue # Try next path (backup)
except (KeyError, TypeError) as e:
last_error = e
continue # Try next path (backup)
# If we get here, all paths failed - return None instead of raising
return None
def save(self, context: UserContext) -> None:
"""Save user context to file atomically with backup rotation.
This function implements crash-safe context persistence:
1. Creates parent directories if they don't exist
2. Rotates existing context file to backup (context.json -> context.json.bak)
3. Writes to a temporary file first
4. Atomically renames temp file to final path
Args:
context: The UserContext to save.
Raises:
OSError: If file operations fail (permissions, disk full, etc.)
"""
context_path = self.context_path
backup_path = context_path.with_suffix('.json.bak')
# 1. Create parent directories if missing
context_path.parent.mkdir(parents=True, exist_ok=True)
# 2. Rotate backup: if context.json exists, move it to context.json.bak
if context_path.exists():
# Remove old backup if exists
if backup_path.exists():
backup_path.unlink()
# Rename current context to backup
context_path.rename(backup_path)
# 3. Write to temp file in same directory (for atomic rename)
fd, temp_path = tempfile.mkstemp(dir=context_path.parent, suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(context.to_dict(), f, indent=2, ensure_ascii=False)
# 4. Atomic replace
os.replace(temp_path, context_path)
except Exception:
# Clean up temp file on failure
if os.path.exists(temp_path):
os.unlink(temp_path)
raise
def get_current_project(self) -> Optional[str]:
"""Get the user's current project.
Returns:
Current project name or None if no context exists.
"""
context = self.load()
if context is None or not context.current_project:
return None
return context.current_project
def set_current_project(self, project: str) -> None:
"""Set the user's current project.
Creates default context if none exists. Updates current_project,
adds to recent_projects (max 5), and updates last_active_at.
Args:
project: Name of the project to set as current.
"""
context = self.load()
if context is None:
context = self.create_default_context()
# Update recent projects
recent = context.recent_projects.copy()
# Remove project if already in list (will be added at front)
if project in recent:
recent.remove(project)
# Add to front of list
recent.insert(0, project)
# Keep only last 5
context.recent_projects = recent[:5]
# Update current project and timestamp
context.current_project = project
context.last_active_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
self.save(context)
def get_recent_projects(self) -> List[str]:
"""Get the user's recent projects list.
Returns:
List of recent project names (may be empty).
"""
context = self.load()
if context is None:
return []
return context.recent_projects
def create_default_context(self) -> UserContext:
"""Create a new default context for the user.
Returns:
New UserContext with default values.
"""
return UserContext(
schema_version="1.0",
user_id=self.user_id,
current_project="",
recent_projects=[],
last_active_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
preferences={}
)