Perfect version.

This commit is contained in:
retoor 2025-11-04 07:52:36 +01:00
parent 685766ef86
commit 2b701cb5cd
14 changed files with 1956 additions and 7 deletions

View File

@ -5,6 +5,7 @@ from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
from .agent_roles import AgentRole, get_agent_role
from .agent_communication import AgentMessage, AgentCommunicationBus, MessageType
from ..memory.knowledge_store import KnowledgeStore
@dataclass
class AgentInstance:
@ -36,6 +37,7 @@ class AgentManager:
self.db_path = db_path
self.api_caller = api_caller
self.communication_bus = AgentCommunicationBus(db_path)
self.knowledge_store = KnowledgeStore(db_path)
self.active_agents: Dict[str, AgentInstance] = {}
self.session_id = str(uuid.uuid4())[:16]
@ -70,9 +72,16 @@ class AgentManager:
agent.context.update(context)
agent.add_message('user', task)
knowledge_matches = self.knowledge_store.search_entries(task, top_k=3)
agent.task_count += 1
messages = agent.get_messages_for_api()
if knowledge_matches:
knowledge_content = "Knowledge base matches based on your query:\\n"
for i, entry in enumerate(knowledge_matches, 1):
shortened_content = entry.content[:2000]
knowledge_content += f"{i}. {shortened_content}\\n\\n"
messages.insert(-1, {'role': 'user', 'content': knowledge_content})
try:
response = self.api_caller(

View File

@ -1,4 +1,5 @@
import json
import time
from pr.ui import Colors
from pr.tools import read_file
from pr.tools.base import get_tools_definition
@ -143,6 +144,9 @@ def handle_command(assistant, command):
elif cmd == '/stats':
show_system_stats(assistant)
elif cmd.startswith('/bg'):
handle_background_command(assistant, command)
else:
return None
@ -389,3 +393,141 @@ def show_system_stats(assistant):
print(f" API cache entries: {cache_stats['api_cache']['valid_entries']}")
if 'tool_cache' in cache_stats:
print(f" Tool cache entries: {cache_stats['tool_cache']['valid_entries']}")
def handle_background_command(assistant, command):
"""Handle background multiplexer commands."""
parts = command.strip().split(maxsplit=2)
if len(parts) < 2:
print(f"{Colors.RED}Usage: /bg <subcommand> [args]{Colors.RESET}")
print(f"{Colors.GRAY}Available subcommands: start, list, status, output, input, kill, events{Colors.RESET}")
return
subcmd = parts[1].lower()
try:
if subcmd == 'start' and len(parts) >= 3:
session_name = f"bg_{len(parts[2].split())}_{int(time.time())}"
start_background_session(assistant, session_name, parts[2])
elif subcmd == 'list':
list_background_sessions(assistant)
elif subcmd == 'status' and len(parts) >= 3:
show_session_status(assistant, parts[2])
elif subcmd == 'output' and len(parts) >= 3:
show_session_output(assistant, parts[2])
elif subcmd == 'input' and len(parts) >= 4:
send_session_input(assistant, parts[2], parts[3])
elif subcmd == 'kill' and len(parts) >= 3:
kill_background_session(assistant, parts[2])
elif subcmd == 'events':
show_background_events(assistant)
else:
print(f"{Colors.RED}Unknown background command: {subcmd}{Colors.RESET}")
print(f"{Colors.GRAY}Available: start, list, status, output, input, kill, events{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error executing background command: {e}{Colors.RESET}")
def start_background_session(assistant, session_name, command):
"""Start a command in background."""
try:
from pr.multiplexer import start_background_process
result = start_background_process(session_name, command)
if result['status'] == 'success':
print(f"{Colors.GREEN}Started background session '{session_name}' with PID {result['pid']}{Colors.RESET}")
else:
print(f"{Colors.RED}Failed to start background session: {result.get('error', 'Unknown error')}{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error starting background session: {e}{Colors.RESET}")
def list_background_sessions(assistant):
"""List all background sessions."""
try:
from pr.ui.display import display_multiplexer_status
from pr.multiplexer import get_all_sessions
sessions = get_all_sessions()
display_multiplexer_status(sessions)
except Exception as e:
print(f"{Colors.RED}Error listing background sessions: {e}{Colors.RESET}")
def show_session_status(assistant, session_name):
"""Show status of a specific session."""
try:
from pr.multiplexer import get_session_info
info = get_session_info(session_name)
if info:
print(f"{Colors.BOLD}Session '{session_name}':{Colors.RESET}")
print(f" Status: {info.get('status', 'unknown')}")
print(f" PID: {info.get('pid', 'N/A')}")
print(f" Command: {info.get('command', 'N/A')}")
if 'start_time' in info:
import time
elapsed = time.time() - info['start_time']
print(f" Running for: {elapsed:.1f}s")
else:
print(f"{Colors.YELLOW}Session '{session_name}' not found{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error getting session status: {e}{Colors.RESET}")
def show_session_output(assistant, session_name):
"""Show output of a specific session."""
try:
from pr.multiplexer import get_session_output
output = get_session_output(session_name, lines=50)
if output:
print(f"{Colors.BOLD}Recent output from '{session_name}':{Colors.RESET}")
print(f"{Colors.GRAY}{'' * 60}{Colors.RESET}")
for line in output:
print(line)
else:
print(f"{Colors.YELLOW}No output available for session '{session_name}'{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error getting session output: {e}{Colors.RESET}")
def send_session_input(assistant, session_name, input_text):
"""Send input to a background session."""
try:
from pr.multiplexer import send_input_to_session
result = send_input_to_session(session_name, input_text)
if result['status'] == 'success':
print(f"{Colors.GREEN}Input sent to session '{session_name}'{Colors.RESET}")
else:
print(f"{Colors.RED}Failed to send input: {result.get('error', 'Unknown error')}{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error sending input: {e}{Colors.RESET}")
def kill_background_session(assistant, session_name):
"""Kill a background session."""
try:
from pr.multiplexer import kill_session
result = kill_session(session_name)
if result['status'] == 'success':
print(f"{Colors.GREEN}Session '{session_name}' terminated{Colors.RESET}")
else:
print(f"{Colors.RED}Failed to kill session: {result.get('error', 'Unknown error')}{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error killing session: {e}{Colors.RESET}")
def show_background_events(assistant):
"""Show recent background events."""
try:
from pr.core.background_monitor import get_global_monitor
monitor = get_global_monitor()
events = monitor.get_pending_events()
if events:
print(f"{Colors.BOLD}Recent Background Events:{Colors.RESET}")
print(f"{Colors.GRAY}{'' * 60}{Colors.RESET}")
for event in events[-10:]: # Show last 10 events
from pr.ui.display import display_background_event
display_background_event(event)
else:
print(f"{Colors.GRAY}No recent background events{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error getting background events: {e}{Colors.RESET}")

View File

@ -0,0 +1,224 @@
from pr.tools.interactive_control import (
list_active_sessions, get_session_status, read_session_output,
send_input_to_session, close_interactive_session
)
from pr.multiplexer import get_multiplexer
from pr.tools.prompt_detection import get_global_detector
from pr.ui import Colors
def show_sessions(args=None):
"""Show all active multiplexer sessions."""
sessions = list_active_sessions()
if not sessions:
print(f"{Colors.YELLOW}No active sessions.{Colors.RESET}")
return
print(f"{Colors.BOLD}Active Sessions:{Colors.RESET}")
print("-" * 80)
for session_name, session_data in sessions.items():
metadata = session_data['metadata']
output_summary = session_data['output_summary']
status = get_session_status(session_name)
is_active = status.get('is_active', False) if status else False
status_color = Colors.GREEN if is_active else Colors.RED
print(f"{Colors.CYAN}{session_name}{Colors.RESET}: {status_color}{metadata.get('process_type', 'unknown')}{Colors.RESET}")
if status and 'pid' in status:
print(f" PID: {status['pid']}")
print(f" Age: {metadata.get('start_time', 0):.1f}s")
print(f" Output: {output_summary['stdout_lines']} stdout, {output_summary['stderr_lines']} stderr lines")
print(f" Interactions: {metadata.get('interaction_count', 0)}")
print(f" State: {metadata.get('state', 'unknown')}")
print()
def attach_session(args):
"""Attach to a session (show its output and allow interaction)."""
if not args or len(args) < 1:
print(f"{Colors.RED}Usage: attach_session <session_name>{Colors.RESET}")
return
session_name = args[0]
status = get_session_status(session_name)
if not status:
print(f"{Colors.RED}Session '{session_name}' not found.{Colors.RESET}")
return
print(f"{Colors.BOLD}Attaching to session: {session_name}{Colors.RESET}")
print(f"Process type: {status.get('metadata', {}).get('process_type', 'unknown')}")
print("-" * 50)
# Show recent output
try:
output = read_session_output(session_name, lines=20)
if output['stdout']:
print(f"{Colors.GRAY}Recent stdout:{Colors.RESET}")
for line in output['stdout'].split('\n'):
if line.strip():
print(f" {line}")
if output['stderr']:
print(f"{Colors.YELLOW}Recent stderr:{Colors.RESET}")
for line in output['stderr'].split('\n'):
if line.strip():
print(f" {line}")
except Exception as e:
print(f"{Colors.RED}Error reading output: {e}{Colors.RESET}")
print(f"\n{Colors.CYAN}Session is {'active' if status.get('is_active') else 'inactive'}{Colors.RESET}")
def detach_session(args):
"""Detach from a session (stop showing its output but keep it running)."""
if not args or len(args) < 1:
print(f"{Colors.RED}Usage: detach_session <session_name>{Colors.RESET}")
return
session_name = args[0]
mux = get_multiplexer(session_name)
if not mux:
print(f"{Colors.RED}Session '{session_name}' not found.{Colors.RESET}")
return
# In this implementation, detaching just means we stop displaying output
# The session continues to run in the background
mux.show_output = False
print(f"{Colors.GREEN}Detached from session '{session_name}'. It continues running in background.{Colors.RESET}")
def kill_session(args):
"""Kill a session forcefully."""
if not args or len(args) < 1:
print(f"{Colors.RED}Usage: kill_session <session_name>{Colors.RESET}")
return
session_name = args[0]
try:
close_interactive_session(session_name)
print(f"{Colors.GREEN}Session '{session_name}' terminated.{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error terminating session '{session_name}': {e}{Colors.RESET}")
def send_command(args):
"""Send a command to a session."""
if not args or len(args) < 2:
print(f"{Colors.RED}Usage: send_command <session_name> <command>{Colors.RESET}")
return
session_name = args[0]
command = ' '.join(args[1:])
try:
send_input_to_session(session_name, command)
print(f"{Colors.GREEN}Sent command to '{session_name}': {command}{Colors.RESET}")
except Exception as e:
print(f"{Colors.RED}Error sending command to '{session_name}': {e}{Colors.RESET}")
def show_session_log(args):
"""Show the full log/output of a session."""
if not args or len(args) < 1:
print(f"{Colors.RED}Usage: show_session_log <session_name>{Colors.RESET}")
return
session_name = args[0]
try:
output = read_session_output(session_name) # Get all output
print(f"{Colors.BOLD}Full log for session: {session_name}{Colors.RESET}")
print("=" * 80)
if output['stdout']:
print(f"{Colors.GRAY}STDOUT:{Colors.RESET}")
print(output['stdout'])
print()
if output['stderr']:
print(f"{Colors.YELLOW}STDERR:{Colors.RESET}")
print(output['stderr'])
print()
except Exception as e:
print(f"{Colors.RED}Error reading log for '{session_name}': {e}{Colors.RESET}")
def show_session_status(args):
"""Show detailed status of a session."""
if not args or len(args) < 1:
print(f"{Colors.RED}Usage: show_session_status <session_name>{Colors.RESET}")
return
session_name = args[0]
status = get_session_status(session_name)
if not status:
print(f"{Colors.RED}Session '{session_name}' not found.{Colors.RESET}")
return
print(f"{Colors.BOLD}Status for session: {session_name}{Colors.RESET}")
print("-" * 50)
metadata = status.get('metadata', {})
print(f"Process type: {metadata.get('process_type', 'unknown')}")
print(f"Active: {status.get('is_active', False)}")
if 'pid' in status:
print(f"PID: {status['pid']}")
print(f"Start time: {metadata.get('start_time', 0):.1f}")
print(f"Last activity: {metadata.get('last_activity', 0):.1f}")
print(f"Interaction count: {metadata.get('interaction_count', 0)}")
print(f"State: {metadata.get('state', 'unknown')}")
output_summary = status.get('output_summary', {})
print(f"Output lines: {output_summary.get('stdout_lines', 0)} stdout, {output_summary.get('stderr_lines', 0)} stderr")
# Show prompt detection info
detector = get_global_detector()
session_info = detector.get_session_info(session_name)
if session_info:
print(f"Current state: {session_info['current_state']}")
print(f"Is waiting for input: {session_info['is_waiting']}")
def list_waiting_sessions(args=None):
"""List sessions that appear to be waiting for input."""
sessions = list_active_sessions()
detector = get_global_detector()
waiting_sessions = []
for session_name in sessions:
if detector.is_waiting_for_input(session_name):
waiting_sessions.append(session_name)
if not waiting_sessions:
print(f"{Colors.GREEN}No sessions are currently waiting for input.{Colors.RESET}")
return
print(f"{Colors.BOLD}Sessions waiting for input:{Colors.RESET}")
for session_name in waiting_sessions:
status = get_session_status(session_name)
if status:
process_type = status.get('metadata', {}).get('process_type', 'unknown')
print(f" {Colors.CYAN}{session_name}{Colors.RESET} ({process_type})")
# Show suggestions
session_info = detector.get_session_info(session_name)
if session_info:
suggestions = detector.get_response_suggestions({}, process_type)
if suggestions:
print(f" Suggested inputs: {', '.join(suggestions[:3])}") # Show first 3
print()
# Command registry for the multiplexer commands
MULTIPLEXER_COMMANDS = {
'show_sessions': show_sessions,
'attach_session': attach_session,
'detach_session': detach_session,
'kill_session': kill_session,
'send_command': send_command,
'show_session_log': show_session_log,
'show_session_status': show_session_status,
'list_waiting_sessions': list_waiting_sessions,
}

View File

@ -1,8 +1,8 @@
import os
DEFAULT_MODEL = "x-ai/grok-code-fast-1"
DEFAULT_API_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL_LIST_URL = "https://openrouter.ai/api/v1/models"
DEFAULT_API_URL = "https://static.molodetz.nl/rp.cgi/api/v1/chat/completions"
MODEL_LIST_URL = "https://static.molodetz.nl/rp.cgi/api/v1/models"
DB_PATH = os.path.expanduser("~/.assistant_db.sqlite")
LOG_FILE = os.path.expanduser("~/.assistant_error.log")
@ -59,3 +59,32 @@ ADVANCED_CONTEXT_ENABLED = True
CONTEXT_RELEVANCE_THRESHOLD = 0.3
ADAPTIVE_CONTEXT_MIN = 10
ADAPTIVE_CONTEXT_MAX = 50
# Background monitoring and multiplexer configuration
BACKGROUND_MONITOR_ENABLED = True
BACKGROUND_MONITOR_INTERVAL = 5.0 # seconds
AUTONOMOUS_INTERACTION_INTERVAL = 10.0 # seconds
MULTIPLEXER_BUFFER_SIZE = 1000 # lines
MULTIPLEXER_OUTPUT_TIMEOUT = 30 # seconds
MAX_CONCURRENT_SESSIONS = 10
# Process-specific timeouts (seconds)
PROCESS_TIMEOUTS = {
'default': 300, # 5 minutes
'apt': 600, # 10 minutes
'ssh': 60, # 1 minute
'vim': 3600, # 1 hour
'git': 300, # 5 minutes
'npm': 600, # 10 minutes
'pip': 300, # 5 minutes
}
# Activity thresholds for LLM notification
HIGH_OUTPUT_THRESHOLD = 50 # lines
INACTIVE_THRESHOLD = 300 # seconds
SESSION_NOTIFY_INTERVAL = 60 # seconds
# Autonomous behavior flags
ENABLE_AUTONOMOUS_SESSIONS = True
ENABLE_BACKGROUND_UPDATES = True
ENABLE_TIMEOUT_DETECTION = True

View File

@ -20,10 +20,16 @@ from pr.tools import (
search_replace,close_editor,create_diff,apply_patch,
tail_process, kill_process
)
from pr.tools.interactive_control import (
start_interactive_session, send_input_to_session, read_session_output,
list_active_sessions, close_interactive_session
)
from pr.tools.patch import display_file_diff
from pr.tools.filesystem import display_edit_summary, display_edit_timeline, clear_edit_tracker
from pr.tools.base import get_tools_definition
from pr.commands import handle_command
from pr.core.background_monitor import start_global_monitor, stop_global_monitor, get_global_monitor
from pr.core.autonomous_interactions import start_global_autonomous, stop_global_autonomous, get_global_autonomous
logger = logging.getLogger('pr')
logger.setLevel(logging.DEBUG)
@ -57,6 +63,7 @@ class Assistant:
self.db_conn = None
self.autonomous_mode = False
self.autonomous_iterations = 0
self.background_monitoring = False
self.init_database()
self.messages.append(init_system_message(args))
@ -69,6 +76,18 @@ class Assistant:
logger.warning(f"Could not initialize enhanced features: {e}")
self.enhanced = None
# Initialize background monitoring components
try:
start_global_monitor()
autonomous = get_global_autonomous()
autonomous.start(llm_callback=self._handle_background_updates)
self.background_monitoring = True
if self.debug:
logger.debug("Background monitoring initialized")
except Exception as e:
logger.warning(f"Could not initialize background monitoring: {e}")
self.background_monitoring = False
def init_database(self):
try:
logger.debug(f"Initializing database at {DB_PATH}")
@ -89,6 +108,74 @@ class Assistant:
logger.error(f"Database initialization error: {e}")
self.db_conn = None
def _handle_background_updates(self, updates):
"""Handle background session updates by injecting them into the conversation."""
if not updates or not updates.get('sessions'):
return
# Format the update as a system message
update_message = self._format_background_update_message(updates)
# Inject into current conversation if we're in an active session
if self.messages and len(self.messages) > 0:
self.messages.append({
"role": "system",
"content": f"Background session updates: {update_message}"
})
if self.verbose:
print(f"{Colors.CYAN}Background update: {update_message}{Colors.RESET}")
def _format_background_update_message(self, updates):
"""Format background updates for LLM consumption."""
session_summaries = []
for session_name, session_info in updates.get('sessions', {}).items():
summary = session_info.get('summary', f'Session {session_name}')
session_summaries.append(f"{session_name}: {summary}")
if session_summaries:
return "Active background sessions: " + "; ".join(session_summaries)
else:
return "No active background sessions requiring attention."
def _check_background_updates(self):
"""Check for pending background updates and display them."""
if not self.background_monitoring:
return
try:
monitor = get_global_monitor()
events = monitor.get_pending_events()
if events:
print(f"\n{Colors.CYAN}Background Events:{Colors.RESET}")
for event in events:
event_type = event.get('type', 'unknown')
session_name = event.get('session_name', 'unknown')
if event_type == 'session_started':
print(f" {Colors.GREEN}{Colors.RESET} Session '{session_name}' started")
elif event_type == 'session_ended':
print(f" {Colors.YELLOW}{Colors.RESET} Session '{session_name}' ended")
elif event_type == 'output_received':
lines = len(event.get('new_output', {}).get('stdout', []))
print(f" {Colors.BLUE}📝{Colors.RESET} Session '{session_name}' produced {lines} lines of output")
elif event_type == 'possible_input_needed':
print(f" {Colors.RED}{Colors.RESET} Session '{session_name}' may need input")
elif event_type == 'high_output_volume':
total = event.get('total_lines', 0)
print(f" {Colors.YELLOW}📊{Colors.RESET} Session '{session_name}' has high output volume ({total} lines)")
elif event_type == 'inactive_session':
inactive_time = event.get('inactive_seconds', 0)
print(f" {Colors.GRAY}{Colors.RESET} Session '{session_name}' inactive for {inactive_time:.0f}s")
print() # Add blank line after events
except Exception as e:
if self.debug:
print(f"{Colors.RED}Error checking background updates: {e}{Colors.RESET}")
def execute_tool_calls(self, tool_calls):
results = []
@ -107,7 +194,10 @@ class Assistant:
'run_command': lambda **kw: run_command(**kw),
'tail_process': lambda **kw: tail_process(**kw),
'kill_process': lambda **kw: kill_process(**kw),
'run_command_interactive': lambda **kw: run_command_interactive(**kw),
'start_interactive_session': lambda **kw: start_interactive_session(**kw),
'send_input_to_session': lambda **kw: send_input_to_session(**kw),
'read_session_output': lambda **kw: read_session_output(**kw),
'close_interactive_session': lambda **kw: close_interactive_session(**kw),
'read_file': lambda **kw: read_file(**kw, db_conn=self.db_conn),
'write_file': lambda **kw: write_file(**kw, db_conn=self.db_conn),
'list_directory': lambda **kw: list_directory(**kw),
@ -133,6 +223,11 @@ class Assistant:
'display_edit_summary': lambda **kw: display_edit_summary(),
'display_edit_timeline': lambda **kw: display_edit_timeline(**kw),
'clear_edit_tracker': lambda **kw: clear_edit_tracker(),
'start_interactive_session': lambda **kw: start_interactive_session(**kw),
'send_input_to_session': lambda **kw: send_input_to_session(**kw),
'read_session_output': lambda **kw: read_session_output(**kw),
'list_active_sessions': lambda **kw: list_active_sessions(**kw),
'close_interactive_session': lambda **kw: close_interactive_session(**kw),
'create_agent': lambda **kw: create_agent(**kw),
'list_agents': lambda **kw: list_agents(**kw),
'execute_agent_task': lambda **kw: execute_agent_task(**kw),
@ -264,7 +359,24 @@ class Assistant:
while True:
try:
user_input = input(f"{Colors.BLUE}You>{Colors.RESET} ").strip()
# Check for background updates before prompting user
if self.background_monitoring:
self._check_background_updates()
# Create prompt with background status
prompt = f"{Colors.BLUE}You"
if self.background_monitoring:
try:
from pr.multiplexer import get_all_sessions
sessions = get_all_sessions()
active_count = sum(1 for s in sessions.values() if s.get('status') == 'running')
if active_count > 0:
prompt += f"[{active_count}bg]"
except:
pass
prompt += f">{Colors.RESET} "
user_input = input(prompt).strip()
if not user_input:
continue
@ -302,6 +414,14 @@ class Assistant:
except Exception as e:
logger.error(f"Error cleaning up enhanced features: {e}")
# Stop background monitoring
if self.background_monitoring:
try:
stop_global_autonomous()
stop_global_monitor()
except Exception as e:
logger.error(f"Error stopping background monitoring: {e}")
try:
from pr.multiplexer import cleanup_all_multiplexers
cleanup_all_multiplexers()

View File

@ -0,0 +1,189 @@
import time
import threading
from pr.core.background_monitor import get_global_monitor
from pr.tools.interactive_control import list_active_sessions, get_session_status, read_session_output
class AutonomousInteractions:
def __init__(self, interaction_interval=10.0):
self.interaction_interval = interaction_interval
self.active = False
self.interaction_thread = None
self.llm_callback = None
self.last_check_time = 0
def start(self, llm_callback=None):
"""Start the autonomous interaction loop."""
self.llm_callback = llm_callback
if self.interaction_thread is None:
self.active = True
self.interaction_thread = threading.Thread(target=self._interaction_loop, daemon=True)
self.interaction_thread.start()
def stop(self):
"""Stop the autonomous interaction loop."""
self.active = False
if self.interaction_thread:
self.interaction_thread.join(timeout=2)
def _interaction_loop(self):
"""Main loop for autonomous interactions with background processes."""
while self.active:
try:
current_time = time.time()
if current_time - self.last_check_time >= self.interaction_interval:
self._check_sessions_and_notify()
self.last_check_time = current_time
time.sleep(1) # Check every second for shutdown
except Exception as e:
print(f"Error in autonomous interaction loop: {e}")
time.sleep(self.interaction_interval)
def _check_sessions_and_notify(self):
"""Check active sessions and determine if LLM notification is needed."""
try:
sessions = list_active_sessions()
if not sessions:
return # No active sessions
sessions_needing_attention = self._identify_sessions_needing_attention(sessions)
if sessions_needing_attention and self.llm_callback:
# Format session updates for LLM
updates = self._format_session_updates(sessions_needing_attention)
self.llm_callback(updates)
except Exception as e:
print(f"Error checking sessions: {e}")
def _identify_sessions_needing_attention(self, sessions):
"""Identify which sessions need LLM attention based on various criteria."""
needing_attention = []
for session_name, session_data in sessions.items():
metadata = session_data['metadata']
output_summary = session_data['output_summary']
# Criteria for needing attention:
# 1. Recent output activity
time_since_activity = time.time() - metadata.get('last_activity', 0)
if time_since_activity < 30: # Activity in last 30 seconds
needing_attention.append(session_name)
continue
# 2. High output volume (potential completion or error)
total_lines = output_summary['stdout_lines'] + output_summary['stderr_lines']
if total_lines > 50: # Arbitrary threshold
needing_attention.append(session_name)
continue
# 3. Long-running sessions that might need intervention
session_age = time.time() - metadata.get('start_time', 0)
if session_age > 300 and time_since_activity > 60: # 5+ minutes old, inactive for 1+ minute
needing_attention.append(session_name)
continue
# 4. Sessions that appear to be waiting for input
if self._session_looks_stuck(session_name, session_data):
needing_attention.append(session_name)
continue
return needing_attention
def _session_looks_stuck(self, session_name, session_data):
"""Determine if a session appears to be stuck waiting for input."""
metadata = session_data['metadata']
# Check if process is still running
status = get_session_status(session_name)
if not status or not status.get('is_active', False):
return False
time_since_activity = time.time() - metadata.get('last_activity', 0)
interaction_count = metadata.get('interaction_count', 0)
# If running for a while but no interactions, might be waiting
session_age = time.time() - metadata.get('start_time', 0)
if session_age > 60 and interaction_count == 0 and time_since_activity > 30:
return True
# If had interactions but been quiet for a while
if interaction_count > 0 and time_since_activity > 120: # 2 minutes
return True
return False
def _format_session_updates(self, session_names):
"""Format session information for LLM consumption."""
updates = {
'type': 'background_session_updates',
'timestamp': time.time(),
'sessions': {}
}
for session_name in session_names:
status = get_session_status(session_name)
if status:
# Get recent output (last 20 lines)
try:
recent_output = read_session_output(session_name, lines=20)
except:
recent_output = {'stdout': '', 'stderr': ''}
updates['sessions'][session_name] = {
'status': status,
'recent_output': recent_output,
'summary': self._create_session_summary(status, recent_output)
}
return updates
def _create_session_summary(self, status, recent_output):
"""Create a human-readable summary of session status."""
summary_parts = []
process_type = status.get('metadata', {}).get('process_type', 'unknown')
summary_parts.append(f"Type: {process_type}")
is_active = status.get('is_active', False)
summary_parts.append(f"Status: {'Active' if is_active else 'Inactive'}")
if is_active and 'pid' in status:
summary_parts.append(f"PID: {status['pid']}")
age = time.time() - status.get('metadata', {}).get('start_time', 0)
summary_parts.append(f"Age: {age:.1f}s")
output_lines = len(recent_output.get('stdout', '').split('\n')) + len(recent_output.get('stderr', '').split('\n'))
summary_parts.append(f"Recent output: {output_lines} lines")
interaction_count = status.get('metadata', {}).get('interaction_count', 0)
summary_parts.append(f"Interactions: {interaction_count}")
return " | ".join(summary_parts)
# Global autonomous interactions instance
_global_autonomous = None
def get_global_autonomous():
"""Get the global autonomous interactions instance."""
global _global_autonomous
return _global_autonomous
def start_global_autonomous(llm_callback=None):
"""Start global autonomous interactions."""
global _global_autonomous
if _global_autonomous is None:
_global_autonomous = AutonomousInteractions()
_global_autonomous.start(llm_callback)
return _global_autonomous
def stop_global_autonomous():
"""Stop global autonomous interactions."""
global _global_autonomous
if _global_autonomous:
_global_autonomous.stop()
_global_autonomous = None

View File

@ -0,0 +1,236 @@
import threading
import time
import queue
from pr.multiplexer import get_all_multiplexer_states, get_multiplexer
from pr.tools.interactive_control import get_session_status
class BackgroundMonitor:
def __init__(self, check_interval=5.0):
self.check_interval = check_interval
self.active = False
self.monitor_thread = None
self.event_queue = queue.Queue()
self.last_states = {}
self.event_callbacks = []
def start(self):
"""Start the background monitoring thread."""
if self.monitor_thread is None:
self.active = True
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
def stop(self):
"""Stop the background monitoring thread."""
self.active = False
if self.monitor_thread:
self.monitor_thread.join(timeout=2)
def add_event_callback(self, callback):
"""Add a callback function to be called when events are detected."""
self.event_callbacks.append(callback)
def remove_event_callback(self, callback):
"""Remove an event callback."""
if callback in self.event_callbacks:
self.event_callbacks.remove(callback)
def get_pending_events(self):
"""Get all pending events from the queue."""
events = []
while not self.event_queue.empty():
try:
events.append(self.event_queue.get_nowait())
except queue.Empty:
break
return events
def _monitor_loop(self):
"""Main monitoring loop that checks for multiplexer activity."""
while self.active:
try:
current_states = get_all_multiplexer_states()
# Detect changes and events
events = self._detect_events(self.last_states, current_states)
# Queue events for processing
for event in events:
self.event_queue.put(event)
# Also call callbacks immediately
for callback in self.event_callbacks:
try:
callback(event)
except Exception as e:
print(f"Error in event callback: {e}")
self.last_states = current_states.copy()
time.sleep(self.check_interval)
except Exception as e:
print(f"Error in background monitor loop: {e}")
time.sleep(self.check_interval)
def _detect_events(self, old_states, new_states):
"""Detect significant events in multiplexer states."""
events = []
# Check for new sessions
for session_name in new_states:
if session_name not in old_states:
events.append({
'type': 'session_started',
'session_name': session_name,
'metadata': new_states[session_name]['metadata']
})
# Check for ended sessions
for session_name in old_states:
if session_name not in new_states:
events.append({
'type': 'session_ended',
'session_name': session_name
})
# Check for activity in existing sessions
for session_name, new_state in new_states.items():
if session_name in old_states:
old_state = old_states[session_name]
# Check for output changes
old_stdout_lines = old_state['output_summary']['stdout_lines']
new_stdout_lines = new_state['output_summary']['stdout_lines']
old_stderr_lines = old_state['output_summary']['stderr_lines']
new_stderr_lines = new_state['output_summary']['stderr_lines']
if new_stdout_lines > old_stdout_lines or new_stderr_lines > old_stderr_lines:
# Get the new output
mux = get_multiplexer(session_name)
if mux:
all_output = mux.get_all_output()
new_output = {
'stdout': all_output['stdout'].split('\n')[old_stdout_lines:],
'stderr': all_output['stderr'].split('\n')[old_stderr_lines:]
}
events.append({
'type': 'output_received',
'session_name': session_name,
'new_output': new_output,
'total_lines': {
'stdout': new_stdout_lines,
'stderr': new_stderr_lines
}
})
# Check for state changes
old_metadata = old_state['metadata']
new_metadata = new_state['metadata']
if old_metadata.get('state') != new_metadata.get('state'):
events.append({
'type': 'state_changed',
'session_name': session_name,
'old_state': old_metadata.get('state'),
'new_state': new_metadata.get('state')
})
# Check for process type identification
if (old_metadata.get('process_type') == 'unknown' and
new_metadata.get('process_type') != 'unknown'):
events.append({
'type': 'process_identified',
'session_name': session_name,
'process_type': new_metadata.get('process_type')
})
# Check for sessions needing attention (based on heuristics)
for session_name, state in new_states.items():
metadata = state['metadata']
output_summary = state['output_summary']
# Heuristic: High output volume might indicate completion or error
total_lines = output_summary['stdout_lines'] + output_summary['stderr_lines']
if total_lines > 100: # Arbitrary threshold
events.append({
'type': 'high_output_volume',
'session_name': session_name,
'total_lines': total_lines
})
# Heuristic: Long-running session without recent activity
time_since_activity = time.time() - metadata.get('last_activity', 0)
if time_since_activity > 300: # 5 minutes
events.append({
'type': 'inactive_session',
'session_name': session_name,
'inactive_seconds': time_since_activity
})
# Heuristic: Sessions that might be waiting for input
# This would be enhanced with prompt detection in later phases
if self._might_be_waiting_for_input(session_name, state):
events.append({
'type': 'possible_input_needed',
'session_name': session_name
})
return events
def _might_be_waiting_for_input(self, session_name, state):
"""Heuristic to detect if a session might be waiting for input."""
metadata = state['metadata']
process_type = metadata.get('process_type', 'unknown')
# Simple heuristics based on process type and recent activity
time_since_activity = time.time() - metadata.get('last_activity', 0)
# If it's been more than 10 seconds since last activity, might be waiting
if time_since_activity > 10:
return True
return False
# Global monitor instance
_global_monitor = None
def get_global_monitor():
"""Get the global background monitor instance."""
global _global_monitor
if _global_monitor is None:
_global_monitor = BackgroundMonitor()
return _global_monitor
def start_global_monitor():
"""Start the global background monitor."""
monitor = get_global_monitor()
monitor.start()
def stop_global_monitor():
"""Stop the global background monitor."""
global _global_monitor
if _global_monitor:
_global_monitor.stop()
# Global monitor instance
_global_monitor = None
def start_global_monitor():
"""Start the global background monitor."""
global _global_monitor
if _global_monitor is None:
_global_monitor = BackgroundMonitor()
_global_monitor.start()
return _global_monitor
def stop_global_monitor():
"""Stop the global background monitor."""
global _global_monitor
if _global_monitor:
_global_monitor.stop()
_global_monitor = None
def get_global_monitor():
"""Get the global background monitor instance."""
global _global_monitor
return _global_monitor

View File

@ -2,7 +2,13 @@ import threading
import queue
import time
import sys
import subprocess
import signal
import os
from pr.ui import Colors
from collections import defaultdict
from pr.tools.process_handlers import get_handler_for_process, detect_process_type
from pr.tools.prompt_detection import get_global_detector
class TerminalMultiplexer:
def __init__(self, name, show_output=True):
@ -14,6 +20,15 @@ class TerminalMultiplexer:
self.stderr_queue = queue.Queue()
self.active = True
self.lock = threading.Lock()
self.metadata = {
'start_time': time.time(),
'last_activity': time.time(),
'interaction_count': 0,
'process_type': 'unknown',
'state': 'active'
}
self.handler = None
self.prompt_detector = get_global_detector()
if self.show_output:
self.display_thread = threading.Thread(target=self._display_worker, daemon=True)
@ -40,12 +55,24 @@ class TerminalMultiplexer:
def write_stdout(self, data):
with self.lock:
self.stdout_buffer.append(data)
self.metadata['last_activity'] = time.time()
# Update handler state if available
if self.handler:
self.handler.update_state(data)
# Update prompt detector
self.prompt_detector.update_session_state(self.name, data, self.metadata['process_type'])
if self.show_output:
self.stdout_queue.put(data)
def write_stderr(self, data):
with self.lock:
self.stderr_buffer.append(data)
self.metadata['last_activity'] = time.time()
# Update handler state if available
if self.handler:
self.handler.update_state(data)
# Update prompt detector
self.prompt_detector.update_session_state(self.name, data, self.metadata['process_type'])
if self.show_output:
self.stderr_queue.put(data)
@ -64,6 +91,37 @@ class TerminalMultiplexer:
'stderr': ''.join(self.stderr_buffer)
}
def get_metadata(self):
with self.lock:
return self.metadata.copy()
def update_metadata(self, key, value):
with self.lock:
self.metadata[key] = value
def set_process_type(self, process_type):
"""Set the process type and initialize appropriate handler."""
with self.lock:
self.metadata['process_type'] = process_type
self.handler = get_handler_for_process(process_type, self)
def send_input(self, input_data):
if hasattr(self, 'process') and self.process.poll() is None:
try:
self.process.stdin.write(input_data + '\n')
self.process.stdin.flush()
with self.lock:
self.metadata['last_activity'] = time.time()
self.metadata['interaction_count'] += 1
except Exception as e:
self.write_stderr(f"Error sending input: {e}")
else:
# This will be implemented when we have a process attached
# For now, just update activity
with self.lock:
self.metadata['last_activity'] = time.time()
self.metadata['interaction_count'] += 1
def close(self):
self.active = False
if hasattr(self, 'display_thread'):
@ -72,6 +130,9 @@ class TerminalMultiplexer:
_multiplexers = {}
_mux_counter = 0
_mux_lock = threading.Lock()
_background_monitor = None
_monitor_active = False
_monitor_interval = 0.2 # 200ms
def create_multiplexer(name=None, show_output=True):
global _mux_counter
@ -92,7 +153,201 @@ def close_multiplexer(name):
mux.close()
del _multiplexers[name]
def get_all_multiplexer_states():
with _mux_lock:
states = {}
for name, mux in _multiplexers.items():
states[name] = {
'metadata': mux.get_metadata(),
'output_summary': {
'stdout_lines': len(mux.stdout_buffer),
'stderr_lines': len(mux.stderr_buffer)
}
}
return states
def cleanup_all_multiplexers():
for mux in list(_multiplexers.values()):
mux.close()
_multiplexers.clear()
# Background process management
_background_processes = {}
_process_lock = threading.Lock()
class BackgroundProcess:
def __init__(self, name, command):
self.name = name
self.command = command
self.process = None
self.multiplexer = None
self.status = 'starting'
self.start_time = time.time()
self.end_time = None
def start(self):
"""Start the background process."""
try:
# Create multiplexer for this process
mux_name, mux = create_multiplexer(self.name, show_output=False)
self.multiplexer = mux
# Detect process type
process_type = detect_process_type(self.command)
mux.set_process_type(process_type)
# Start the subprocess
self.process = subprocess.Popen(
self.command,
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
self.status = 'running'
# Start output monitoring threads
threading.Thread(target=self._monitor_stdout, daemon=True).start()
threading.Thread(target=self._monitor_stderr, daemon=True).start()
return {'status': 'success', 'pid': self.process.pid}
except Exception as e:
self.status = 'error'
return {'status': 'error', 'error': str(e)}
def _monitor_stdout(self):
"""Monitor stdout from the process."""
try:
for line in iter(self.process.stdout.readline, ''):
if line:
self.multiplexer.write_stdout(line.rstrip('\n\r'))
except Exception as e:
self.write_stderr(f"Error reading stdout: {e}")
finally:
self._check_completion()
def _monitor_stderr(self):
"""Monitor stderr from the process."""
try:
for line in iter(self.process.stderr.readline, ''):
if line:
self.multiplexer.write_stderr(line.rstrip('\n\r'))
except Exception as e:
self.write_stderr(f"Error reading stderr: {e}")
def _check_completion(self):
"""Check if process has completed."""
if self.process and self.process.poll() is not None:
self.status = 'completed'
self.end_time = time.time()
def get_info(self):
"""Get process information."""
self._check_completion()
return {
'name': self.name,
'command': self.command,
'status': self.status,
'pid': self.process.pid if self.process else None,
'start_time': self.start_time,
'end_time': self.end_time,
'runtime': time.time() - self.start_time if not self.end_time else self.end_time - self.start_time
}
def get_output(self, lines=None):
"""Get process output."""
if not self.multiplexer:
return []
all_output = self.multiplexer.get_all_output()
stdout_lines = all_output['stdout'].split('\n') if all_output['stdout'] else []
stderr_lines = all_output['stderr'].split('\n') if all_output['stderr'] else []
combined = stdout_lines + stderr_lines
if lines:
combined = combined[-lines:]
return [line for line in combined if line.strip()]
def send_input(self, input_text):
"""Send input to the process."""
if self.process and self.status == 'running':
try:
self.process.stdin.write(input_text + '\n')
self.process.stdin.flush()
return {'status': 'success'}
except Exception as e:
return {'status': 'error', 'error': str(e)}
return {'status': 'error', 'error': 'Process not running or no stdin'}
def kill(self):
"""Kill the process."""
if self.process and self.status == 'running':
try:
self.process.terminate()
# Wait a bit for graceful termination
time.sleep(0.1)
if self.process.poll() is None:
self.process.kill()
self.status = 'killed'
self.end_time = time.time()
return {'status': 'success'}
except Exception as e:
return {'status': 'error', 'error': str(e)}
return {'status': 'error', 'error': 'Process not running'}
def start_background_process(name, command):
"""Start a background process."""
with _process_lock:
if name in _background_processes:
return {'status': 'error', 'error': f'Process {name} already exists'}
process = BackgroundProcess(name, command)
result = process.start()
if result['status'] == 'success':
_background_processes[name] = process
return result
def get_all_sessions():
"""Get all background process sessions."""
with _process_lock:
sessions = {}
for name, process in _background_processes.items():
sessions[name] = process.get_info()
return sessions
def get_session_info(name):
"""Get information about a specific session."""
with _process_lock:
process = _background_processes.get(name)
return process.get_info() if process else None
def get_session_output(name, lines=None):
"""Get output from a specific session."""
with _process_lock:
process = _background_processes.get(name)
return process.get_output(lines) if process else None
def send_input_to_session(name, input_text):
"""Send input to a background session."""
with _process_lock:
process = _background_processes.get(name)
return process.send_input(input_text) if process else {'status': 'error', 'error': 'Session not found'}
def kill_session(name):
"""Kill a background session."""
with _process_lock:
process = _background_processes.get(name)
if process:
result = process.kill()
if result['status'] == 'success':
del _background_processes[name]
return result
return {'status': 'error', 'error': 'Session not found'}

View File

@ -72,8 +72,8 @@ def get_tools_definition():
{
"type": "function",
"function": {
"name": "run_command_interactive",
"description": "Execute an interactive terminal command that requires user input or displays UI. The command runs in the user's terminal. Returns exit code only.",
"name": "start_interactive_session",
"description": "Execute an interactive terminal command that requires user input or displays UI. The command runs in a dedicated session and returns a session name.",
"parameters": {
"type": "object",
"properties": {
@ -83,6 +83,49 @@ def get_tools_definition():
}
}
},
{
"type": "function",
"function": {
"name": "send_input_to_session",
"description": "Send input to an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"},
"input_data": {"type": "string", "description": "The input to send to the session"}
},
"required": ["session_name", "input_data"]
}
}
},
{
"type": "function",
"function": {
"name": "read_session_output",
"description": "Read output from an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"}
},
"required": ["session_name"]
}
}
},
{
"type": "function",
"function": {
"name": "close_interactive_session",
"description": "Close an interactive session.",
"parameters": {
"type": "object",
"properties": {
"session_name": {"type": "string", "description": "The name of the session"}
},
"required": ["session_name"]
}
}
},
{
"type": "function",
"function": {

View File

@ -3,6 +3,8 @@ import subprocess
import time
import select
from pr.multiplexer import create_multiplexer, close_multiplexer, get_multiplexer
from pr.tools.interactive_control import start_interactive_session
from pr.config import MAX_CONCURRENT_SESSIONS
_processes = {}
@ -95,7 +97,7 @@ def tail_process(pid: int, timeout: int = 30):
return {"status": "error", "error": f"Process {pid} not found"}
def run_command(command, timeout=30):
def run_command(command, timeout=30, monitored=False):
mux_name = None
try:
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

View File

@ -0,0 +1,157 @@
import subprocess
import threading
import time
from pr.multiplexer import create_multiplexer, get_multiplexer, close_multiplexer, get_all_multiplexer_states
def start_interactive_session(command, session_name=None, process_type='generic'):
"""
Start an interactive session in a dedicated multiplexer.
Args:
command: The command to run (list or string)
session_name: Optional name for the session
process_type: Type of process (ssh, vim, apt, etc.)
Returns:
session_name: The name of the created session
"""
name, mux = create_multiplexer(session_name)
mux.update_metadata('process_type', process_type)
# Start the process
if isinstance(command, str):
command = command.split()
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
mux.process = process
mux.update_metadata('pid', process.pid)
# Set process type and handler
detected_type = detect_process_type(command)
mux.set_process_type(detected_type)
# Start output readers
stdout_thread = threading.Thread(target=_read_output, args=(process.stdout, mux.write_stdout), daemon=True)
stderr_thread = threading.Thread(target=_read_output, args=(process.stderr, mux.write_stderr), daemon=True)
stdout_thread.start()
stderr_thread.start()
mux.stdout_thread = stdout_thread
mux.stderr_thread = stderr_thread
return name
except Exception as e:
close_multiplexer(name)
raise e
def _read_output(stream, write_func):
"""Read from a stream and write to multiplexer buffer."""
try:
for line in iter(stream.readline, ''):
if line:
write_func(line.rstrip('\n'))
except Exception as e:
print(f"Error reading output: {e}")
def send_input_to_session(session_name, input_data):
"""
Send input to an interactive session.
Args:
session_name: Name of the session
input_data: Input string to send
"""
mux = get_multiplexer(session_name)
if not mux:
raise ValueError(f"Session {session_name} not found")
if not hasattr(mux, 'process') or mux.process.poll() is not None:
raise ValueError(f"Session {session_name} is not active")
try:
mux.process.stdin.write(input_data + '\n')
mux.process.stdin.flush()
except Exception as e:
raise RuntimeError(f"Failed to send input to session {session_name}: {e}")
def read_session_output(session_name, lines=None):
"""
Read output from a session.
Args:
session_name: Name of the session
lines: Number of recent lines to return (None for all)
Returns:
dict: {'stdout': str, 'stderr': str}
"""
mux = get_multiplexer(session_name)
if not mux:
raise ValueError(f"Session {session_name} not found")
output = mux.get_all_output()
if lines is not None:
# Return last N lines
stdout_lines = output['stdout'].split('\n')[-lines:] if output['stdout'] else []
stderr_lines = output['stderr'].split('\n')[-lines:] if output['stderr'] else []
output = {
'stdout': '\n'.join(stdout_lines),
'stderr': '\n'.join(stderr_lines)
}
return output
def list_active_sessions():
"""
List all active interactive sessions.
Returns:
dict: Session states
"""
return get_all_multiplexer_states()
def get_session_status(session_name):
"""
Get detailed status of a session.
Args:
session_name: Name of the session
Returns:
dict: Session metadata and status
"""
mux = get_multiplexer(session_name)
if not mux:
return None
status = mux.get_metadata()
status['is_active'] = hasattr(mux, 'process') and mux.process.poll() is None
if status['is_active']:
status['pid'] = mux.process.pid
status['output_summary'] = {
'stdout_lines': len(mux.stdout_buffer),
'stderr_lines': len(mux.stderr_buffer)
}
return status
def close_interactive_session(session_name):
"""
Close an interactive session.
"""
try:
mux = get_multiplexer(session_name)
if mux:
mux.process.kill()
close_multiplexer(session_name)
return {"status": "success"}
except Exception as e:
return {"status": "error", "error": str(e)}

View File

@ -0,0 +1,264 @@
import re
import time
from abc import ABC, abstractmethod
class ProcessHandler(ABC):
"""Base class for process-specific handlers."""
def __init__(self, multiplexer):
self.multiplexer = multiplexer
self.state_machine = {}
self.current_state = 'initial'
self.prompt_patterns = []
self.response_suggestions = {}
@abstractmethod
def get_process_type(self):
"""Return the process type this handler manages."""
pass
def update_state(self, output):
"""Update internal state based on output."""
pass
def get_prompt_suggestions(self):
"""Return suggested responses for current state."""
return self.response_suggestions.get(self.current_state, [])
def is_waiting_for_input(self):
"""Check if process appears to be waiting for input."""
return self.current_state in ['waiting_confirmation', 'waiting_input']
class AptHandler(ProcessHandler):
"""Handler for apt package manager interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['running_command'],
'running_command': ['waiting_confirmation', 'completed'],
'waiting_confirmation': ['confirmed', 'cancelled'],
'confirmed': ['installing', 'completed'],
'installing': ['completed', 'error'],
'completed': [],
'error': [],
'cancelled': []
}
self.prompt_patterns = [
(r'Do you want to continue\?', 'confirmation'),
(r'After this operation.*installed\.', 'size_info'),
(r'Need to get.*B of archives\.', 'download_info'),
(r'Unpacking.*Configuring', 'configuring'),
(r'Setting up', 'setting_up'),
(r'E:\s', 'error')
]
def get_process_type(self):
return 'apt'
def update_state(self, output):
"""Update state based on apt output patterns."""
output_lower = output.lower()
# Check for completion
if 'processing triggers' in output_lower or 'done' in output_lower:
self.current_state = 'completed'
# Check for confirmation prompts
elif 'do you want to continue' in output_lower:
self.current_state = 'waiting_confirmation'
# Check for installation progress
elif 'setting up' in output_lower or 'unpacking' in output_lower:
self.current_state = 'installing'
# Check for errors
elif 'e:' in output_lower or 'error' in output_lower:
self.current_state = 'error'
def get_prompt_suggestions(self):
"""Return suggested responses for apt prompts."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'waiting_confirmation':
suggestions.extend(['y', 'yes', 'n', 'no'])
return suggestions
class VimHandler(ProcessHandler):
"""Handler for vim editor interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['normal_mode', 'insert_mode'],
'normal_mode': ['insert_mode', 'command_mode', 'visual_mode'],
'insert_mode': ['normal_mode'],
'command_mode': ['normal_mode'],
'visual_mode': ['normal_mode'],
'exiting': []
}
self.prompt_patterns = [
(r'-- INSERT --', 'insert_mode'),
(r'-- VISUAL --', 'visual_mode'),
(r':', 'command_mode'),
(r'Press ENTER', 'waiting_enter'),
(r'Saved', 'saved')
]
self.mode_indicators = {
'insert': '-- INSERT --',
'visual': '-- VISUAL --',
'command': ':'
}
def get_process_type(self):
return 'vim'
def update_state(self, output):
"""Update state based on vim mode indicators."""
if '-- INSERT --' in output:
self.current_state = 'insert_mode'
elif '-- VISUAL --' in output:
self.current_state = 'visual_mode'
elif output.strip().endswith(':'):
self.current_state = 'command_mode'
elif 'Press ENTER' in output:
self.current_state = 'waiting_enter'
else:
# Default to normal mode if no specific indicators
self.current_state = 'normal_mode'
def get_prompt_suggestions(self):
"""Return suggested commands for vim modes."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'command_mode':
suggestions.extend(['w', 'q', 'wq', 'q!', 'w!'])
elif self.current_state == 'normal_mode':
suggestions.extend(['i', 'a', 'o', 'dd', ':w', ':q'])
elif self.current_state == 'waiting_enter':
suggestions.extend(['\n'])
return suggestions
class SSHHandler(ProcessHandler):
"""Handler for SSH connection interactions."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['connecting'],
'connecting': ['auth_prompt', 'connected', 'failed'],
'auth_prompt': ['connected', 'failed'],
'connected': ['shell', 'disconnected'],
'shell': ['disconnected'],
'failed': [],
'disconnected': []
}
self.prompt_patterns = [
(r'password:', 'password_prompt'),
(r'yes/no', 'host_key_prompt'),
(r'Permission denied', 'auth_failed'),
(r'Welcome to', 'connected'),
(r'\$', 'shell_prompt'),
(r'\#', 'root_shell_prompt'),
(r'Connection closed', 'disconnected')
]
def get_process_type(self):
return 'ssh'
def update_state(self, output):
"""Update state based on SSH connection output."""
output_lower = output.lower()
if 'permission denied' in output_lower:
self.current_state = 'failed'
elif 'password:' in output_lower:
self.current_state = 'auth_prompt'
elif 'yes/no' in output_lower:
self.current_state = 'auth_prompt'
elif 'welcome to' in output_lower or 'last login' in output_lower:
self.current_state = 'connected'
elif output.strip().endswith('$') or output.strip().endswith('#'):
self.current_state = 'shell'
elif 'connection closed' in output_lower:
self.current_state = 'disconnected'
def get_prompt_suggestions(self):
"""Return suggested responses for SSH prompts."""
suggestions = super().get_prompt_suggestions()
if self.current_state == 'auth_prompt':
if 'password:' in self.multiplexer.get_all_output()['stdout']:
suggestions.extend(['<password>']) # Placeholder for actual password
elif 'yes/no' in self.multiplexer.get_all_output()['stdout']:
suggestions.extend(['yes', 'no'])
return suggestions
class GenericProcessHandler(ProcessHandler):
"""Fallback handler for unknown process types."""
def __init__(self, multiplexer):
super().__init__(multiplexer)
self.state_machine = {
'initial': ['running'],
'running': ['waiting_input', 'completed'],
'waiting_input': ['running'],
'completed': []
}
self.prompt_patterns = [
(r'\?\s*$', 'waiting_input'), # Lines ending with ?
(r'>\s*$', 'waiting_input'), # Lines ending with >
(r':\s*$', 'waiting_input'), # Lines ending with :
(r'done', 'completed'),
(r'finished', 'completed'),
(r'exit code', 'completed')
]
def get_process_type(self):
return 'generic'
def update_state(self, output):
"""Basic state detection for generic processes."""
output_lower = output.lower()
if any(pattern in output_lower for pattern in ['done', 'finished', 'complete']):
self.current_state = 'completed'
elif any(output.strip().endswith(char) for char in ['?', '>', ':']):
self.current_state = 'waiting_input'
else:
self.current_state = 'running'
# Handler registry
_handler_classes = {
'apt': AptHandler,
'vim': VimHandler,
'ssh': SSHHandler,
'generic': GenericProcessHandler
}
def get_handler_for_process(process_type, multiplexer):
"""Get appropriate handler for a process type."""
handler_class = _handler_classes.get(process_type, GenericProcessHandler)
return handler_class(multiplexer)
def detect_process_type(command):
"""Detect process type from command."""
command_str = ' '.join(command) if isinstance(command, list) else command
command_lower = command_str.lower()
if 'apt' in command_lower or 'apt-get' in command_lower:
return 'apt'
elif 'vim' in command_lower or 'vi ' in command_lower:
return 'vim'
elif 'ssh' in command_lower:
return 'ssh'
else:
return 'generic'
return 'ssh'
def detect_process_type(command):
"""Detect process type from command."""
command_str = ' '.join(command) if isinstance(command, list) else command
command_lower = command_str.lower()
if 'apt' in command_lower or 'apt-get' in command_lower:
return 'apt'
elif 'vim' in command_lower or 'vi ' in command_lower:
return 'vim'
elif 'ssh' in command_lower:
return 'ssh'
else:
return 'generic'

View File

@ -0,0 +1,278 @@
import re
import time
from collections import defaultdict
class PromptDetector:
"""Detects various process prompts and manages interaction state."""
def __init__(self):
self.prompt_patterns = self._load_prompt_patterns()
self.state_machines = self._load_state_machines()
self.session_states = {}
self.timeout_configs = {
'default': 30, # 30 seconds default timeout
'apt': 300, # 5 minutes for apt operations
'ssh': 60, # 1 minute for SSH connections
'vim': 3600 # 1 hour for vim sessions
}
def _load_prompt_patterns(self):
"""Load regex patterns for detecting various prompts."""
return {
'bash_prompt': [
re.compile(r'[\w\-\.]+@[\w\-\.]+:.*[\$#]\s*$'),
re.compile(r'\$\s*$'),
re.compile(r'#\s*$'),
re.compile(r'>\s*$') # Continuation prompt
],
'confirmation': [
re.compile(r'[Yy]/[Nn]', re.IGNORECASE),
re.compile(r'[Yy]es/[Nn]o', re.IGNORECASE),
re.compile(r'continue\?', re.IGNORECASE),
re.compile(r'proceed\?', re.IGNORECASE)
],
'password': [
re.compile(r'password:', re.IGNORECASE),
re.compile(r'passphrase:', re.IGNORECASE),
re.compile(r'enter password', re.IGNORECASE)
],
'sudo_password': [
re.compile(r'\[sudo\].*password', re.IGNORECASE)
],
'apt': [
re.compile(r'Do you want to continue\?', re.IGNORECASE),
re.compile(r'After this operation', re.IGNORECASE),
re.compile(r'Need to get', re.IGNORECASE)
],
'vim': [
re.compile(r'-- INSERT --'),
re.compile(r'-- VISUAL --'),
re.compile(r':'),
re.compile(r'Press ENTER', re.IGNORECASE)
],
'ssh': [
re.compile(r'yes/no', re.IGNORECASE),
re.compile(r'password:', re.IGNORECASE),
re.compile(r'Permission denied', re.IGNORECASE)
],
'git': [
re.compile(r'Username:', re.IGNORECASE),
re.compile(r'Email:', re.IGNORECASE)
],
'error': [
re.compile(r'error:', re.IGNORECASE),
re.compile(r'failed', re.IGNORECASE),
re.compile(r'exception', re.IGNORECASE)
]
}
def _load_state_machines(self):
"""Load state machines for different process types."""
return {
'apt': {
'states': ['initial', 'running', 'confirming', 'installing', 'completed', 'error'],
'transitions': {
'initial': ['running'],
'running': ['confirming', 'installing', 'completed', 'error'],
'confirming': ['installing', 'cancelled'],
'installing': ['completed', 'error'],
'completed': [],
'error': [],
'cancelled': []
}
},
'ssh': {
'states': ['initial', 'connecting', 'authenticating', 'connected', 'error'],
'transitions': {
'initial': ['connecting'],
'connecting': ['authenticating', 'connected', 'error'],
'authenticating': ['connected', 'error'],
'connected': ['error'],
'error': []
}
},
'vim': {
'states': ['initial', 'normal', 'insert', 'visual', 'command', 'exiting'],
'transitions': {
'initial': ['normal', 'insert'],
'normal': ['insert', 'visual', 'command', 'exiting'],
'insert': ['normal'],
'visual': ['normal'],
'command': ['normal', 'exiting'],
'exiting': []
}
}
}
def detect_prompt(self, output, process_type='generic'):
"""Detect what type of prompt is present in the output."""
detections = {}
# Check all pattern categories
for category, patterns in self.prompt_patterns.items():
for pattern in patterns:
if pattern.search(output):
if category not in detections:
detections[category] = []
detections[category].append(pattern.pattern)
# Process type specific detection
if process_type in self.prompt_patterns:
for pattern in self.prompt_patterns[process_type]:
if pattern.search(output):
detections[process_type] = detections.get(process_type, [])
detections[process_type].append(pattern.pattern)
return detections
def get_response_suggestions(self, prompt_detections, process_type='generic'):
"""Get suggested responses based on detected prompts."""
suggestions = []
for category, patterns in prompt_detections.items():
if category == 'confirmation':
suggestions.extend(['y', 'yes', 'n', 'no'])
elif category == 'password':
suggestions.append('<password>')
elif category == 'sudo_password':
suggestions.append('<sudo_password>')
elif category == 'apt':
if any('continue' in p for p in patterns):
suggestions.extend(['y', 'yes'])
elif category == 'vim':
if any(':' in p for p in patterns):
suggestions.extend(['w', 'q', 'wq', 'q!'])
elif any('ENTER' in p for p in patterns):
suggestions.append('\n')
elif category == 'ssh':
if any('yes/no' in p for p in patterns):
suggestions.extend(['yes', 'no'])
elif any('password' in p for p in patterns):
suggestions.append('<password>')
elif category == 'bash_prompt':
suggestions.extend(['help', 'ls', 'pwd', 'exit'])
return list(set(suggestions)) # Remove duplicates
def update_session_state(self, session_name, output, process_type='generic'):
"""Update the state machine for a session based on output."""
if session_name not in self.session_states:
self.session_states[session_name] = {
'current_state': 'initial',
'process_type': process_type,
'last_activity': time.time(),
'transitions': []
}
session_state = self.session_states[session_name]
old_state = session_state['current_state']
# Detect prompts and determine new state
detections = self.detect_prompt(output, process_type)
new_state = self._determine_state_from_detections(detections, process_type, old_state)
if new_state != old_state:
session_state['transitions'].append({
'from': old_state,
'to': new_state,
'timestamp': time.time(),
'trigger': detections
})
session_state['current_state'] = new_state
session_state['last_activity'] = time.time()
return new_state
def _determine_state_from_detections(self, detections, process_type, current_state):
"""Determine new state based on prompt detections."""
if process_type in self.state_machines:
state_machine = self.state_machines[process_type]
# State transition logic based on detections
if 'confirmation' in detections and current_state in ['running', 'initial']:
return 'confirming'
elif 'password' in detections or 'sudo_password' in detections:
return 'authenticating'
elif 'error' in detections:
return 'error'
elif 'bash_prompt' in detections and current_state != 'initial':
return 'connected' if process_type == 'ssh' else 'completed'
elif 'vim' in detections:
if any('-- INSERT --' in p for p in detections.get('vim', [])):
return 'insert'
elif any('-- VISUAL --' in p for p in detections.get('vim', [])):
return 'visual'
elif any(':' in p for p in detections.get('vim', [])):
return 'command'
# Default state progression
if current_state == 'initial':
return 'running'
elif current_state == 'running' and detections:
return 'waiting_input'
elif current_state == 'waiting_input' and not detections:
return 'running'
return current_state
def is_waiting_for_input(self, session_name):
"""Check if a session is currently waiting for input."""
if session_name not in self.session_states:
return False
state = self.session_states[session_name]['current_state']
process_type = self.session_states[session_name]['process_type']
# States that typically indicate waiting for input
waiting_states = {
'generic': ['waiting_input'],
'apt': ['confirming'],
'ssh': ['authenticating'],
'vim': ['command', 'insert', 'visual']
}
return state in waiting_states.get(process_type, [])
def get_session_timeout(self, session_name):
"""Get the timeout for a session based on its process type."""
if session_name not in self.session_states:
return self.timeout_configs['default']
process_type = self.session_states[session_name]['process_type']
return self.timeout_configs.get(process_type, self.timeout_configs['default'])
def check_for_timeouts(self):
"""Check all sessions for timeouts and return timed out sessions."""
timed_out = []
current_time = time.time()
for session_name, state in self.session_states.items():
timeout = self.get_session_timeout(session_name)
if current_time - state['last_activity'] > timeout:
timed_out.append(session_name)
return timed_out
def get_session_info(self, session_name):
"""Get information about a session's state."""
if session_name not in self.session_states:
return None
state = self.session_states[session_name]
return {
'current_state': state['current_state'],
'process_type': state['process_type'],
'last_activity': state['last_activity'],
'transitions': state['transitions'][-5:], # Last 5 transitions
'is_waiting': self.is_waiting_for_input(session_name)
}
# Global detector instance
_detector = None
def get_global_detector():
"""Get the global prompt detector instance."""
global _detector
if _detector is None:
_detector = PromptDetector()
return _detector

View File

@ -1,4 +1,5 @@
import json
import time
from typing import Dict, Any
from pr.ui.colors import Colors