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