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,868 @@
|
|
|
1
|
+
"""Terminal manager implementation for sandbox environments."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
import asyncio
|
|
5
|
+
import re
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, Optional, Callable, Awaitable
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from ..services.base import TerminalManager
|
|
11
|
+
from kolega_code.events import AgentEvent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SandboxTerminalManager(TerminalManager):
|
|
15
|
+
"""Terminal manager that operates within a sandbox."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, sandbox: Any, workspace_id: str, thread_id: str, connection_manager: Any = None):
|
|
18
|
+
"""
|
|
19
|
+
Initialize sandbox terminal manager.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
sandbox: The sandbox instance (e.g., E2B Sandbox)
|
|
23
|
+
workspace_id: ID of the workspace
|
|
24
|
+
thread_id: ID of the thread
|
|
25
|
+
connection_manager: Connection manager for broadcasting events (optional)
|
|
26
|
+
"""
|
|
27
|
+
self.sandbox = sandbox
|
|
28
|
+
self.workspace_id = workspace_id
|
|
29
|
+
self.thread_id = thread_id
|
|
30
|
+
self.connection_manager = connection_manager
|
|
31
|
+
self.terminals: Dict[str, Dict[str, Any]] = {}
|
|
32
|
+
self.outputs: Dict[str, list] = {}
|
|
33
|
+
self._default_terminal_id: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
# Track commands and their status (for interface parity)
|
|
36
|
+
self.command_history: Dict[str, Dict[str, Any]] = {}
|
|
37
|
+
self.command_counter = 0
|
|
38
|
+
|
|
39
|
+
def set_connection_manager(self, connection_manager: Any) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Set the connection manager for streaming terminal output.
|
|
42
|
+
This allows setting it after creation when it becomes available.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
connection_manager: Connection manager for broadcasting events
|
|
46
|
+
"""
|
|
47
|
+
self.connection_manager = connection_manager
|
|
48
|
+
|
|
49
|
+
async def _ensure_default_terminal(self) -> str:
|
|
50
|
+
"""Ensure a default terminal exists and return its ID."""
|
|
51
|
+
if self._default_terminal_id is None or self._default_terminal_id not in self.terminals:
|
|
52
|
+
self._default_terminal_id = await self.launch_terminal()
|
|
53
|
+
return self._default_terminal_id
|
|
54
|
+
|
|
55
|
+
async def get_last_command(self, terminal_id: str) -> str:
|
|
56
|
+
"""Get the last command sent to a terminal."""
|
|
57
|
+
if terminal_id not in self.terminals:
|
|
58
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
59
|
+
|
|
60
|
+
terminal_info = self.terminals[terminal_id]
|
|
61
|
+
return terminal_info.get("last_command", "")
|
|
62
|
+
|
|
63
|
+
async def get_last_command_purpose(self, terminal_id: str) -> str:
|
|
64
|
+
"""Get the purpose of the last command sent to a terminal."""
|
|
65
|
+
if terminal_id not in self.terminals:
|
|
66
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
67
|
+
|
|
68
|
+
terminal_info = self.terminals[terminal_id]
|
|
69
|
+
return terminal_info.get("last_command_purpose", "")
|
|
70
|
+
|
|
71
|
+
def _handle_cd_command(self, command: str, current_dir: str, terminal_info: Dict[str, Any]) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Check if command is a cd command and update terminal's working directory if so.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
command: The command that was executed
|
|
77
|
+
current_dir: The current working directory
|
|
78
|
+
terminal_info: Terminal info dict to update
|
|
79
|
+
"""
|
|
80
|
+
# Check if this is a cd command
|
|
81
|
+
# Match cd followed by path, stopping at ; or && or ||
|
|
82
|
+
cd_match = re.match(r"^\s*cd\s+([^;&|]+)", command.strip())
|
|
83
|
+
if not cd_match:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
new_dir = cd_match.group(1).strip()
|
|
87
|
+
|
|
88
|
+
# Remove quotes if present
|
|
89
|
+
if (new_dir.startswith('"') and new_dir.endswith('"')) or (new_dir.startswith("'") and new_dir.endswith("'")):
|
|
90
|
+
new_dir = new_dir[1:-1]
|
|
91
|
+
|
|
92
|
+
# Handle relative and absolute paths
|
|
93
|
+
if new_dir.startswith("/"):
|
|
94
|
+
# Absolute path
|
|
95
|
+
new_working_dir = new_dir
|
|
96
|
+
elif new_dir == "..":
|
|
97
|
+
# Parent directory
|
|
98
|
+
new_working_dir = os.path.dirname(current_dir.rstrip("/"))
|
|
99
|
+
if not new_working_dir:
|
|
100
|
+
new_working_dir = "/"
|
|
101
|
+
elif new_dir == ".":
|
|
102
|
+
# Current directory (no change)
|
|
103
|
+
new_working_dir = current_dir
|
|
104
|
+
elif new_dir == "~":
|
|
105
|
+
# Home directory
|
|
106
|
+
new_working_dir = "/home/user"
|
|
107
|
+
else:
|
|
108
|
+
# Relative path
|
|
109
|
+
new_working_dir = os.path.join(current_dir, new_dir)
|
|
110
|
+
|
|
111
|
+
# Normalize the path (handle double slashes)
|
|
112
|
+
new_working_dir = os.path.normpath(new_working_dir)
|
|
113
|
+
# Ensure single leading slash for absolute paths
|
|
114
|
+
if new_working_dir.startswith("//"):
|
|
115
|
+
new_working_dir = new_working_dir[1:]
|
|
116
|
+
|
|
117
|
+
# Update the terminal's stored working directory
|
|
118
|
+
terminal_info["cwd"] = new_working_dir
|
|
119
|
+
|
|
120
|
+
async def _create_output_handler(self, terminal_id: str, output_type: str) -> Callable[[str], Awaitable[None]]:
|
|
121
|
+
"""
|
|
122
|
+
Create an async output handler for streaming.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
terminal_id: ID of the terminal
|
|
126
|
+
output_type: Type of output ('stdout' or 'stderr')
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Async callback function for handling output
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
async def handler(data: str) -> None:
|
|
133
|
+
# Store output
|
|
134
|
+
self.outputs[terminal_id].append(
|
|
135
|
+
{"type": output_type, "data": data, "timestamp": datetime.now(timezone.utc)}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Broadcast output immediately for streaming
|
|
139
|
+
if self.connection_manager:
|
|
140
|
+
try:
|
|
141
|
+
terminal_output_event = AgentEvent(
|
|
142
|
+
event_type="terminal_output",
|
|
143
|
+
sender="agent",
|
|
144
|
+
content={
|
|
145
|
+
"output": data,
|
|
146
|
+
"terminal_id": terminal_id,
|
|
147
|
+
"thread_id": self.thread_id,
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
await self.connection_manager.broadcast_event(
|
|
151
|
+
terminal_output_event, self.workspace_id, self.thread_id
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
# Don't let broadcast errors affect command execution
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
return handler
|
|
158
|
+
|
|
159
|
+
async def run_command(self, command: str, cwd: Optional[str] = None, timeout: Optional[int] = None) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Run a command directly (convenience method for utilities).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
command: Command to execute
|
|
165
|
+
cwd: Optional working directory (defaults to /home/user/workspace)
|
|
166
|
+
timeout: Optional timeout in seconds (0 for no timeout, None for default 60s)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Command output as string
|
|
170
|
+
"""
|
|
171
|
+
working_dir = cwd if cwd is not None else "/home/user/workspace"
|
|
172
|
+
|
|
173
|
+
# Convert Path objects to strings for E2B compatibility
|
|
174
|
+
if hasattr(working_dir, "__fspath__"):
|
|
175
|
+
working_dir = str(working_dir)
|
|
176
|
+
|
|
177
|
+
# Ensure the working directory exists (E2B specific fix)
|
|
178
|
+
if working_dir != "/home/user":
|
|
179
|
+
try:
|
|
180
|
+
# Try to create the directory if it doesn't exist
|
|
181
|
+
await self.sandbox.commands.run(f"test -d {working_dir} || mkdir -p {working_dir}")
|
|
182
|
+
except Exception:
|
|
183
|
+
# If we can't create it, fall back to /home/user
|
|
184
|
+
working_dir = "/home/user"
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Determine timeout settings
|
|
188
|
+
sandbox_timeout = timeout if timeout is not None else 60 # Default to 60s for backward compatibility
|
|
189
|
+
|
|
190
|
+
# For utility commands, we don't need streaming
|
|
191
|
+
if sandbox_timeout == 0:
|
|
192
|
+
# No timeout - let it run indefinitely
|
|
193
|
+
result = await self.sandbox.commands.run(command, cwd=working_dir, timeout=0)
|
|
194
|
+
else:
|
|
195
|
+
# Use timeout with buffer for asyncio
|
|
196
|
+
result = await asyncio.wait_for(
|
|
197
|
+
self.sandbox.commands.run(command, cwd=working_dir, timeout=sandbox_timeout),
|
|
198
|
+
timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Return the combined output (stdout + stderr)
|
|
202
|
+
output = ""
|
|
203
|
+
if result.stdout:
|
|
204
|
+
output += result.stdout
|
|
205
|
+
if result.stderr:
|
|
206
|
+
if output:
|
|
207
|
+
output += "\n"
|
|
208
|
+
output += result.stderr
|
|
209
|
+
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
except asyncio.TimeoutError:
|
|
213
|
+
return f"Command execution timed out after {sandbox_timeout + 5} seconds"
|
|
214
|
+
except Exception as e:
|
|
215
|
+
return f"Command failed: {str(e)}"
|
|
216
|
+
|
|
217
|
+
async def launch_terminal(self, terminal_id: Optional[str] = None, **terminal_kwargs) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Launch a new terminal session.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
terminal_id: Optional ID for the terminal. If not provided, generates UUID.
|
|
223
|
+
**terminal_kwargs: Additional terminal options:
|
|
224
|
+
- cwd: Working directory (default: /home/user/workspace)
|
|
225
|
+
- env: Environment variables (default: {})
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Terminal ID
|
|
229
|
+
"""
|
|
230
|
+
if terminal_id is None:
|
|
231
|
+
terminal_id = str(uuid.uuid4())
|
|
232
|
+
|
|
233
|
+
# Extract terminal options
|
|
234
|
+
cwd = terminal_kwargs.get("cwd", "/home/user/workspace")
|
|
235
|
+
env = terminal_kwargs.get("env", {})
|
|
236
|
+
|
|
237
|
+
# Convert Path objects to strings for E2B compatibility
|
|
238
|
+
if hasattr(cwd, "__fspath__"): # Check if it's a Path-like object
|
|
239
|
+
cwd = str(cwd)
|
|
240
|
+
|
|
241
|
+
# Ensure the directory exists (try to create if it doesn't)
|
|
242
|
+
try:
|
|
243
|
+
# Check if directory exists
|
|
244
|
+
await self.sandbox.commands.run(f"test -d {cwd}")
|
|
245
|
+
except Exception:
|
|
246
|
+
# Directory doesn't exist, try to create it
|
|
247
|
+
try:
|
|
248
|
+
await self.sandbox.commands.run(f"mkdir -p {cwd}")
|
|
249
|
+
# Directory now exists (either already existed or was created)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
# If we can't create the directory, fall back to /home/user
|
|
252
|
+
print(f"Warning: Could not ensure directory {cwd} exists: {e}")
|
|
253
|
+
cwd = "/home/user"
|
|
254
|
+
|
|
255
|
+
self.terminals[terminal_id] = {
|
|
256
|
+
"created_at": datetime.now(timezone.utc),
|
|
257
|
+
"cwd": cwd,
|
|
258
|
+
"env": env,
|
|
259
|
+
"process": None,
|
|
260
|
+
"last_command": "",
|
|
261
|
+
"last_command_purpose": "",
|
|
262
|
+
"active_commands": {}, # Track commands for this terminal
|
|
263
|
+
}
|
|
264
|
+
self.outputs[terminal_id] = []
|
|
265
|
+
|
|
266
|
+
return terminal_id
|
|
267
|
+
|
|
268
|
+
async def send_command(
|
|
269
|
+
self, terminal_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
|
|
270
|
+
) -> bool:
|
|
271
|
+
"""
|
|
272
|
+
Send a command to a terminal.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
terminal_id: ID of the terminal
|
|
276
|
+
command: Command to execute
|
|
277
|
+
purpose: Optional description of command purpose
|
|
278
|
+
timeout: Optional timeout in seconds (0 or None for no timeout)
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
True if command was sent successfully
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
KeyError: If terminal doesn't exist
|
|
285
|
+
"""
|
|
286
|
+
if terminal_id not in self.terminals:
|
|
287
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
288
|
+
|
|
289
|
+
terminal_info = self.terminals[terminal_id]
|
|
290
|
+
|
|
291
|
+
# Update last command info
|
|
292
|
+
terminal_info["last_command"] = command.rstrip("\n") # Strip trailing newline for consistency
|
|
293
|
+
terminal_info["last_command_purpose"] = purpose or ""
|
|
294
|
+
|
|
295
|
+
# Use terminal's working directory
|
|
296
|
+
working_dir = terminal_info["cwd"]
|
|
297
|
+
|
|
298
|
+
# Convert Path objects to strings for E2B compatibility
|
|
299
|
+
if hasattr(working_dir, "__fspath__"): # Check if it's a Path-like object
|
|
300
|
+
working_dir = str(working_dir)
|
|
301
|
+
|
|
302
|
+
# Store command in output
|
|
303
|
+
self.outputs[terminal_id].append(
|
|
304
|
+
{"type": "command", "data": command, "timestamp": datetime.now(timezone.utc), "purpose": purpose}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Broadcast command if connection manager available
|
|
308
|
+
if self.connection_manager:
|
|
309
|
+
try:
|
|
310
|
+
await self._broadcast_output(terminal_id, f"$ {command}\n")
|
|
311
|
+
except Exception:
|
|
312
|
+
pass # Don't fail if broadcast fails
|
|
313
|
+
|
|
314
|
+
# Create streaming output handlers
|
|
315
|
+
stdout_handler = await self._create_output_handler(terminal_id, "stdout")
|
|
316
|
+
stderr_handler = await self._create_output_handler(terminal_id, "stderr")
|
|
317
|
+
|
|
318
|
+
# Execute command in sandbox with streaming
|
|
319
|
+
try:
|
|
320
|
+
# Determine timeout settings
|
|
321
|
+
sandbox_timeout = timeout if timeout is not None else 0 # Default to no timeout
|
|
322
|
+
|
|
323
|
+
# If no timeout requested (0), don't use asyncio.wait_for
|
|
324
|
+
if sandbox_timeout == 0:
|
|
325
|
+
result = await self.sandbox.commands.run(
|
|
326
|
+
command,
|
|
327
|
+
cwd=working_dir,
|
|
328
|
+
on_stdout=stdout_handler,
|
|
329
|
+
on_stderr=stderr_handler,
|
|
330
|
+
timeout=0, # No timeout for sandbox
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
# Use timeout with buffer for asyncio
|
|
334
|
+
result = await asyncio.wait_for(
|
|
335
|
+
self.sandbox.commands.run(
|
|
336
|
+
command,
|
|
337
|
+
cwd=working_dir,
|
|
338
|
+
on_stdout=stdout_handler,
|
|
339
|
+
on_stderr=stderr_handler,
|
|
340
|
+
timeout=sandbox_timeout,
|
|
341
|
+
),
|
|
342
|
+
timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Store exit code
|
|
346
|
+
self.outputs[terminal_id].append(
|
|
347
|
+
{
|
|
348
|
+
"type": "exit",
|
|
349
|
+
"data": f"Process exited with code {result.exit_code}",
|
|
350
|
+
"exit_code": result.exit_code,
|
|
351
|
+
"timestamp": datetime.now(timezone.utc),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Broadcast exit status
|
|
356
|
+
if self.connection_manager:
|
|
357
|
+
await self._broadcast_output(terminal_id, f"Process exited with code {result.exit_code}\n")
|
|
358
|
+
|
|
359
|
+
# If it was a successful cd command, update the terminal's working directory
|
|
360
|
+
if result.exit_code == 0:
|
|
361
|
+
self._handle_cd_command(command, working_dir, terminal_info)
|
|
362
|
+
|
|
363
|
+
return result.exit_code == 0 # Return True only if command succeeded
|
|
364
|
+
|
|
365
|
+
except asyncio.TimeoutError:
|
|
366
|
+
# Handle timeout specifically
|
|
367
|
+
error_msg = f"Command execution timed out after {sandbox_timeout + 5} seconds"
|
|
368
|
+
self.outputs[terminal_id].append(
|
|
369
|
+
{"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Broadcast error
|
|
373
|
+
if self.connection_manager:
|
|
374
|
+
await self._broadcast_output(terminal_id, error_msg)
|
|
375
|
+
|
|
376
|
+
self.outputs[terminal_id].append(
|
|
377
|
+
{
|
|
378
|
+
"type": "exit",
|
|
379
|
+
"data": "Process exited with code 1",
|
|
380
|
+
"exit_code": 1,
|
|
381
|
+
"timestamp": datetime.now(timezone.utc),
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Broadcast exit status
|
|
386
|
+
if self.connection_manager:
|
|
387
|
+
await self._broadcast_output(terminal_id, "Process exited with code 1\n")
|
|
388
|
+
|
|
389
|
+
return False # Command failed due to timeout
|
|
390
|
+
|
|
391
|
+
except Exception as e:
|
|
392
|
+
# Store error
|
|
393
|
+
error_msg = f"Command failed: {str(e)}"
|
|
394
|
+
self.outputs[terminal_id].append(
|
|
395
|
+
{"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Broadcast error
|
|
399
|
+
if self.connection_manager:
|
|
400
|
+
await self._broadcast_output(terminal_id, error_msg)
|
|
401
|
+
|
|
402
|
+
self.outputs[terminal_id].append(
|
|
403
|
+
{
|
|
404
|
+
"type": "exit",
|
|
405
|
+
"data": "Process exited with code 1",
|
|
406
|
+
"exit_code": 1,
|
|
407
|
+
"timestamp": datetime.now(timezone.utc),
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# Broadcast exit status
|
|
412
|
+
if self.connection_manager:
|
|
413
|
+
await self._broadcast_output(terminal_id, "Process exited with code 1\n")
|
|
414
|
+
|
|
415
|
+
return False # Command failed
|
|
416
|
+
|
|
417
|
+
async def send_input(
|
|
418
|
+
self, terminal_id: str, text: str, submit: bool = True, command_id: Optional[str] = None
|
|
419
|
+
) -> bool:
|
|
420
|
+
"""
|
|
421
|
+
Send input to an active tracked command in the sandbox.
|
|
422
|
+
"""
|
|
423
|
+
if terminal_id not in self.terminals:
|
|
424
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
425
|
+
|
|
426
|
+
terminal_info = self.terminals[terminal_id]
|
|
427
|
+
active_commands = terminal_info["active_commands"]
|
|
428
|
+
|
|
429
|
+
if command_id is None:
|
|
430
|
+
if not active_commands:
|
|
431
|
+
raise ValueError(f"No active command is running in terminal {terminal_id}")
|
|
432
|
+
if len(active_commands) > 1:
|
|
433
|
+
raise ValueError(f"Multiple active commands are running in terminal {terminal_id}; provide command_id")
|
|
434
|
+
command_id = next(iter(active_commands))
|
|
435
|
+
|
|
436
|
+
command_info = self.command_history.get(command_id)
|
|
437
|
+
if not command_info or command_info.get("terminal_id") != terminal_id:
|
|
438
|
+
raise ValueError(f"Command ID {command_id} not found in terminal {terminal_id}")
|
|
439
|
+
if command_info.get("status") != "running":
|
|
440
|
+
raise ValueError(f"Command {command_id} is not running in terminal {terminal_id}")
|
|
441
|
+
|
|
442
|
+
pid = command_info.get("pid")
|
|
443
|
+
if pid is None:
|
|
444
|
+
raise ValueError(f"Command {command_id} is not ready for input yet")
|
|
445
|
+
|
|
446
|
+
payload = text
|
|
447
|
+
if submit and not payload.endswith("\n"):
|
|
448
|
+
payload += "\n"
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
await self.sandbox.commands.send_stdin(pid, payload)
|
|
452
|
+
return True
|
|
453
|
+
except AttributeError as exc:
|
|
454
|
+
raise ValueError("Sandbox command stdin is not supported by this E2B SDK version") from exc
|
|
455
|
+
|
|
456
|
+
async def _broadcast_output(self, terminal_id: str, output: str):
|
|
457
|
+
"""Broadcast terminal output to connected clients."""
|
|
458
|
+
if not self.connection_manager:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
terminal_output_event = AgentEvent(
|
|
463
|
+
event_type="terminal_output",
|
|
464
|
+
sender="agent",
|
|
465
|
+
content={
|
|
466
|
+
"output": output,
|
|
467
|
+
"terminal_id": terminal_id,
|
|
468
|
+
"thread_id": self.thread_id,
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
await self.connection_manager.broadcast_event(terminal_output_event, self.workspace_id, self.thread_id)
|
|
472
|
+
except Exception:
|
|
473
|
+
# Don't let broadcast errors affect command execution
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
async def send_command_tracked(
|
|
477
|
+
self, terminal_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
|
|
478
|
+
) -> Optional[str]:
|
|
479
|
+
"""
|
|
480
|
+
Send a command and return a command ID for tracking.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
terminal_id: ID of the terminal to send command to
|
|
484
|
+
command: The command to execute
|
|
485
|
+
purpose: Optional description of the command's purpose
|
|
486
|
+
timeout: Optional timeout in seconds (0 or None for no timeout)
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Command ID for tracking, or None if command couldn't be sent
|
|
490
|
+
|
|
491
|
+
Raises:
|
|
492
|
+
KeyError: If terminal doesn't exist
|
|
493
|
+
"""
|
|
494
|
+
if terminal_id not in self.terminals:
|
|
495
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
496
|
+
|
|
497
|
+
# Generate command ID
|
|
498
|
+
self.command_counter += 1
|
|
499
|
+
command_id = f"{terminal_id}_{self.command_counter}"
|
|
500
|
+
|
|
501
|
+
# Record command in history
|
|
502
|
+
start_time = datetime.now(timezone.utc)
|
|
503
|
+
self.command_history[command_id] = {
|
|
504
|
+
"command": command.strip(),
|
|
505
|
+
"purpose": purpose,
|
|
506
|
+
"terminal_id": terminal_id,
|
|
507
|
+
"start_time": start_time,
|
|
508
|
+
"status": "running",
|
|
509
|
+
"return_code": None,
|
|
510
|
+
"pid": None,
|
|
511
|
+
"handle": None,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
# Also track in terminal's active commands
|
|
515
|
+
self.terminals[terminal_id]["active_commands"][command_id] = self.command_history[command_id]
|
|
516
|
+
|
|
517
|
+
# Get terminal info
|
|
518
|
+
terminal_info = self.terminals[terminal_id]
|
|
519
|
+
working_dir = terminal_info["cwd"]
|
|
520
|
+
|
|
521
|
+
# Store command in output
|
|
522
|
+
self.outputs[terminal_id].append(
|
|
523
|
+
{"type": "command", "data": command, "timestamp": datetime.now(timezone.utc), "purpose": purpose}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Broadcast command
|
|
527
|
+
if self.connection_manager:
|
|
528
|
+
await self._broadcast_output(terminal_id, f"$ {command}\n")
|
|
529
|
+
|
|
530
|
+
# Start command execution asynchronously without waiting
|
|
531
|
+
asyncio.create_task(self._execute_command_async(command_id, terminal_id, command, working_dir, timeout))
|
|
532
|
+
|
|
533
|
+
return command_id
|
|
534
|
+
|
|
535
|
+
async def _execute_command_async(
|
|
536
|
+
self, command_id: str, terminal_id: str, command: str, working_dir: str, timeout: Optional[int] = None
|
|
537
|
+
):
|
|
538
|
+
"""Execute a command asynchronously and track its status."""
|
|
539
|
+
try:
|
|
540
|
+
# Convert Path objects to strings for E2B compatibility
|
|
541
|
+
if hasattr(working_dir, "__fspath__"): # Check if it's a Path-like object
|
|
542
|
+
working_dir = str(working_dir)
|
|
543
|
+
|
|
544
|
+
# Create streaming output handlers
|
|
545
|
+
stdout_handler = await self._create_output_handler(terminal_id, "stdout")
|
|
546
|
+
stderr_handler = await self._create_output_handler(terminal_id, "stderr")
|
|
547
|
+
|
|
548
|
+
# Determine timeout settings
|
|
549
|
+
sandbox_timeout = timeout if timeout is not None else 0 # Default to no timeout
|
|
550
|
+
|
|
551
|
+
# Execute with streaming and keep stdin open for interactive prompts.
|
|
552
|
+
try:
|
|
553
|
+
if sandbox_timeout == 0:
|
|
554
|
+
handle = await self.sandbox.commands.run(
|
|
555
|
+
command,
|
|
556
|
+
background=True,
|
|
557
|
+
cwd=working_dir,
|
|
558
|
+
on_stdout=stdout_handler,
|
|
559
|
+
on_stderr=stderr_handler,
|
|
560
|
+
stdin=True,
|
|
561
|
+
timeout=0,
|
|
562
|
+
)
|
|
563
|
+
self.command_history[command_id]["pid"] = handle.pid
|
|
564
|
+
self.command_history[command_id]["handle"] = handle
|
|
565
|
+
result = await handle.wait()
|
|
566
|
+
else:
|
|
567
|
+
handle = await self.sandbox.commands.run(
|
|
568
|
+
command,
|
|
569
|
+
background=True,
|
|
570
|
+
cwd=working_dir,
|
|
571
|
+
on_stdout=stdout_handler,
|
|
572
|
+
on_stderr=stderr_handler,
|
|
573
|
+
stdin=True,
|
|
574
|
+
timeout=sandbox_timeout,
|
|
575
|
+
)
|
|
576
|
+
self.command_history[command_id]["pid"] = handle.pid
|
|
577
|
+
self.command_history[command_id]["handle"] = handle
|
|
578
|
+
result = await asyncio.wait_for(
|
|
579
|
+
handle.wait(),
|
|
580
|
+
timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
|
|
581
|
+
)
|
|
582
|
+
except asyncio.TimeoutError:
|
|
583
|
+
# If the sandbox itself times out or hangs
|
|
584
|
+
raise Exception(f"Command execution timed out after {sandbox_timeout + 5} seconds")
|
|
585
|
+
|
|
586
|
+
# Command completed
|
|
587
|
+
self.command_history[command_id]["status"] = "completed"
|
|
588
|
+
self.command_history[command_id]["return_code"] = result.exit_code
|
|
589
|
+
self.command_history[command_id]["end_time"] = datetime.now(timezone.utc)
|
|
590
|
+
|
|
591
|
+
# Store exit code
|
|
592
|
+
self.outputs[terminal_id].append(
|
|
593
|
+
{
|
|
594
|
+
"type": "exit",
|
|
595
|
+
"data": f"Process exited with code {result.exit_code}",
|
|
596
|
+
"exit_code": result.exit_code,
|
|
597
|
+
"timestamp": datetime.now(timezone.utc),
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Broadcast exit status
|
|
602
|
+
if self.connection_manager:
|
|
603
|
+
await self._broadcast_output(terminal_id, f"Process exited with code {result.exit_code}\n")
|
|
604
|
+
|
|
605
|
+
# If it was a successful cd command, update the terminal's working directory
|
|
606
|
+
if result.exit_code == 0 and terminal_id in self.terminals:
|
|
607
|
+
terminal_info = self.terminals[terminal_id]
|
|
608
|
+
self._handle_cd_command(command, working_dir, terminal_info)
|
|
609
|
+
|
|
610
|
+
# Remove from active commands
|
|
611
|
+
if terminal_id in self.terminals:
|
|
612
|
+
self.terminals[terminal_id]["active_commands"].pop(command_id, None)
|
|
613
|
+
|
|
614
|
+
except Exception as e:
|
|
615
|
+
# Command failed
|
|
616
|
+
self.command_history[command_id]["status"] = "failed"
|
|
617
|
+
self.command_history[command_id]["return_code"] = 1
|
|
618
|
+
self.command_history[command_id]["end_time"] = datetime.now(timezone.utc)
|
|
619
|
+
|
|
620
|
+
# Store error
|
|
621
|
+
error_msg = f"Command failed: {str(e)}"
|
|
622
|
+
self.outputs[terminal_id].append(
|
|
623
|
+
{"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Broadcast error
|
|
627
|
+
if self.connection_manager:
|
|
628
|
+
await self._broadcast_output(terminal_id, error_msg)
|
|
629
|
+
|
|
630
|
+
# Store exit info
|
|
631
|
+
self.outputs[terminal_id].append(
|
|
632
|
+
{
|
|
633
|
+
"type": "exit",
|
|
634
|
+
"data": "Process exited with code 1",
|
|
635
|
+
"exit_code": 1,
|
|
636
|
+
"timestamp": datetime.now(timezone.utc),
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Broadcast exit status
|
|
641
|
+
if self.connection_manager:
|
|
642
|
+
await self._broadcast_output(terminal_id, "Process exited with code 1\n")
|
|
643
|
+
|
|
644
|
+
# Remove from active commands
|
|
645
|
+
if terminal_id in self.terminals:
|
|
646
|
+
self.terminals[terminal_id]["active_commands"].pop(command_id, None)
|
|
647
|
+
|
|
648
|
+
def read_output(self, terminal_id: str, num_chars: int = 1024, offset: int = 0) -> str:
|
|
649
|
+
"""
|
|
650
|
+
Read characters from a terminal's output buffer.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
terminal_id: ID of the terminal to read output from
|
|
654
|
+
num_chars: Number of characters to read (default: 1024).
|
|
655
|
+
offset: Number of characters from the end to start reading from (default: 0).
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
The requested characters from the terminal's output buffer.
|
|
659
|
+
|
|
660
|
+
Raises:
|
|
661
|
+
KeyError: If terminal doesn't exist
|
|
662
|
+
"""
|
|
663
|
+
if terminal_id not in self.terminals:
|
|
664
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
665
|
+
|
|
666
|
+
# Reconstruct full output from stored outputs
|
|
667
|
+
full_output = ""
|
|
668
|
+
for output in self.outputs[terminal_id]:
|
|
669
|
+
if output["type"] == "command":
|
|
670
|
+
full_output += f"$ {output['data']}\n"
|
|
671
|
+
elif output["type"] in ["stdout", "stderr"]:
|
|
672
|
+
full_output += output["data"]
|
|
673
|
+
if not output["data"].endswith("\n"):
|
|
674
|
+
full_output += "\n"
|
|
675
|
+
elif output["type"] == "exit":
|
|
676
|
+
full_output += f"{output['data']}\n"
|
|
677
|
+
|
|
678
|
+
# Apply offset and num_chars logic similar to LocalTerminalManager
|
|
679
|
+
total_chars = len(full_output)
|
|
680
|
+
|
|
681
|
+
if total_chars == 0:
|
|
682
|
+
return ""
|
|
683
|
+
|
|
684
|
+
if offset == 0:
|
|
685
|
+
# Default behavior: read last num_chars characters
|
|
686
|
+
if total_chars <= num_chars:
|
|
687
|
+
return full_output
|
|
688
|
+
else:
|
|
689
|
+
return full_output[-num_chars:]
|
|
690
|
+
else:
|
|
691
|
+
# With offset: read num_chars characters starting from (end - offset - num_chars)
|
|
692
|
+
start_pos = max(0, total_chars - offset - num_chars)
|
|
693
|
+
end_pos = max(0, total_chars - offset)
|
|
694
|
+
|
|
695
|
+
if start_pos >= end_pos:
|
|
696
|
+
return ""
|
|
697
|
+
|
|
698
|
+
return full_output[start_pos:end_pos]
|
|
699
|
+
|
|
700
|
+
def get_command_status(self, terminal_id: str, command_id: str) -> dict:
|
|
701
|
+
"""
|
|
702
|
+
Get the status of a specific command.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
terminal_id: ID of the terminal
|
|
706
|
+
command_id: ID of the command to check
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Dictionary containing command status information
|
|
710
|
+
|
|
711
|
+
Raises:
|
|
712
|
+
KeyError: If terminal doesn't exist
|
|
713
|
+
"""
|
|
714
|
+
if terminal_id not in self.terminals:
|
|
715
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
716
|
+
|
|
717
|
+
if command_id not in self.command_history:
|
|
718
|
+
return {"status": "not_found"}
|
|
719
|
+
|
|
720
|
+
command_info = self.command_history[command_id]
|
|
721
|
+
|
|
722
|
+
# Calculate duration
|
|
723
|
+
if "end_time" in command_info:
|
|
724
|
+
duration = (command_info["end_time"] - command_info["start_time"]).total_seconds()
|
|
725
|
+
else:
|
|
726
|
+
duration = (datetime.now(timezone.utc) - command_info["start_time"]).total_seconds()
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
"status": command_info["status"],
|
|
730
|
+
"command": command_info["command"],
|
|
731
|
+
"purpose": command_info.get("purpose"),
|
|
732
|
+
"duration": duration,
|
|
733
|
+
"return_code": command_info.get("return_code"),
|
|
734
|
+
"child_pids": [], # No child process tracking in sandbox
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async def get_terminal_status(self, terminal_id: str) -> dict:
|
|
738
|
+
"""
|
|
739
|
+
Get comprehensive status of a terminal including active commands.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
terminal_id: ID of the terminal to check
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
Dictionary containing terminal status and active commands
|
|
746
|
+
|
|
747
|
+
Raises:
|
|
748
|
+
KeyError: If terminal doesn't exist
|
|
749
|
+
"""
|
|
750
|
+
if terminal_id not in self.terminals:
|
|
751
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
752
|
+
|
|
753
|
+
terminal_info = self.terminals[terminal_id]
|
|
754
|
+
|
|
755
|
+
# Get active commands for this terminal
|
|
756
|
+
active_commands = {}
|
|
757
|
+
for cmd_id, cmd_info in terminal_info["active_commands"].items():
|
|
758
|
+
active_commands[cmd_id] = self.get_command_status(terminal_id, cmd_id)
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
"running": True, # Sandbox terminals are always "running"
|
|
762
|
+
"ready_for_commands": len(active_commands) == 0,
|
|
763
|
+
"active_commands": active_commands,
|
|
764
|
+
"last_command": terminal_info.get("last_command", ""),
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async def cleanup_all(self):
|
|
768
|
+
"""Clean up all terminals - useful for interrupt handling"""
|
|
769
|
+
print(f"Cleaning up {len(self.terminals)} terminal(s)")
|
|
770
|
+
|
|
771
|
+
terminal_ids = list(self.terminals.keys())
|
|
772
|
+
for terminal_id in terminal_ids:
|
|
773
|
+
try:
|
|
774
|
+
await self.close_terminal(terminal_id)
|
|
775
|
+
print(f"Closed terminal {terminal_id}")
|
|
776
|
+
except Exception as e:
|
|
777
|
+
print(f"Error closing terminal {terminal_id}: {e}")
|
|
778
|
+
self.terminals.clear()
|
|
779
|
+
self.outputs.clear()
|
|
780
|
+
self.command_history.clear()
|
|
781
|
+
|
|
782
|
+
def _handle_output(self, terminal_id: str, data: str, stream: str):
|
|
783
|
+
"""Handle output from process."""
|
|
784
|
+
self.outputs[terminal_id].append({"type": stream, "data": data, "timestamp": datetime.now(timezone.utc)})
|
|
785
|
+
|
|
786
|
+
async def get_output(self, terminal_id: str, **kwargs) -> str:
|
|
787
|
+
"""
|
|
788
|
+
Get output from a terminal.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
terminal_id: ID of the terminal
|
|
792
|
+
**kwargs: Optional filters (last_n_lines, since_timestamp, etc.)
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
Terminal output as string
|
|
796
|
+
|
|
797
|
+
Raises:
|
|
798
|
+
KeyError: If terminal doesn't exist
|
|
799
|
+
"""
|
|
800
|
+
if terminal_id not in self.terminals:
|
|
801
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
802
|
+
|
|
803
|
+
outputs = self.outputs[terminal_id]
|
|
804
|
+
|
|
805
|
+
# Apply filters if provided
|
|
806
|
+
last_n_lines = kwargs.get("last_n_lines")
|
|
807
|
+
since_timestamp = kwargs.get("since_timestamp")
|
|
808
|
+
|
|
809
|
+
filtered_outputs = outputs
|
|
810
|
+
|
|
811
|
+
if since_timestamp:
|
|
812
|
+
filtered_outputs = [o for o in filtered_outputs if o["timestamp"] > since_timestamp]
|
|
813
|
+
|
|
814
|
+
# Convert to string
|
|
815
|
+
lines = []
|
|
816
|
+
for output in filtered_outputs:
|
|
817
|
+
if output["type"] in ["stdout", "stderr"]:
|
|
818
|
+
lines.append(output["data"])
|
|
819
|
+
elif output["type"] == "command":
|
|
820
|
+
lines.append(f"$ {output['data']}")
|
|
821
|
+
elif output["type"] == "exit":
|
|
822
|
+
lines.append(output["data"])
|
|
823
|
+
|
|
824
|
+
if last_n_lines and len(lines) > last_n_lines:
|
|
825
|
+
lines = lines[-last_n_lines:]
|
|
826
|
+
|
|
827
|
+
return "\n".join(lines)
|
|
828
|
+
|
|
829
|
+
async def close_terminal(self, terminal_id: str) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Close a terminal.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
terminal_id: ID of the terminal to close
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
KeyError: If terminal doesn't exist
|
|
838
|
+
"""
|
|
839
|
+
if terminal_id not in self.terminals:
|
|
840
|
+
raise KeyError(f"Terminal {terminal_id} not found")
|
|
841
|
+
|
|
842
|
+
# Clean up
|
|
843
|
+
del self.terminals[terminal_id]
|
|
844
|
+
del self.outputs[terminal_id]
|
|
845
|
+
|
|
846
|
+
async def close_all(self) -> None:
|
|
847
|
+
"""Close all terminals."""
|
|
848
|
+
terminal_ids = list(self.terminals.keys())
|
|
849
|
+
for terminal_id in terminal_ids:
|
|
850
|
+
await self.close_terminal(terminal_id)
|
|
851
|
+
|
|
852
|
+
async def list_terminals(self) -> Dict[str, Any]:
|
|
853
|
+
"""
|
|
854
|
+
Get information about all terminals.
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
Dictionary mapping terminal IDs to terminal info
|
|
858
|
+
"""
|
|
859
|
+
result = {}
|
|
860
|
+
for terminal_id, info in self.terminals.items():
|
|
861
|
+
result[terminal_id] = {
|
|
862
|
+
"created_at": info["created_at"].isoformat(),
|
|
863
|
+
"cwd": info["cwd"],
|
|
864
|
+
"has_running_process": info.get("process") is not None,
|
|
865
|
+
"running": True, # Match LocalTerminalManager format
|
|
866
|
+
"last_command": info.get("last_command", ""),
|
|
867
|
+
}
|
|
868
|
+
return result
|