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.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. 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)