kolega-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import fcntl
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import psutil
|
|
6
|
+
import pty
|
|
7
|
+
import re
|
|
8
|
+
import select
|
|
9
|
+
import signal
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from ..events import AgentConnectionManager
|
|
15
|
+
from ..events import AgentEvent
|
|
16
|
+
from .base import TerminalManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncPersistentTerminal:
|
|
20
|
+
"""
|
|
21
|
+
An asynchronous class for maintaining a persistent terminal process that can receive commands
|
|
22
|
+
and return output over time.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
COMMAND_MONITOR_TIMEOUT_SECONDS = 300
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
workspace_id: str,
|
|
30
|
+
thread_id: str,
|
|
31
|
+
terminal_id: str,
|
|
32
|
+
connection_manager: AgentConnectionManager,
|
|
33
|
+
terminal_cmd: Optional[List[str]] = None,
|
|
34
|
+
cwd: Optional[str] = None,
|
|
35
|
+
env: Optional[Dict[str, str]] = None,
|
|
36
|
+
auto_activate_venv: bool = True,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize a persistent terminal process.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
terminal: Command to start the terminal. Defaults to bash/cmd.exe based on platform.
|
|
43
|
+
cwd: Working directory for the terminal. Defaults to current directory.
|
|
44
|
+
env: Environment variables for the terminal. Defaults to current environment.
|
|
45
|
+
"""
|
|
46
|
+
self.terminal_id = terminal_id
|
|
47
|
+
self.workspace_id = workspace_id
|
|
48
|
+
self.thread_id = thread_id
|
|
49
|
+
self.connection_manager = connection_manager
|
|
50
|
+
self.auto_activate_venv = auto_activate_venv
|
|
51
|
+
|
|
52
|
+
# Check platform - this implementation only works on Unix-like systems
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
raise RuntimeError("PTY-based shell is not supported on Windows")
|
|
55
|
+
|
|
56
|
+
# Attributes to be set during startup
|
|
57
|
+
self.process = None
|
|
58
|
+
self.master_fd = None
|
|
59
|
+
self.slave_fd = None
|
|
60
|
+
self.is_running = False
|
|
61
|
+
self.shell_cleaned = False
|
|
62
|
+
self.pid = None
|
|
63
|
+
|
|
64
|
+
# Store initialization parameters
|
|
65
|
+
if terminal_cmd is None:
|
|
66
|
+
# Try to find the best shell available
|
|
67
|
+
for shell in ["/bin/zsh", "/bin/bash", "/bin/sh"]:
|
|
68
|
+
if os.path.exists(shell):
|
|
69
|
+
self.terminal_cmd = [shell]
|
|
70
|
+
break
|
|
71
|
+
else:
|
|
72
|
+
self.terminal_cmd = ["/bin/sh", "-f"] # Fallback to /bin/sh
|
|
73
|
+
else:
|
|
74
|
+
self.terminal_cmd = terminal_cmd
|
|
75
|
+
|
|
76
|
+
self.cwd = cwd
|
|
77
|
+
|
|
78
|
+
if env is None:
|
|
79
|
+
self.env = os.environ.copy()
|
|
80
|
+
else:
|
|
81
|
+
self.env = env
|
|
82
|
+
|
|
83
|
+
# Force environment to use unbuffered Python output
|
|
84
|
+
self.env["PYTHONUNBUFFERED"] = "1"
|
|
85
|
+
# Set TERM to a simple terminal type
|
|
86
|
+
self.env["TERM"] = "xterm"
|
|
87
|
+
self.env["PROMPT"] = "$ "
|
|
88
|
+
|
|
89
|
+
# Buffer for output that hasn't been read yet
|
|
90
|
+
self.output_buffer = bytearray()
|
|
91
|
+
|
|
92
|
+
# Buffer that keeps all output (doesn't get cleared)
|
|
93
|
+
self.persistent_output_buffer = bytearray()
|
|
94
|
+
|
|
95
|
+
# Tracking the last time output was received
|
|
96
|
+
self.last_output_time = 0
|
|
97
|
+
|
|
98
|
+
# Track the last command sent to the terminal
|
|
99
|
+
self.last_command = ""
|
|
100
|
+
self.last_command_purpose = ""
|
|
101
|
+
|
|
102
|
+
# Command tracking for process-based completion detection
|
|
103
|
+
self.active_commands = {} # command_id -> command_info
|
|
104
|
+
self.command_counter = 0
|
|
105
|
+
self.shell_prompt_detected = True # Track if shell is ready for new commands
|
|
106
|
+
|
|
107
|
+
def _strip_ansi_codes(self, text: str) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Remove ANSI escape sequences (color/formatting codes) from text.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
text: The text containing ANSI codes
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Text with ANSI codes removed
|
|
116
|
+
"""
|
|
117
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
118
|
+
return ansi_escape.sub("", text)
|
|
119
|
+
|
|
120
|
+
async def start(self) -> None:
|
|
121
|
+
"""Start the terminal process."""
|
|
122
|
+
self.pid, self.master_fd = pty.fork()
|
|
123
|
+
|
|
124
|
+
if self.pid == 0:
|
|
125
|
+
# Child process - execute the shell
|
|
126
|
+
if self.cwd:
|
|
127
|
+
os.chdir(self.cwd)
|
|
128
|
+
|
|
129
|
+
# Replace the child process with the shell
|
|
130
|
+
os.execvpe(self.terminal_cmd[0], self.terminal_cmd, self.env)
|
|
131
|
+
else:
|
|
132
|
+
# Parent process
|
|
133
|
+
# Set master to non-blocking mode
|
|
134
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
135
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
136
|
+
|
|
137
|
+
self.is_running = True
|
|
138
|
+
self.last_output_time = time.time()
|
|
139
|
+
|
|
140
|
+
# Start the background task to read output
|
|
141
|
+
self._read_task = asyncio.create_task(self._read_output())
|
|
142
|
+
|
|
143
|
+
# Wait a bit for the shell to initialize
|
|
144
|
+
await asyncio.sleep(0.2)
|
|
145
|
+
|
|
146
|
+
if self.auto_activate_venv:
|
|
147
|
+
activation_script = await self.detect_venv()
|
|
148
|
+
if activation_script:
|
|
149
|
+
await self.send_command(activation_script)
|
|
150
|
+
|
|
151
|
+
# Clean up the shell
|
|
152
|
+
await self.send_command("unsetopt prompt_cr prompt_sp") # Disable prompt % marker
|
|
153
|
+
await self.send_command("unsetopt zle") # Turn off zsh line editing
|
|
154
|
+
await self.send_command('PS1=""') # Set empty prompt
|
|
155
|
+
await self.send_command('PROMPT=""') # Alternative way to set prompt
|
|
156
|
+
await self.send_command('RPROMPT=""')
|
|
157
|
+
await self.get_new_output(inactivity_timeout=0.5)
|
|
158
|
+
self.shell_cleaned = True
|
|
159
|
+
|
|
160
|
+
# Clear last_command after shell initialization
|
|
161
|
+
self.last_command = ""
|
|
162
|
+
self.last_command_purpose = ""
|
|
163
|
+
|
|
164
|
+
self.is_running = True
|
|
165
|
+
self.last_output_time = time.time()
|
|
166
|
+
|
|
167
|
+
# Start the background task to read output
|
|
168
|
+
self._read_task = asyncio.create_task(self._read_output())
|
|
169
|
+
|
|
170
|
+
async def detect_venv(self) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Detect if a Python virtual environment exists in the project directory.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Path to the activation script if a virtual environment was found, empty string otherwise
|
|
176
|
+
"""
|
|
177
|
+
# Common virtual environment directory names
|
|
178
|
+
venv_dirs = [".venv", "venv", "env", ".env"]
|
|
179
|
+
|
|
180
|
+
for venv_dir in venv_dirs:
|
|
181
|
+
# Check if the virtual environment directory exists
|
|
182
|
+
venv_path = os.path.join(str(self.cwd), venv_dir)
|
|
183
|
+
|
|
184
|
+
# Check if directory exists
|
|
185
|
+
if not os.path.isdir(venv_path):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Check for the activation script based on OS
|
|
189
|
+
activate_script = os.path.join(venv_path, "bin", "activate")
|
|
190
|
+
windows_script = os.path.join(venv_path, "Scripts", "activate")
|
|
191
|
+
|
|
192
|
+
if os.path.isfile(activate_script):
|
|
193
|
+
return f"source {activate_script}"
|
|
194
|
+
|
|
195
|
+
if os.path.isfile(windows_script):
|
|
196
|
+
return f"source {windows_script}"
|
|
197
|
+
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
async def _read_output(self) -> None:
|
|
201
|
+
"""Background task to continuously read from process output."""
|
|
202
|
+
READ_SIZE = 1024
|
|
203
|
+
|
|
204
|
+
while self.is_running:
|
|
205
|
+
# Use asyncio-friendly way to check for data
|
|
206
|
+
await asyncio.sleep(0.01) # Small sleep to prevent CPU thrashing
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Try a non-blocking read
|
|
210
|
+
r, _, _ = select.select([self.master_fd], [], [], 0)
|
|
211
|
+
if self.master_fd in r:
|
|
212
|
+
try:
|
|
213
|
+
data = os.read(self.master_fd, READ_SIZE)
|
|
214
|
+
if data:
|
|
215
|
+
self.last_output_time = time.time()
|
|
216
|
+
self.output_buffer.extend(data)
|
|
217
|
+
self.persistent_output_buffer.extend(data)
|
|
218
|
+
|
|
219
|
+
data_str = bytes(data).decode("utf-8", errors="replace")
|
|
220
|
+
|
|
221
|
+
if self.shell_cleaned:
|
|
222
|
+
output_ansi_stripped = self._strip_ansi_codes(data_str)
|
|
223
|
+
terminal_output_event = AgentEvent(
|
|
224
|
+
event_type="terminal_output",
|
|
225
|
+
sender="agent",
|
|
226
|
+
content={
|
|
227
|
+
"output": output_ansi_stripped,
|
|
228
|
+
"terminal_id": self.terminal_id,
|
|
229
|
+
"thread_id": self.thread_id,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
await self.connection_manager.broadcast_event(
|
|
233
|
+
terminal_output_event, self.workspace_id, self.thread_id
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# EOF - process has exited
|
|
237
|
+
self.is_running = False
|
|
238
|
+
break
|
|
239
|
+
except (OSError, IOError):
|
|
240
|
+
# Check if process has exited
|
|
241
|
+
try:
|
|
242
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
243
|
+
if pid == self.pid:
|
|
244
|
+
self.is_running = False
|
|
245
|
+
break
|
|
246
|
+
except ChildProcessError:
|
|
247
|
+
self.is_running = False
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
# If read error but process still running, just continue
|
|
251
|
+
continue
|
|
252
|
+
except Exception:
|
|
253
|
+
self.is_running = False
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
# Process has terminated
|
|
257
|
+
self.is_running = False
|
|
258
|
+
|
|
259
|
+
async def send_command(self, command: str, purpose: Optional[str] = None) -> bool:
|
|
260
|
+
"""
|
|
261
|
+
Send a command to the terminal.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
command: The command to execute
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
True if command was sent successfully, False if process is not running
|
|
268
|
+
"""
|
|
269
|
+
if not self.is_running or self.master_fd is None:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
if not command.endswith("\n"):
|
|
273
|
+
command += "\n"
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
os.write(self.master_fd, command.encode())
|
|
277
|
+
self.last_command = command
|
|
278
|
+
self.last_command_purpose = purpose
|
|
279
|
+
return True
|
|
280
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
281
|
+
self.is_running = False
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
async def send_input(self, text: str, submit: bool = True) -> bool:
|
|
285
|
+
"""
|
|
286
|
+
Send raw input to the foreground process without changing command tracking.
|
|
287
|
+
"""
|
|
288
|
+
if not self.is_running or self.master_fd is None:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
payload = text
|
|
292
|
+
if submit and not payload.endswith("\n"):
|
|
293
|
+
payload += "\n"
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
os.write(self.master_fd, payload.encode())
|
|
297
|
+
return True
|
|
298
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
299
|
+
self.is_running = False
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
async def get_new_output(self, inactivity_timeout: float = 2.0, max_wait: Optional[float] = 30) -> str:
|
|
303
|
+
"""
|
|
304
|
+
Get output that hasn't been read yet, waiting until the terminal stops producing
|
|
305
|
+
output for inactivity_timeout seconds or until max_wait seconds have passed.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
inactivity_timeout: Seconds of inactivity that signals completion
|
|
309
|
+
max_wait: Maximum seconds to wait overall
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Any new output from the terminal
|
|
313
|
+
"""
|
|
314
|
+
start_time = time.time()
|
|
315
|
+
|
|
316
|
+
while True:
|
|
317
|
+
current_time = time.time()
|
|
318
|
+
|
|
319
|
+
# Check for process termination
|
|
320
|
+
if not self.is_running and len(self.output_buffer) == 0:
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
# Check if we've been inactive long enough to stop
|
|
324
|
+
inactive_time = current_time - self.last_output_time
|
|
325
|
+
if len(self.output_buffer) > 0 and inactive_time >= inactivity_timeout:
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
# Check if we've exceeded the maximum wait time
|
|
329
|
+
if max_wait is not None and current_time - start_time >= max_wait:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
# Wait a bit before checking again
|
|
333
|
+
await asyncio.sleep(0.1)
|
|
334
|
+
|
|
335
|
+
# Get and clear the current buffer
|
|
336
|
+
output = bytes(self.output_buffer).decode("utf-8", errors="replace")
|
|
337
|
+
self.output_buffer.clear()
|
|
338
|
+
|
|
339
|
+
return output
|
|
340
|
+
|
|
341
|
+
def read_output(self, num_chars: int = 1024, offset: int = 0) -> str:
|
|
342
|
+
"""
|
|
343
|
+
Read characters from the persistent output buffer.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
num_chars: Number of characters to read (default: 1024).
|
|
347
|
+
If buffer is smaller than num_chars, returns entire buffer.
|
|
348
|
+
offset: Number of characters from the end to start reading from (default: 0).
|
|
349
|
+
If offset is 0, reads the last num_chars characters.
|
|
350
|
+
If offset is > 0, reads num_chars characters starting from that offset from the end.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
The requested characters from the output buffer as a UTF-8 decoded string.
|
|
354
|
+
"""
|
|
355
|
+
# Get the length of the buffer
|
|
356
|
+
buffer_length = len(self.persistent_output_buffer)
|
|
357
|
+
|
|
358
|
+
if buffer_length == 0:
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
# First decode the entire buffer to get proper character boundaries
|
|
362
|
+
full_output = bytes(self.persistent_output_buffer).decode("utf-8", errors="replace")
|
|
363
|
+
total_chars = len(full_output)
|
|
364
|
+
|
|
365
|
+
if total_chars == 0:
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
# Calculate start and end positions
|
|
369
|
+
if offset == 0:
|
|
370
|
+
# Default behavior: read last num_chars characters
|
|
371
|
+
if total_chars <= num_chars:
|
|
372
|
+
return full_output
|
|
373
|
+
else:
|
|
374
|
+
return full_output[-num_chars:]
|
|
375
|
+
else:
|
|
376
|
+
# With offset: read num_chars characters starting from (end - offset - num_chars)
|
|
377
|
+
# This means we skip the last 'offset' characters and read 'num_chars' before that
|
|
378
|
+
start_pos = max(0, total_chars - offset - num_chars)
|
|
379
|
+
end_pos = max(0, total_chars - offset)
|
|
380
|
+
|
|
381
|
+
# Ensure we don't have negative ranges
|
|
382
|
+
if start_pos >= end_pos:
|
|
383
|
+
return ""
|
|
384
|
+
|
|
385
|
+
return full_output[start_pos:end_pos]
|
|
386
|
+
|
|
387
|
+
async def is_alive(self) -> bool:
|
|
388
|
+
"""Check if the terminal process is still running."""
|
|
389
|
+
if self.pid is None:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
# Check if process is still running
|
|
394
|
+
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
|
395
|
+
if pid == self.pid:
|
|
396
|
+
self.is_running = False
|
|
397
|
+
except ChildProcessError:
|
|
398
|
+
self.is_running = False
|
|
399
|
+
|
|
400
|
+
return self.is_running
|
|
401
|
+
|
|
402
|
+
async def close(self) -> None:
|
|
403
|
+
"""Close the terminal process and clean up resources."""
|
|
404
|
+
if not self.is_running or self.pid is None:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self.is_running = False
|
|
408
|
+
|
|
409
|
+
# Try to terminate the process gracefully
|
|
410
|
+
try:
|
|
411
|
+
os.kill(self.pid, signal.SIGTERM)
|
|
412
|
+
# Give it a moment to terminate
|
|
413
|
+
await asyncio.sleep(0.5)
|
|
414
|
+
|
|
415
|
+
# Force kill if still running
|
|
416
|
+
if await self.is_alive():
|
|
417
|
+
os.kill(self.pid, signal.SIGKILL)
|
|
418
|
+
except ProcessLookupError:
|
|
419
|
+
# Process is already gone
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
# Close the master FD
|
|
423
|
+
if self.master_fd is not None:
|
|
424
|
+
os.close(self.master_fd)
|
|
425
|
+
self.master_fd = None
|
|
426
|
+
|
|
427
|
+
# Cancel the read task
|
|
428
|
+
if hasattr(self, "_read_task") and not self._read_task.done():
|
|
429
|
+
self._read_task.cancel()
|
|
430
|
+
try:
|
|
431
|
+
await self._read_task
|
|
432
|
+
except asyncio.CancelledError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
async def send_command_tracked(self, command: str, purpose: Optional[str] = None) -> Optional[str]:
|
|
436
|
+
"""
|
|
437
|
+
Send a command and return a unique command ID for tracking.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
command: The command to execute
|
|
441
|
+
purpose: Optional description of the command's purpose
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Command ID for tracking, or None if command couldn't be sent
|
|
445
|
+
"""
|
|
446
|
+
if not self.is_running or self.master_fd is None:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
self.command_counter += 1
|
|
450
|
+
command_id = f"{self.terminal_id}_{self.command_counter}"
|
|
451
|
+
|
|
452
|
+
# Record the command
|
|
453
|
+
self.active_commands[command_id] = {
|
|
454
|
+
"command": command.strip(),
|
|
455
|
+
"purpose": purpose,
|
|
456
|
+
"start_time": time.time(),
|
|
457
|
+
"status": "running",
|
|
458
|
+
"child_pids": set(),
|
|
459
|
+
"return_code": None,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# Send the command
|
|
463
|
+
success = await self.send_command(command, purpose)
|
|
464
|
+
if success:
|
|
465
|
+
self.shell_prompt_detected = False
|
|
466
|
+
# Start monitoring for completion
|
|
467
|
+
asyncio.create_task(self._monitor_command_completion(command_id))
|
|
468
|
+
return command_id
|
|
469
|
+
else:
|
|
470
|
+
del self.active_commands[command_id]
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
async def _monitor_command_completion(self, command_id: str):
|
|
474
|
+
"""Monitor a command for completion by checking child processes."""
|
|
475
|
+
command_info = self.active_commands.get(command_id)
|
|
476
|
+
if not command_info:
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
# Initial delay to let command start
|
|
480
|
+
await asyncio.sleep(0.1)
|
|
481
|
+
|
|
482
|
+
max_monitor_time = self.COMMAND_MONITOR_TIMEOUT_SECONDS
|
|
483
|
+
check_interval = 0.2 # Check every 200ms for faster response
|
|
484
|
+
consecutive_no_children = 0
|
|
485
|
+
|
|
486
|
+
start_time = time.time()
|
|
487
|
+
|
|
488
|
+
while command_info["status"] == "running":
|
|
489
|
+
current_time = time.time()
|
|
490
|
+
|
|
491
|
+
# Timeout protection - don't monitor forever (>= so a zero timeout
|
|
492
|
+
# deterministically times out on the first check)
|
|
493
|
+
if current_time - start_time >= max_monitor_time:
|
|
494
|
+
command_info["status"] = "monitor_timeout"
|
|
495
|
+
command_info["return_code"] = None
|
|
496
|
+
command_info["monitor_timeout_seconds"] = max_monitor_time
|
|
497
|
+
break
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
# Get current child processes of the shell
|
|
501
|
+
try:
|
|
502
|
+
shell_process = psutil.Process(self.pid)
|
|
503
|
+
current_children = {child.pid for child in shell_process.children(recursive=True)}
|
|
504
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
505
|
+
# Shell process might have died
|
|
506
|
+
command_info["status"] = "terminated"
|
|
507
|
+
command_info["return_code"] = -1
|
|
508
|
+
break
|
|
509
|
+
|
|
510
|
+
# Update tracked child PIDs
|
|
511
|
+
command_info["child_pids"].update(current_children)
|
|
512
|
+
|
|
513
|
+
# If no children, start counting consecutive checks
|
|
514
|
+
if not current_children:
|
|
515
|
+
consecutive_no_children += 1
|
|
516
|
+
|
|
517
|
+
# If we've had no children for several consecutive checks, likely done
|
|
518
|
+
# Use shorter requirement for quick commands
|
|
519
|
+
required_consecutive = 3 if current_time - start_time < 5 else 2
|
|
520
|
+
|
|
521
|
+
if consecutive_no_children >= required_consecutive:
|
|
522
|
+
# Double-check with prompt detection if we can
|
|
523
|
+
prompt_detected = self._check_for_shell_prompt()
|
|
524
|
+
|
|
525
|
+
# Mark as completed if:
|
|
526
|
+
# 1. We have no children for several checks AND
|
|
527
|
+
# 2. Either we detect a prompt OR enough time has passed
|
|
528
|
+
if prompt_detected or consecutive_no_children >= 5:
|
|
529
|
+
command_info["status"] = "completed"
|
|
530
|
+
command_info["return_code"] = 0 # Assume success
|
|
531
|
+
self.shell_prompt_detected = True
|
|
532
|
+
break
|
|
533
|
+
else:
|
|
534
|
+
# Reset counter if we see children again
|
|
535
|
+
consecutive_no_children = 0
|
|
536
|
+
|
|
537
|
+
except Exception:
|
|
538
|
+
# If we can't monitor, assume command is still running
|
|
539
|
+
# But don't let it run forever
|
|
540
|
+
if current_time - start_time > 30: # 30 second fallback
|
|
541
|
+
command_info["status"] = "completed"
|
|
542
|
+
command_info["return_code"] = 0
|
|
543
|
+
break
|
|
544
|
+
|
|
545
|
+
await asyncio.sleep(check_interval)
|
|
546
|
+
|
|
547
|
+
def _check_for_shell_prompt(self) -> bool:
|
|
548
|
+
"""Check if the recent output contains a shell prompt pattern."""
|
|
549
|
+
recent_output = self.read_output(200) # Last 200 chars
|
|
550
|
+
|
|
551
|
+
# Look for common prompt patterns - be more inclusive to avoid hanging
|
|
552
|
+
prompt_patterns = [
|
|
553
|
+
r"\$\s*$", # $ at end (bash)
|
|
554
|
+
r">\s*$", # > at end
|
|
555
|
+
r"#\s*$", # # at end (root)
|
|
556
|
+
r"%\s*$", # % at end (zsh)
|
|
557
|
+
r"❯\s*$", # Fish shell prompt
|
|
558
|
+
r"➜.*$", # Oh My Zsh arrow prompts
|
|
559
|
+
r"»\s*$", # Custom prompt with »
|
|
560
|
+
r"λ\s*$", # Lambda prompt
|
|
561
|
+
r"⚡\s*$", # Lightning prompt
|
|
562
|
+
r"\]\s*\$\s*$", # [user@host dir]$ pattern
|
|
563
|
+
r"\)\s*\$\s*$", # (venv) $ pattern
|
|
564
|
+
r":\s*\$\s*$", # dir:$ pattern
|
|
565
|
+
r"~\s*\$\s*$", # ~$ pattern
|
|
566
|
+
r"\w+\s*\$\s*$", # word$ pattern (simplified)
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
for pattern in prompt_patterns:
|
|
570
|
+
if re.search(pattern, recent_output.strip(), re.MULTILINE):
|
|
571
|
+
return True
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
def get_command_status(self, command_id: str) -> dict:
|
|
575
|
+
"""Get the status of a specific command."""
|
|
576
|
+
if command_id not in self.active_commands:
|
|
577
|
+
return {"status": "not_found"}
|
|
578
|
+
|
|
579
|
+
command_info = self.active_commands[command_id]
|
|
580
|
+
return {
|
|
581
|
+
"status": command_info["status"],
|
|
582
|
+
"command": command_info["command"],
|
|
583
|
+
"purpose": command_info.get("purpose"),
|
|
584
|
+
"duration": time.time() - command_info["start_time"],
|
|
585
|
+
"return_code": command_info.get("return_code"),
|
|
586
|
+
"monitor_timeout_seconds": command_info.get("monitor_timeout_seconds"),
|
|
587
|
+
"child_pids": list(command_info["child_pids"]),
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
def get_active_commands(self) -> dict:
|
|
591
|
+
"""Get all currently active commands."""
|
|
592
|
+
return {
|
|
593
|
+
cmd_id: self.get_command_status(cmd_id)
|
|
594
|
+
for cmd_id in self.active_commands
|
|
595
|
+
if self.active_commands[cmd_id]["status"] in {"running", "monitor_timeout"}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
def is_ready_for_commands(self) -> bool:
|
|
599
|
+
"""Check if the terminal is ready to accept new commands."""
|
|
600
|
+
return self.shell_prompt_detected and len(self.get_active_commands()) == 0
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class LocalTerminalManager(TerminalManager):
|
|
604
|
+
"""
|
|
605
|
+
A manager class that helps handle multiple terminal instances.
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
def __init__(self, workspace_id: str, thread_id: str, connection_manager: AgentConnectionManager):
|
|
609
|
+
"""Initialize an empty terminal manager."""
|
|
610
|
+
self.terminals: Dict[str, AsyncPersistentTerminal] = {}
|
|
611
|
+
self.workspace_id = workspace_id
|
|
612
|
+
self.thread_id = thread_id
|
|
613
|
+
self.connection_manager = connection_manager
|
|
614
|
+
|
|
615
|
+
async def get_last_command(self, terminal_id: str) -> str:
|
|
616
|
+
if terminal_id not in self.terminals:
|
|
617
|
+
raise KeyError(f"Terminal with ID {terminal_id} not found")
|
|
618
|
+
|
|
619
|
+
# Strip trailing newline for consistency
|
|
620
|
+
return self.terminals[terminal_id].last_command.rstrip("\n")
|
|
621
|
+
|
|
622
|
+
async def get_last_command_purpose(self, terminal_id: str) -> str:
|
|
623
|
+
if terminal_id not in self.terminals:
|
|
624
|
+
raise KeyError(f"Terminal with ID {terminal_id} not found")
|
|
625
|
+
|
|
626
|
+
return self.terminals[terminal_id].last_command_purpose
|
|
627
|
+
|
|
628
|
+
async def launch_terminal(self, terminal_id: Optional[str] = None, **terminal_kwargs) -> str:
|
|
629
|
+
"""
|
|
630
|
+
Create a new terminal instance with the given ID or generate a random ID.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
terminal_id: Optional identifier for the terminal (random UUID if not provided)
|
|
634
|
+
**terminal_kwargs: Arguments to pass to AsyncPersistentTerminal constructor
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
The ID of the created terminal
|
|
638
|
+
"""
|
|
639
|
+
# Generate a random ID if none provided
|
|
640
|
+
if terminal_id is None:
|
|
641
|
+
terminal_id = str(uuid.uuid4())
|
|
642
|
+
|
|
643
|
+
# Create new terminal instance
|
|
644
|
+
term = AsyncPersistentTerminal(
|
|
645
|
+
workspace_id=self.workspace_id,
|
|
646
|
+
thread_id=self.thread_id,
|
|
647
|
+
terminal_id=terminal_id,
|
|
648
|
+
connection_manager=self.connection_manager,
|
|
649
|
+
**terminal_kwargs,
|
|
650
|
+
)
|
|
651
|
+
await term.start()
|
|
652
|
+
|
|
653
|
+
# Store in our dictionary
|
|
654
|
+
self.terminals[terminal_id] = term
|
|
655
|
+
return terminal_id
|
|
656
|
+
|
|
657
|
+
async def send_command(
|
|
658
|
+
self, term_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
|
|
659
|
+
) -> bool:
|
|
660
|
+
"""
|
|
661
|
+
Send a command to a specific terminal.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
term_id: ID of the terminal to send command to
|
|
665
|
+
command: The command to execute
|
|
666
|
+
purpose: Optional description of command purpose
|
|
667
|
+
timeout: Optional timeout in seconds (not implemented for local terminals)
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
True if command was sent successfully
|
|
671
|
+
|
|
672
|
+
Raises:
|
|
673
|
+
KeyError: If terminal_id doesn't exist
|
|
674
|
+
"""
|
|
675
|
+
if term_id not in self.terminals:
|
|
676
|
+
raise KeyError(f"Terminal with ID {term_id} not found")
|
|
677
|
+
|
|
678
|
+
# Note: timeout is not implemented for local terminals as they use persistent shell sessions
|
|
679
|
+
return await self.terminals[term_id].send_command(command, purpose=purpose)
|
|
680
|
+
|
|
681
|
+
async def send_input(
|
|
682
|
+
self, term_id: str, text: str, submit: bool = True, command_id: Optional[str] = None
|
|
683
|
+
) -> bool:
|
|
684
|
+
"""
|
|
685
|
+
Send input to a running command in a local terminal.
|
|
686
|
+
"""
|
|
687
|
+
if term_id not in self.terminals:
|
|
688
|
+
raise KeyError(f"Terminal with ID {term_id} not found")
|
|
689
|
+
|
|
690
|
+
terminal = self.terminals[term_id]
|
|
691
|
+
active_commands = terminal.get_active_commands()
|
|
692
|
+
|
|
693
|
+
if command_id is not None:
|
|
694
|
+
status = terminal.get_command_status(command_id)
|
|
695
|
+
if status["status"] == "not_found":
|
|
696
|
+
raise ValueError(f"Command ID {command_id} not found in terminal {term_id}")
|
|
697
|
+
if status["status"] not in {"running", "monitor_timeout"}:
|
|
698
|
+
raise ValueError(f"Command {command_id} is not running in terminal {term_id}")
|
|
699
|
+
elif not active_commands:
|
|
700
|
+
raise ValueError(f"No active command is running in terminal {term_id}")
|
|
701
|
+
elif len(active_commands) > 1:
|
|
702
|
+
raise ValueError(f"Multiple active commands are running in terminal {term_id}; provide command_id")
|
|
703
|
+
|
|
704
|
+
return await terminal.send_input(text, submit=submit)
|
|
705
|
+
|
|
706
|
+
async def get_output(self, terminal_id: str, **kwargs) -> str:
|
|
707
|
+
"""
|
|
708
|
+
Get output from a specific terminal.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
terminal_id: ID of the terminal to get output from
|
|
712
|
+
**kwargs: Arguments to pass to get_new_output
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
Output from the specified terminal
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
KeyError: If terminal_id doesn't exist
|
|
719
|
+
"""
|
|
720
|
+
if terminal_id not in self.terminals:
|
|
721
|
+
raise KeyError(f"Terminal with ID {terminal_id} not found")
|
|
722
|
+
|
|
723
|
+
return await self.terminals[terminal_id].get_new_output(**kwargs)
|
|
724
|
+
|
|
725
|
+
def read_output(self, terminal_id: str, num_chars: int = 1024, offset: int = 0) -> str:
|
|
726
|
+
"""
|
|
727
|
+
Read characters from a terminal's persistent output buffer.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
terminal_id: ID of the terminal to read output from
|
|
731
|
+
num_chars: Number of characters to read (default: 1024).
|
|
732
|
+
If buffer is smaller than num_chars, returns entire buffer.
|
|
733
|
+
offset: Number of characters from the end to start reading from (default: 0).
|
|
734
|
+
If offset is 0, reads the last num_chars characters.
|
|
735
|
+
If offset is > 0, reads num_chars characters starting from that offset from the end.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
The requested characters from the terminal's output buffer as a UTF-8 decoded string.
|
|
739
|
+
|
|
740
|
+
Raises:
|
|
741
|
+
KeyError: If terminal_id doesn't exist
|
|
742
|
+
"""
|
|
743
|
+
if terminal_id not in self.terminals:
|
|
744
|
+
raise KeyError(f"Terminal with ID {terminal_id} not found")
|
|
745
|
+
|
|
746
|
+
return self.terminals[terminal_id].read_output(num_chars=num_chars, offset=offset)
|
|
747
|
+
|
|
748
|
+
async def close_terminal(self, terminal_id: str) -> None:
|
|
749
|
+
"""
|
|
750
|
+
Close a specific terminal.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
terminal_id: ID of the terminal to close
|
|
754
|
+
|
|
755
|
+
Raises:
|
|
756
|
+
KeyError: If terminal_id doesn't exist
|
|
757
|
+
"""
|
|
758
|
+
if terminal_id not in self.terminals:
|
|
759
|
+
raise KeyError(f"Terminal with ID {terminal_id} not found.")
|
|
760
|
+
|
|
761
|
+
await self.terminals[terminal_id].close()
|
|
762
|
+
del self.terminals[terminal_id]
|
|
763
|
+
|
|
764
|
+
async def close_all(self) -> None:
|
|
765
|
+
"""Close all terminal instances."""
|
|
766
|
+
# Get a copy of keys to avoid modification during iteration
|
|
767
|
+
term_ids = list(self.terminals.keys())
|
|
768
|
+
for term_id in term_ids:
|
|
769
|
+
await self.close_terminal(term_id)
|
|
770
|
+
|
|
771
|
+
async def list_terminals(self) -> Dict[str, bool]:
|
|
772
|
+
"""
|
|
773
|
+
Get a dictionary of all terminals IDs and their running status.
|
|
774
|
+
|
|
775
|
+
Returns:
|
|
776
|
+
Dictionary mapping terminal IDs to boolean running status
|
|
777
|
+
"""
|
|
778
|
+
result = {}
|
|
779
|
+
for term_id, term in self.terminals.items():
|
|
780
|
+
result[term_id] = {"running": await term.is_alive(), "last_command": term.last_command}
|
|
781
|
+
return result
|
|
782
|
+
|
|
783
|
+
async def send_command_tracked(
|
|
784
|
+
self, term_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
|
|
785
|
+
) -> Optional[str]:
|
|
786
|
+
"""
|
|
787
|
+
Send a command and return a command ID for tracking.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
term_id: ID of the terminal to send command to
|
|
791
|
+
command: The command to execute
|
|
792
|
+
purpose: Optional description of the command's purpose
|
|
793
|
+
timeout: Optional timeout in seconds (not implemented for local terminals)
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
Command ID for tracking, or None if command couldn't be sent
|
|
797
|
+
|
|
798
|
+
Raises:
|
|
799
|
+
KeyError: If terminal_id doesn't exist
|
|
800
|
+
"""
|
|
801
|
+
if term_id not in self.terminals:
|
|
802
|
+
raise KeyError(f"Terminal with ID {term_id} not found")
|
|
803
|
+
|
|
804
|
+
# Note: timeout is not implemented for local terminals as they use persistent shell sessions
|
|
805
|
+
return await self.terminals[term_id].send_command_tracked(command, purpose)
|
|
806
|
+
|
|
807
|
+
def get_command_status(self, term_id: str, command_id: str) -> dict:
|
|
808
|
+
"""
|
|
809
|
+
Get the status of a specific command.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
term_id: ID of the terminal
|
|
813
|
+
command_id: ID of the command to check
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
Dictionary containing command status information
|
|
817
|
+
|
|
818
|
+
Raises:
|
|
819
|
+
KeyError: If terminal_id doesn't exist
|
|
820
|
+
"""
|
|
821
|
+
if term_id not in self.terminals:
|
|
822
|
+
raise KeyError(f"Terminal with ID {term_id} not found")
|
|
823
|
+
|
|
824
|
+
return self.terminals[term_id].get_command_status(command_id)
|
|
825
|
+
|
|
826
|
+
async def get_terminal_status(self, term_id: str) -> dict:
|
|
827
|
+
"""
|
|
828
|
+
Get comprehensive status of a terminal including active commands.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
term_id: ID of the terminal to check
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
Dictionary containing terminal status and active commands
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
KeyError: If terminal_id doesn't exist
|
|
838
|
+
"""
|
|
839
|
+
if term_id not in self.terminals:
|
|
840
|
+
raise KeyError(f"Terminal with ID {term_id} not found")
|
|
841
|
+
|
|
842
|
+
terminal = self.terminals[term_id]
|
|
843
|
+
return {
|
|
844
|
+
"running": await terminal.is_alive(),
|
|
845
|
+
"ready_for_commands": terminal.is_ready_for_commands(),
|
|
846
|
+
"active_commands": terminal.get_active_commands(),
|
|
847
|
+
"last_command": terminal.last_command,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async def cleanup_all(self):
|
|
851
|
+
"""Clean up all terminals - useful for interrupt handling"""
|
|
852
|
+
print(f"Cleaning up {len(self.terminals)} terminal(s)")
|
|
853
|
+
for terminal_id, terminal in list(self.terminals.items()):
|
|
854
|
+
try:
|
|
855
|
+
await terminal.close()
|
|
856
|
+
print(f"Closed terminal {terminal_id}")
|
|
857
|
+
except Exception as e:
|
|
858
|
+
print(f"Error closing terminal {terminal_id}: {e}")
|
|
859
|
+
self.terminals.clear()
|
|
860
|
+
|
|
861
|
+
async def run_command(self, command: str, cwd: Optional[str] = None, timeout: Optional[int] = None) -> str:
|
|
862
|
+
"""
|
|
863
|
+
Run a command directly (convenience method for utilities).
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
command: Command to execute
|
|
867
|
+
cwd: Optional working directory
|
|
868
|
+
timeout: Optional timeout in seconds
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
Command output as string
|
|
872
|
+
"""
|
|
873
|
+
# Create a temporary terminal for this command
|
|
874
|
+
terminal_kwargs = {}
|
|
875
|
+
if cwd:
|
|
876
|
+
terminal_kwargs["cwd"] = cwd
|
|
877
|
+
|
|
878
|
+
terminal_id = await self.launch_terminal(**terminal_kwargs)
|
|
879
|
+
try:
|
|
880
|
+
# Send command and wait for completion
|
|
881
|
+
await self.send_command(terminal_id, command)
|
|
882
|
+
|
|
883
|
+
# Get output (with a reasonable timeout)
|
|
884
|
+
import asyncio
|
|
885
|
+
|
|
886
|
+
await asyncio.sleep(0.1) # Brief wait for command to start
|
|
887
|
+
|
|
888
|
+
# Wait for command completion by checking if it's ready for new commands
|
|
889
|
+
max_wait = timeout or 30 # Default 30 second timeout
|
|
890
|
+
waited = 0
|
|
891
|
+
while waited < max_wait:
|
|
892
|
+
if self.terminals[terminal_id].is_ready_for_commands():
|
|
893
|
+
break
|
|
894
|
+
await asyncio.sleep(0.5)
|
|
895
|
+
waited += 0.5
|
|
896
|
+
|
|
897
|
+
# Get the output
|
|
898
|
+
output = await self.get_output(terminal_id)
|
|
899
|
+
return output
|
|
900
|
+
|
|
901
|
+
finally:
|
|
902
|
+
# Clean up the temporary terminal
|
|
903
|
+
await self.close_terminal(terminal_id)
|