Skip to content

Commit 4dbfb7f

Browse files
committed
Merge branch 'develop' of https://github.com/EvolutionAPI/evo-nexus into develop
2 parents d91e876 + 2224bb3 commit 4dbfb7f

3 files changed

Lines changed: 173 additions & 83 deletions

File tree

dashboard/backend/routes/terminal.py

Lines changed: 151 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
"""Terminal endpoint — WebSocket PTY for real terminal sessions."""
22

33
import os
4-
import pty
5-
import select
6-
import signal
7-
import struct
8-
import fcntl
9-
import termios
10-
import subprocess
4+
import sys
115
import json
126
import threading
137
import time
@@ -22,6 +16,18 @@
2216
bp = Blueprint("terminal", __name__)
2317
sock = Sock()
2418

19+
_WINDOWS = sys.platform == "win32"
20+
21+
if _WINDOWS:
22+
from winpty import PtyProcess
23+
else:
24+
import pty
25+
import select
26+
import signal
27+
import struct
28+
import fcntl
29+
import termios
30+
2531
# Store active sessions: {session_id: {pid, fd}}
2632
sessions = {}
2733
session_counter = 0
@@ -94,7 +100,7 @@ def _parse_session_file(filepath, max_lines=20):
94100

95101

96102
def _spawn_pty(cmd, cwd):
97-
"""Spawn a process in a PTY and return (pid, fd)."""
103+
"""Spawn a process in a PTY and return (pid, fd). Unix only."""
98104
env = os.environ.copy()
99105
env["TERM"] = "xterm-256color"
100106
env["COLUMNS"] = "120"
@@ -109,22 +115,34 @@ def _spawn_pty(cmd, cwd):
109115

110116

111117
def _set_winsize(fd, rows, cols):
112-
"""Set terminal window size."""
118+
"""Set terminal window size. Unix only."""
113119
winsize = struct.pack("HHHH", rows, cols, 0, 0)
114120
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
115121

116122

123+
def _spawn_conpty(cmd, cwd):
124+
"""Spawn a process in a Windows ConPTY and return a PtyProcess."""
125+
env = os.environ.copy()
126+
env["TERM"] = "xterm-256color"
127+
return PtyProcess.spawn(cmd, cwd=str(cwd), env=env, dimensions=(40, 120))
128+
129+
117130
@bp.route("/api/terminal/sessions")
118131
@login_required
119132
def list_sessions():
120133
"""List active terminal sessions."""
121134
result = []
122135
for sid, info in sessions.items():
123-
try:
124-
os.kill(info["pid"], 0) # Check if alive
125-
result.append({"id": sid, "cmd": info["cmd"], "alive": True})
126-
except OSError:
127-
result.append({"id": sid, "cmd": info["cmd"], "alive": False})
136+
if _WINDOWS:
137+
proc = info.get("process")
138+
alive = proc.isalive() if proc else False
139+
else:
140+
try:
141+
os.kill(info["pid"], 0)
142+
alive = True
143+
except OSError:
144+
alive = False
145+
result.append({"id": sid, "cmd": info["cmd"], "alive": alive})
128146
return {"sessions": result}
129147

130148

@@ -184,15 +202,19 @@ def create_session():
184202
return {"error": "Session not found"}, 404
185203
cmd.extend(["--resume", resume_id])
186204
elif cmd_type == "shell":
187-
cmd = [os.environ.get("SHELL", "/bin/zsh")]
205+
cmd = ["cmd.exe"] if _WINDOWS else [os.environ.get("SHELL", "/bin/zsh")]
188206
else:
189207
cmd = ["claude", "--dangerously-skip-permissions"]
190208

191209
try:
192-
pid, fd = _spawn_pty(cmd, WORKSPACE)
193210
session_counter += 1
194211
sid = f"term-{session_counter}"
195-
sessions[sid] = {"pid": pid, "fd": fd, "cmd": " ".join(cmd)}
212+
if _WINDOWS:
213+
proc = _spawn_conpty(cmd, WORKSPACE)
214+
sessions[sid] = {"process": proc, "cmd": " ".join(cmd)}
215+
else:
216+
pid, fd = _spawn_pty(cmd, WORKSPACE)
217+
sessions[sid] = {"pid": pid, "fd": fd, "cmd": " ".join(cmd)}
196218
return {"id": sid, "cmd": " ".join(cmd)}
197219
except Exception as e:
198220
return {"error": str(e)}, 500
@@ -206,8 +228,11 @@ def kill_session(session_id):
206228
if not info:
207229
return {"error": "Session not found"}, 404
208230
try:
209-
os.kill(info["pid"], signal.SIGTERM)
210-
os.close(info["fd"])
231+
if _WINDOWS:
232+
info["process"].terminate(force=True)
233+
else:
234+
os.kill(info["pid"], signal.SIGTERM)
235+
os.close(info["fd"])
211236
except OSError:
212237
pass
213238
del sessions[session_id]
@@ -231,73 +256,127 @@ def terminal_ws(ws, session_id):
231256
ws.send(json.dumps({"error": "Session not found"}))
232257
return
233258

234-
fd = info["fd"]
235-
236-
# Set non-blocking
237-
import fcntl
238-
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
239-
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
259+
if _WINDOWS:
260+
proc = info["process"]
240261

241-
def read_output():
242-
"""Read from PTY and send to WebSocket."""
243-
while True:
244-
try:
245-
r, _, _ = select.select([fd], [], [], 0.1)
246-
if r:
247-
data = os.read(fd, 4096)
262+
def read_output():
263+
"""Read from ConPTY and send to WebSocket."""
264+
while True:
265+
try:
266+
if not proc.isalive():
267+
break
268+
data = proc.read(4096)
248269
if data:
249-
ws.send(data.decode("utf-8", errors="replace"))
270+
ws.send(data if isinstance(data, str) else data.decode("utf-8", errors="replace"))
250271
else:
251-
break
252-
except (OSError, EOFError):
253-
break
272+
time.sleep(0.01)
273+
except Exception:
274+
break
275+
try:
276+
ws.send("\r\n[Process exited]\r\n")
254277
except Exception:
255-
break
278+
pass
279+
280+
reader = threading.Thread(target=read_output, daemon=True)
281+
reader.start()
282+
256283
try:
257-
ws.send("\r\n[Process exited]\r\n")
284+
while True:
285+
try:
286+
msg = ws.receive(timeout=300)
287+
except Exception:
288+
break
289+
290+
if msg is None:
291+
break
292+
293+
if isinstance(msg, str) and msg.startswith('{"resize":'):
294+
try:
295+
data = json.loads(msg)
296+
proc.setwinsize(data["resize"]["rows"], data["resize"]["cols"])
297+
except Exception:
298+
pass
299+
continue
300+
301+
if isinstance(msg, str) and msg == '{"ping":true}':
302+
try:
303+
ws.send('{"pong":true}')
304+
except Exception:
305+
pass
306+
continue
307+
308+
try:
309+
proc.write(msg if isinstance(msg, str) else msg.decode("utf-8"))
310+
except Exception:
311+
break
258312
except Exception:
259313
pass
314+
finally:
315+
reader.join(timeout=1)
260316

261-
# Start reader thread
262-
reader = threading.Thread(target=read_output, daemon=True)
263-
reader.start()
317+
else:
318+
fd = info["fd"]
319+
320+
# Set non-blocking
321+
import fcntl
322+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
323+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
264324

265-
# Main loop: read from WebSocket, write to PTY
266-
try:
267-
while True:
325+
def read_output():
326+
"""Read from PTY and send to WebSocket."""
327+
while True:
328+
try:
329+
r, _, _ = select.select([fd], [], [], 0.1)
330+
if r:
331+
data = os.read(fd, 4096)
332+
if data:
333+
ws.send(data.decode("utf-8", errors="replace"))
334+
else:
335+
break
336+
except (OSError, EOFError):
337+
break
338+
except Exception:
339+
break
268340
try:
269-
msg = ws.receive(timeout=300) # 5 min timeout
341+
ws.send("\r\n[Process exited]\r\n")
270342
except Exception:
271-
break
343+
pass
272344

273-
if msg is None:
274-
break
345+
reader = threading.Thread(target=read_output, daemon=True)
346+
reader.start()
275347

276-
# Handle resize messages
277-
if isinstance(msg, str) and msg.startswith('{"resize":'):
348+
try:
349+
while True:
278350
try:
279-
data = json.loads(msg)
280-
rows = data["resize"]["rows"]
281-
cols = data["resize"]["cols"]
282-
_set_winsize(fd, rows, cols)
351+
msg = ws.receive(timeout=300)
283352
except Exception:
284-
pass
285-
continue
353+
break
354+
355+
if msg is None:
356+
break
357+
358+
if isinstance(msg, str) and msg.startswith('{"resize":'):
359+
try:
360+
data = json.loads(msg)
361+
rows = data["resize"]["rows"]
362+
cols = data["resize"]["cols"]
363+
_set_winsize(fd, rows, cols)
364+
except Exception:
365+
pass
366+
continue
367+
368+
if isinstance(msg, str) and msg == '{"ping":true}':
369+
try:
370+
ws.send('{"pong":true}')
371+
except Exception:
372+
pass
373+
continue
286374

287-
# Handle ping/keepalive
288-
if isinstance(msg, str) and msg == '{"ping":true}':
289375
try:
290-
ws.send('{"pong":true}')
291-
except Exception:
292-
pass
293-
continue
294-
295-
# Write input to PTY
296-
try:
297-
os.write(fd, msg.encode("utf-8") if isinstance(msg, str) else msg)
298-
except OSError:
299-
break
300-
except Exception:
301-
pass
302-
finally:
303-
reader.join(timeout=1)
376+
os.write(fd, msg.encode("utf-8") if isinstance(msg, str) else msg)
377+
except OSError:
378+
break
379+
except Exception:
380+
pass
381+
finally:
382+
reader.join(timeout=1)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"requests>=2.31",
1616
"pyyaml>=6.0",
1717
"flask-sock>=0.7",
18+
"pywinpty>=3.0.3; sys_platform == 'win32'",
1819
]
1920

2021
[tool.uv]

setup.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,22 @@ def check_prerequisites():
6969
errors.append("node")
7070

7171
# npm
72+
npm_cmd = "npm"
7273
try:
73-
result = subprocess.run(["npm", "--version"], capture_output=True, text=True, timeout=5)
74-
if result.returncode == 0:
75-
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
76-
else:
77-
errors.append("npm")
74+
result = subprocess.run([npm_cmd, "--version"], capture_output=True, text=True, timeout=5)
75+
if result.returncode != 0:
76+
raise FileNotFoundError
77+
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
7878
except (FileNotFoundError, subprocess.TimeoutExpired):
79-
errors.append("npm")
79+
try:
80+
npm_cmd = "npm.cmd"
81+
result = subprocess.run([npm_cmd, "--version"], capture_output=True, text=True, timeout=5)
82+
if result.returncode == 0:
83+
print(f" {GREEN}{RESET} npm: {DIM}v{result.stdout.strip()}{RESET}")
84+
else:
85+
errors.append("npm")
86+
except (FileNotFoundError, subprocess.TimeoutExpired):
87+
errors.append("npm")
8088

8189
print()
8290

@@ -88,8 +96,10 @@ def check_prerequisites():
8896
if "uv" in errors:
8997
print(f" {RED}{RESET} uv (Python package manager) — {BOLD}https://docs.astral.sh/uv/{RESET}")
9098
print(f" {DIM}curl -LsSf https://astral.sh/uv/install.sh | sh{RESET}")
91-
if "node" in errors or "npm" in errors:
99+
if "node" in errors:
92100
print(f" {RED}{RESET} Node.js 18+ — {BOLD}https://nodejs.org{RESET}")
101+
if "npm" in errors:
102+
print(f" {RED}{RESET} npm not found (Node.js installed but npm missing from PATH) — {BOLD}https://nodejs.org{RESET}")
93103
print()
94104
print(f" {YELLOW}Install the missing tools and run setup again.{RESET}")
95105
sys.exit(1)
@@ -333,7 +343,7 @@ def copy_env_example(config: dict):
333343
print(f" {GREEN}{RESET} Created .env from .env.example")
334344
else:
335345
print(f" {YELLOW}!{RESET} .env.example not found, creating empty .env")
336-
dst.write_text("# EvoNexus Environment Variables\n# Fill in your API keys below\n\n")
346+
dst.write_text("# EvoNexus Environment Variables\n# Fill in your API keys below\n\n", encoding="utf-8")
337347

338348

339349
def copy_routines_config(config: dict):
@@ -346,7 +356,7 @@ def copy_routines_config(config: dict):
346356
if src.exists():
347357
shutil.copy2(src, dst)
348358
else:
349-
dst.write_text("# EvoNexus Routines — edit schedules here\n# See ROUTINES.md for documentation\n\ndaily: []\nweekly: []\nmonthly: []\n")
359+
dst.write_text("# EvoNexus Routines — edit schedules here\n# See ROUTINES.md for documentation\n\ndaily: []\nweekly: []\nmonthly: []\n", encoding="utf-8")
350360
print(f" {GREEN}{RESET} Created config/routines.yaml")
351361

352362

@@ -407,7 +417,7 @@ def main():
407417
# workspace.yaml
408418
config_dir = WORKSPACE / "config"
409419
config_dir.mkdir(exist_ok=True)
410-
(config_dir / "workspace.yaml").write_text(generate_workspace_yaml(config))
420+
(config_dir / "workspace.yaml").write_text(generate_workspace_yaml(config), encoding="utf-8")
411421
print(f" {GREEN}{RESET} Generated config/workspace.yaml")
412422

413423
# .env
@@ -418,7 +428,7 @@ def main():
418428

419429
# CLAUDE.md
420430
claude_md = generate_claude_md(config)
421-
(WORKSPACE / "CLAUDE.md").write_text(claude_md)
431+
(WORKSPACE / "CLAUDE.md").write_text(claude_md, encoding="utf-8")
422432
print(f" {GREEN}{RESET} Generated CLAUDE.md")
423433

424434
# Folders

0 commit comments

Comments
 (0)