windows tty

frdel committed Nov 13, 2025 at 19:09 UTC 786f51ca270b5aff963d7039918e10ee6645708f
5 files changed +97 -51
python/helpers/runtime.py
+52 -19
@@ -7,9 +7,10 @@ from python.helpers import dotenv, rfc, settings, files
7 import asyncio
8 import threading
9 import queue
10 +import sys
11
11 -T = TypeVar('T')
12 -R = TypeVar('R')
12 +T = TypeVar("T")
13 +R = TypeVar("R")
14
15 parser = argparse.ArgumentParser()
16 args = {}
@@ -41,31 +42,38 @@ def initialize():
42 key = key.lstrip("-")
43 args[key] = value
44
45 +
46 def get_arg(name: str):
47 global args
48 return args.get(name, None)
49
50 +
51 def has_arg(name: str):
52 global args
53 return name in args
54
55 +
56 def is_dockerized() -> bool:
57 return bool(get_arg("dockerized"))
58
59 +
60 def is_development() -> bool:
61 return not is_dockerized()
62
63 +
64 def get_local_url():
65 if is_dockerized():
66 return "host.docker.internal"
67 return "127.0.0.1"
68
69 +
70 def get_runtime_id() -> str:
71 global runtime_id
72 if not runtime_id:
66 - runtime_id = secrets.token_hex(8)
73 + runtime_id = secrets.token_hex(8)
74 return runtime_id
75
76 +
77 def get_persistent_id() -> str:
78 id = dotenv.get_dotenv_value("A0_PERSISTENT_RUNTIME_ID")
79 if not id:
@@ -73,19 +81,28 @@ def get_persistent_id() -> str:
81 dotenv.save_dotenv_value("A0_PERSISTENT_RUNTIME_ID", id)
82 return id
83
84 +
85 @overload
77 -async def call_development_function(func: Callable[..., Awaitable[T]], *args, **kwargs) -> T: ...
86 +async def call_development_function(
87 + func: Callable[..., Awaitable[T]], *args, **kwargs
88 +) -> T: ...
89 +
90
91 @overload
92 async def call_development_function(func: Callable[..., T], *args, **kwargs) -> T: ...
93
82 -async def call_development_function(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
94 +
95 +async def call_development_function(
96 + func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs
97 +) -> T:
98 if is_development():
99 url = _get_rfc_url()
100 password = _get_rfc_password()
101 # Normalize path components to build a valid Python module path across OSes
87 - module_path = Path(files.deabsolute_path(func.__code__.co_filename)).with_suffix("")
88 - module = ".".join(module_path.parts) # __module__ is not reliable
102 + module_path = Path(
103 + files.deabsolute_path(func.__code__.co_filename)
104 + ).with_suffix("")
105 + module = ".".join(module_path.parts) # __module__ is not reliable
106 result = await rfc.call_rfc(
107 url=url,
108 password=password,
@@ -99,7 +116,7 @@ async def call_development_function(func: Union[Callable[..., T], Callable[...,
116 if inspect.iscoroutinefunction(func):
117 return await func(*args, **kwargs)
118 else:
102 - return func(*args, **kwargs) # type: ignore
119 + return func(*args, **kwargs) # type: ignore
120
121
122 async def handle_rfc(rfc_call: rfc.RFCCall):
@@ -117,45 +134,61 @@ def _get_rfc_url() -> str:
134 set = settings.get_settings()
135 url = set["rfc_url"]
136 if not "://" in url:
120 - url = "http://"+url
137 + url = "http://" + url
138 if url.endswith("/"):
139 url = url[:-1]
123 - url = url+":"+str(set["rfc_port_http"])
140 + url = url + ":" + str(set["rfc_port_http"])
141 url += "/rfc"
142 return url
143
144
128 -def call_development_function_sync(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
145 +def call_development_function_sync(
146 + func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs
147 +) -> T:
148 # run async function in sync manner
149 result_queue = queue.Queue()
131 -
150 +
151 def run_in_thread():
152 result = asyncio.run(call_development_function(func, *args, **kwargs))
153 result_queue.put(result)
135 -
154 +
155 thread = threading.Thread(target=run_in_thread)
156 thread.start()
157 thread.join(timeout=30) # wait for thread with timeout
139 -
158 +
159 if thread.is_alive():
160 raise TimeoutError("Function call timed out after 30 seconds")
142 -
161 +
162 result = result_queue.get_nowait()
163 return cast(T, result)
164
165
166 def get_web_ui_port():
167 web_ui_port = (
149 - get_arg("port")
150 - or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0))
151 - or 5000
168 + get_arg("port") or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0)) or 5000
169 )
170 return web_ui_port
171
172 +
173 def get_tunnel_api_port():
174 tunnel_api_port = (
175 get_arg("tunnel_api_port")
176 or int(dotenv.get_dotenv_value("TUNNEL_API_PORT", 0))
177 or 55520
178 )
161 - return tunnel_api_port
\ No newline at end of file
179 + return tunnel_api_port
180 +
181 +
182 +def get_platform():
183 + return sys.platform
184 +
185 +
186 +def is_windows():
187 + return get_platform() == "win32"
188 +
189 +
190 +def get_terminal_executable():
191 + if is_windows():
192 + return "powershell.exe"
193 + else:
194 + return "/bin/bash"
python/helpers/shell_local.py
+3 -2
@@ -1,9 +1,10 @@
1 +import platform
2 import select
3 import subprocess
4 import time
5 import sys
6 from typing import Optional, Tuple
6 -from python.helpers import tty_session
7 +from python.helpers import tty_session, runtime
8 from python.helpers.shell_ssh import clean_string
9
10 class LocalInteractiveSession:
@@ -13,7 +14,7 @@ class LocalInteractiveSession:
14 self.cwd = cwd
15
16 async def connect(self):
16 - self.session = tty_session.TTYSession("/bin/bash", cwd=self.cwd)
17 + self.session = tty_session.TTYSession(runtime.get_terminal_executable(), cwd=self.cwd)
18 await self.session.start()
19 await self.session.read_full_until_idle(idle_timeout=1, total_timeout=1)
20
python/helpers/tty_session.py
+37 -26
@@ -204,55 +204,66 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
204
205
206 async def _spawn_winpty(cmd, cwd, env, echo):
207 - # A quick way to silence command echo in cmd.exe is /Q (quiet)
208 - if not echo and cmd.strip().lower().startswith("cmd") and "/q" not in cmd.lower():
209 - cmd = cmd.replace("cmd.exe", "cmd.exe /Q")
207 + # Clean PowerShell startup: no logo, no profile, bypass execution policy for deterministic behavior
208 + if cmd.strip().lower().startswith("powershell"):
209 + if "-nolog" not in cmd.lower():
210 + cmd = cmd.replace("powershell.exe", "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass", 1)
211
212 cols, rows = 80, 25
212 - pty = winpty.PTY(cols, rows) # type: ignore
213 - # winpty expects env as None or list of "KEY=VALUE" strings, not a dict
214 - winpty_env = None if env is None else [f"{k}={v}" for k, v in env.items()]
215 - child = pty.spawn(cmd, cwd=cwd or os.getcwd(), env=winpty_env)
216 -
217 - master_r_fd = msvcrt.open_osfhandle(child.conout_pipe, os.O_RDONLY) # type: ignore
218 - master_w_fd = msvcrt.open_osfhandle(child.conin_pipe, 0) # type: ignore
213 + child = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols), cwd=cwd or os.getcwd(), env=env) # type: ignore
214
215 loop = asyncio.get_running_loop()
216 reader = asyncio.StreamReader()
217
223 - def _on_data():
224 - try:
225 - data = os.read(master_r_fd, 1 << 16)
226 - except OSError:
227 - data = b""
228 - if data:
229 - reader.feed_data(data)
230 - else:
231 - reader.feed_eof()
232 - loop.remove_reader(master_r_fd)
218 + async def _on_data():
219 + while child.isalive():
220 + try:
221 + # Run blocking read in executor to not block event loop
222 + data = await loop.run_in_executor(None, child.read, 1 << 16)
223 + if data:
224 + reader.feed_data(data.encode('utf-8') if isinstance(data, str) else data)
225 + except EOFError:
226 + break
227 + except Exception:
228 + await asyncio.sleep(0.01)
229 + reader.feed_eof()
230
234 - loop.add_reader(master_r_fd, _on_data)
231 + # Start pumping output in background
232 + asyncio.create_task(_on_data())
233
234 class _Stdin:
235 def write(self, d):
238 - os.write(master_w_fd, d)
236 + # Use winpty's write method, not os.write
237 + if isinstance(d, bytes):
238 + d = d.decode('utf-8', errors='replace')
239 + # Windows needs \r\n for proper line endings
240 + if _IS_WIN:
241 + d = d.replace('\n', '\r\n')
242 + child.write(d)
243
244 async def drain(self):
241 - await asyncio.sleep(0)
245 + await asyncio.sleep(0.01) # Give write time to complete
246
243 - class _Proc(asyncio.subprocess.Process):
247 + class _Proc:
248 def __init__(self):
249 self.stdin = _Stdin() # type: ignore
250 self.stdout = reader
251 self.pid = child.pid
252 + self.returncode = None
253
254 async def wait(self):
255 while child.isalive():
256 await asyncio.sleep(0.2)
257 + self.returncode = 0
258 return 0
259
260 + def terminate(self):
261 + if child.isalive():
262 + child.terminate()
263 +
264 def kill(self):
255 - child.kill()
265 + if child.isalive():
266 + child.kill()
267
268 return _Proc()
269
@@ -261,7 +272,7 @@ async def _spawn_winpty(cmd, cwd, env, echo):
272 if __name__ == "__main__":
273
274 async def interactive_shell():
264 - shell_cmd, prompt_hint = ("cmd.exe", "$") if _IS_WIN else ("/bin/bash", "$")
275 + shell_cmd, prompt_hint = ("powershell.exe", ">") if _IS_WIN else ("/bin/bash", "$")
276
277 # echo=False → suppress the shell’s own echo of commands
278 term = TTYSession(shell_cmd)
python/tools/code_execution_tool.py
+4 -4
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3 import shlex
4 import time
5 from python.helpers.tool import Tool, Response
6 -from python.helpers import files, rfc_exchange, projects
6 +from python.helpers import files, rfc_exchange, projects, runtime
7 from python.helpers.print_style import PrintStyle
8 from python.helpers.shell_local import LocalInteractiveSession
9 from python.helpers.shell_ssh import SSHInteractiveSession
@@ -12,7 +12,6 @@ from python.helpers.strings import truncate_text as truncate_text_string
12 from python.helpers.messages import truncate_text as truncate_text_agent
13 import re
14
15 -
15 # Timeouts for python, nodejs, and terminal runtimes.
16 CODE_EXEC_TIMEOUTS: dict[str, int] = {
17 "first_output_timeout": 30,
@@ -48,6 +47,7 @@ class CodeExecution(Tool):
47 re.compile(r"\\(venv\\).+[$#] ?$"), # (venv) ...$ or (venv) ...#
48 re.compile(r"root@[^:]+:[^#]+# ?$"), # root@container:~#
49 re.compile(r"[a-zA-Z0-9_.-]+@[^:]+:[^$#]+[$#] ?$"), # user@host:~$
50 + re.compile(r"\(?.*\)?\s*PS\s+[^>]+> ?$"), # PowerShell prompt like (base) PS C:\...>
51 ]
52 # potential dialog detection
53 dialog_patterns = [
@@ -173,7 +173,7 @@ class CodeExecution(Tool):
173 async def execute_terminal_command(
174 self, session: int, command: str, reset: bool = False
175 ):
176 - prefix = "bash> " + self.format_command_for_output(command) + "\n\n"
176 + prefix = ("bash>" if not runtime.is_windows() or self.agent.config.code_exec_ssh_enabled else "PS>") + self.format_command_for_output(command) + "\n\n"
177 return await self.terminal_session(session, command, reset, prefix)
178
179 async def terminal_session(
@@ -467,7 +467,7 @@ class CodeExecution(Tool):
467 # remove any single byte \xXX escapes
468 output = re.sub(r"(?<!\\)\\x[0-9A-Fa-f]{2}", "", output)
469 # Strip every line of output before truncation
470 - output = "\n".join(line.strip() for line in output.splitlines())
470 + # output = "\n".join(line.strip() for line in output.splitlines())
471 output = truncate_text_agent(agent=self.agent, output=output, threshold=1000000) # ~1MB, larger outputs should be dumped to file, not read from terminal
472 return output
473
requirements.txt
+1
@@ -48,3 +48,4 @@ exchangelib>=5.4.3
48 pytest>=8.4.2
49 pytest-asyncio>=1.2.0
50 pytest-mock>=3.15.1
51 +pywinpty==3.0.2; sys_platform == "win32"
\ No newline at end of file