autoforge-ai 0.1.0
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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal Manager
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
Manages PTY terminal sessions per project with cross-platform support.
|
|
6
|
+
Uses winpty (ConPTY) on Windows and built-in pty module on Unix.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import threading
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Set
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TerminalInfo:
|
|
26
|
+
"""Metadata for a terminal instance."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Platform detection
|
|
34
|
+
IS_WINDOWS = platform.system() == "Windows"
|
|
35
|
+
|
|
36
|
+
# Conditional imports for PTY support
|
|
37
|
+
# Note: Type checking is disabled for cross-platform PTY modules since mypy
|
|
38
|
+
# cannot properly handle conditional imports for platform-specific APIs.
|
|
39
|
+
if IS_WINDOWS:
|
|
40
|
+
try:
|
|
41
|
+
from winpty import PtyProcess as WinPtyProcess
|
|
42
|
+
|
|
43
|
+
WINPTY_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
WinPtyProcess = None
|
|
46
|
+
WINPTY_AVAILABLE = False
|
|
47
|
+
logger.warning(
|
|
48
|
+
"winpty package not installed. Terminal sessions will not be available on Windows. "
|
|
49
|
+
"Install with: pip install pywinpty"
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Unix systems use built-in pty module
|
|
53
|
+
import fcntl
|
|
54
|
+
import pty
|
|
55
|
+
import select
|
|
56
|
+
import signal
|
|
57
|
+
import struct
|
|
58
|
+
import termios
|
|
59
|
+
|
|
60
|
+
WINPTY_AVAILABLE = False # Not applicable on Unix
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_shell() -> str:
|
|
64
|
+
"""
|
|
65
|
+
Get the appropriate shell for the current platform.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Path to shell executable
|
|
69
|
+
"""
|
|
70
|
+
if IS_WINDOWS:
|
|
71
|
+
# Prefer PowerShell, fall back to cmd.exe
|
|
72
|
+
powershell = shutil.which("powershell.exe")
|
|
73
|
+
if powershell:
|
|
74
|
+
return powershell
|
|
75
|
+
cmd = shutil.which("cmd.exe")
|
|
76
|
+
if cmd:
|
|
77
|
+
return cmd
|
|
78
|
+
# Last resort fallback
|
|
79
|
+
return "cmd.exe"
|
|
80
|
+
else:
|
|
81
|
+
# Unix: Use $SHELL environment variable or fall back to /bin/bash
|
|
82
|
+
shell = os.environ.get("SHELL")
|
|
83
|
+
if shell and shutil.which(shell):
|
|
84
|
+
return shell
|
|
85
|
+
# Fall back to common shells
|
|
86
|
+
for fallback in ["/bin/bash", "/bin/sh"]:
|
|
87
|
+
if os.path.exists(fallback):
|
|
88
|
+
return fallback
|
|
89
|
+
return "/bin/sh"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TerminalSession:
|
|
93
|
+
"""
|
|
94
|
+
Manages a single PTY terminal session for a project.
|
|
95
|
+
|
|
96
|
+
Provides cross-platform PTY support with async output streaming
|
|
97
|
+
and multiple output callbacks for WebSocket clients.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, project_name: str, project_dir: Path):
|
|
101
|
+
"""
|
|
102
|
+
Initialize the terminal session.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
project_name: Name of the project
|
|
106
|
+
project_dir: Absolute path to the project directory (used as cwd)
|
|
107
|
+
"""
|
|
108
|
+
self.project_name = project_name
|
|
109
|
+
self.project_dir = project_dir
|
|
110
|
+
|
|
111
|
+
# PTY process references (platform-specific)
|
|
112
|
+
self._pty_process: "WinPtyProcess | None" = None # Windows winpty
|
|
113
|
+
self._master_fd: int | None = None # Unix master file descriptor
|
|
114
|
+
self._child_pid: int | None = None # Unix child process PID
|
|
115
|
+
|
|
116
|
+
# State tracking
|
|
117
|
+
self._is_active = False
|
|
118
|
+
self._output_task: asyncio.Task | None = None
|
|
119
|
+
|
|
120
|
+
# Output callbacks with thread-safe access
|
|
121
|
+
self._output_callbacks: Set[Callable[[bytes], None]] = set()
|
|
122
|
+
self._callbacks_lock = threading.Lock()
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def is_active(self) -> bool:
|
|
126
|
+
"""Check if the terminal session is currently active."""
|
|
127
|
+
return self._is_active
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def pid(self) -> int | None:
|
|
131
|
+
"""Get the PID of the PTY child process."""
|
|
132
|
+
if IS_WINDOWS:
|
|
133
|
+
if self._pty_process is not None:
|
|
134
|
+
try:
|
|
135
|
+
pid = self._pty_process.pid
|
|
136
|
+
return int(pid) if pid is not None else None
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
return None
|
|
140
|
+
else:
|
|
141
|
+
return self._child_pid
|
|
142
|
+
|
|
143
|
+
def add_output_callback(self, callback: Callable[[bytes], None]) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Add a callback to receive terminal output.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
callback: Function that receives raw bytes from the PTY
|
|
149
|
+
"""
|
|
150
|
+
with self._callbacks_lock:
|
|
151
|
+
self._output_callbacks.add(callback)
|
|
152
|
+
|
|
153
|
+
def remove_output_callback(self, callback: Callable[[bytes], None]) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Remove an output callback.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
callback: The callback to remove
|
|
159
|
+
"""
|
|
160
|
+
with self._callbacks_lock:
|
|
161
|
+
self._output_callbacks.discard(callback)
|
|
162
|
+
|
|
163
|
+
def _broadcast_output(self, data: bytes) -> None:
|
|
164
|
+
"""Broadcast output data to all registered callbacks."""
|
|
165
|
+
with self._callbacks_lock:
|
|
166
|
+
callbacks = list(self._output_callbacks)
|
|
167
|
+
|
|
168
|
+
for callback in callbacks:
|
|
169
|
+
try:
|
|
170
|
+
callback(data)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f"Output callback error: {e}")
|
|
173
|
+
|
|
174
|
+
async def start(self, cols: int = 80, rows: int = 24) -> bool:
|
|
175
|
+
"""
|
|
176
|
+
Start the PTY terminal session.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
cols: Terminal width in columns
|
|
180
|
+
rows: Terminal height in rows
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if started successfully, False otherwise
|
|
184
|
+
"""
|
|
185
|
+
if self._is_active:
|
|
186
|
+
logger.warning(f"Terminal session already active for {self.project_name}")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Validate project directory
|
|
190
|
+
if not self.project_dir.exists():
|
|
191
|
+
logger.error(f"Project directory does not exist: {self.project_dir}")
|
|
192
|
+
return False
|
|
193
|
+
if not self.project_dir.is_dir():
|
|
194
|
+
logger.error(f"Project path is not a directory: {self.project_dir}")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
shell = _get_shell()
|
|
198
|
+
cwd = str(self.project_dir.resolve())
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if IS_WINDOWS:
|
|
202
|
+
return await self._start_windows(shell, cwd, cols, rows)
|
|
203
|
+
else:
|
|
204
|
+
return await self._start_unix(shell, cwd, cols, rows)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.exception(f"Failed to start terminal for {self.project_name}: {e}")
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
async def _start_windows(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
|
|
210
|
+
"""Start PTY on Windows using winpty."""
|
|
211
|
+
if not WINPTY_AVAILABLE:
|
|
212
|
+
logger.error("Cannot start terminal: winpty package not available")
|
|
213
|
+
# This error will be caught and sent to the client
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
"Terminal requires pywinpty on Windows. Install with: pip install pywinpty"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# WinPtyProcess.spawn expects the shell command
|
|
220
|
+
self._pty_process = WinPtyProcess.spawn(
|
|
221
|
+
shell,
|
|
222
|
+
cwd=cwd,
|
|
223
|
+
dimensions=(rows, cols),
|
|
224
|
+
)
|
|
225
|
+
self._is_active = True
|
|
226
|
+
|
|
227
|
+
# Start output reading task
|
|
228
|
+
self._output_task = asyncio.create_task(self._read_output_windows())
|
|
229
|
+
|
|
230
|
+
logger.info(f"Terminal started for {self.project_name} (PID: {self.pid}, shell: {shell})")
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.exception(f"Failed to start Windows PTY: {e}")
|
|
235
|
+
self._pty_process = None
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
async def _start_unix(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
|
|
239
|
+
"""Start PTY on Unix using built-in pty module."""
|
|
240
|
+
# Note: This entire method uses Unix-specific APIs that don't exist on Windows.
|
|
241
|
+
# Type checking is disabled for these platform-specific calls.
|
|
242
|
+
try:
|
|
243
|
+
# Fork a new pseudo-terminal
|
|
244
|
+
pid, master_fd = pty.fork() # type: ignore[attr-defined]
|
|
245
|
+
|
|
246
|
+
if pid == 0:
|
|
247
|
+
# Child process - exec the shell
|
|
248
|
+
os.chdir(cwd)
|
|
249
|
+
# Set terminal size (Unix-specific modules imported at top-level)
|
|
250
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
251
|
+
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
|
|
252
|
+
|
|
253
|
+
# Execute the shell
|
|
254
|
+
os.execvp(shell, [shell])
|
|
255
|
+
os._exit(1) # Fallback if execvp returns (shouldn't happen)
|
|
256
|
+
else:
|
|
257
|
+
# Parent process
|
|
258
|
+
self._master_fd = master_fd
|
|
259
|
+
self._child_pid = pid
|
|
260
|
+
self._is_active = True
|
|
261
|
+
|
|
262
|
+
# Set terminal size on master (Unix-specific modules imported at top-level)
|
|
263
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
264
|
+
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
|
|
265
|
+
|
|
266
|
+
# Start output reading task
|
|
267
|
+
self._output_task = asyncio.create_task(self._read_output_unix())
|
|
268
|
+
|
|
269
|
+
logger.info(f"Terminal started for {self.project_name} (PID: {pid}, shell: {shell})")
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.exception(f"Failed to start Unix PTY: {e}")
|
|
274
|
+
self._master_fd = None
|
|
275
|
+
self._child_pid = None
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
async def _read_output_windows(self) -> None:
|
|
279
|
+
"""Read output from Windows PTY and broadcast to callbacks."""
|
|
280
|
+
pty = self._pty_process
|
|
281
|
+
if pty is None:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
loop = asyncio.get_running_loop()
|
|
285
|
+
|
|
286
|
+
def read_data():
|
|
287
|
+
"""Read data from PTY, capturing pty reference to avoid race condition."""
|
|
288
|
+
try:
|
|
289
|
+
if pty.isalive():
|
|
290
|
+
return pty.read(4096)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
return b""
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
while self._is_active and self._pty_process is not None:
|
|
297
|
+
try:
|
|
298
|
+
# Use run_in_executor for non-blocking read
|
|
299
|
+
# winpty read() is blocking, so we need to run it in executor
|
|
300
|
+
data = await loop.run_in_executor(None, read_data)
|
|
301
|
+
|
|
302
|
+
if data:
|
|
303
|
+
# winpty may return string, convert to bytes if needed
|
|
304
|
+
if isinstance(data, str):
|
|
305
|
+
data = data.encode("utf-8", errors="replace")
|
|
306
|
+
self._broadcast_output(data)
|
|
307
|
+
else:
|
|
308
|
+
# Check if process is still alive
|
|
309
|
+
if self._pty_process is None or not self._pty_process.isalive():
|
|
310
|
+
break
|
|
311
|
+
# Small delay to prevent busy loop
|
|
312
|
+
await asyncio.sleep(0.01)
|
|
313
|
+
|
|
314
|
+
except asyncio.CancelledError:
|
|
315
|
+
raise
|
|
316
|
+
except Exception as e:
|
|
317
|
+
if self._is_active:
|
|
318
|
+
logger.warning(f"Windows PTY read error: {e}")
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
except asyncio.CancelledError:
|
|
322
|
+
pass
|
|
323
|
+
finally:
|
|
324
|
+
if self._is_active:
|
|
325
|
+
self._is_active = False
|
|
326
|
+
logger.info(f"Terminal output stream ended for {self.project_name}")
|
|
327
|
+
|
|
328
|
+
async def _read_output_unix(self) -> None:
|
|
329
|
+
"""Read output from Unix PTY and broadcast to callbacks."""
|
|
330
|
+
if self._master_fd is None:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
loop = asyncio.get_running_loop()
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
while self._is_active and self._master_fd is not None:
|
|
337
|
+
try:
|
|
338
|
+
# Use run_in_executor with select for non-blocking read
|
|
339
|
+
def read_with_select():
|
|
340
|
+
if self._master_fd is None:
|
|
341
|
+
return b""
|
|
342
|
+
try:
|
|
343
|
+
# Wait up to 100ms for data
|
|
344
|
+
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
|
345
|
+
if readable:
|
|
346
|
+
return os.read(self._master_fd, 4096)
|
|
347
|
+
return b""
|
|
348
|
+
except (OSError, ValueError):
|
|
349
|
+
return b""
|
|
350
|
+
|
|
351
|
+
data = await loop.run_in_executor(None, read_with_select)
|
|
352
|
+
|
|
353
|
+
if data:
|
|
354
|
+
self._broadcast_output(data)
|
|
355
|
+
elif not self._check_child_alive():
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
except asyncio.CancelledError:
|
|
359
|
+
raise
|
|
360
|
+
except Exception as e:
|
|
361
|
+
if self._is_active:
|
|
362
|
+
logger.warning(f"Unix PTY read error: {e}")
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
except asyncio.CancelledError:
|
|
366
|
+
pass
|
|
367
|
+
finally:
|
|
368
|
+
if self._is_active:
|
|
369
|
+
self._is_active = False
|
|
370
|
+
logger.info(f"Terminal output stream ended for {self.project_name}")
|
|
371
|
+
# Reap zombie if not already reaped
|
|
372
|
+
if self._child_pid is not None:
|
|
373
|
+
try:
|
|
374
|
+
os.waitpid(self._child_pid, os.WNOHANG) # type: ignore[attr-defined] # Unix-only method, guarded by runtime platform selection
|
|
375
|
+
except ChildProcessError:
|
|
376
|
+
pass
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
def _check_child_alive(self) -> bool:
|
|
381
|
+
"""Check if the Unix child process is still alive."""
|
|
382
|
+
if self._child_pid is None:
|
|
383
|
+
return False
|
|
384
|
+
try:
|
|
385
|
+
# Use signal 0 to check if process exists without reaping it.
|
|
386
|
+
# This avoids race conditions with os.waitpid which can reap the process.
|
|
387
|
+
os.kill(self._child_pid, 0)
|
|
388
|
+
return True
|
|
389
|
+
except OSError:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
def write(self, data: bytes) -> None:
|
|
393
|
+
"""
|
|
394
|
+
Write input data to the PTY.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
data: Raw bytes to write to the terminal
|
|
398
|
+
"""
|
|
399
|
+
if not self._is_active:
|
|
400
|
+
logger.warning(f"Cannot write to inactive terminal for {self.project_name}")
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
if IS_WINDOWS:
|
|
405
|
+
if self._pty_process is not None:
|
|
406
|
+
# winpty expects string input
|
|
407
|
+
text = data.decode("utf-8", errors="replace")
|
|
408
|
+
self._pty_process.write(text)
|
|
409
|
+
else:
|
|
410
|
+
if self._master_fd is not None:
|
|
411
|
+
os.write(self._master_fd, data)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
logger.warning(f"Failed to write to PTY: {e}")
|
|
414
|
+
|
|
415
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
416
|
+
"""
|
|
417
|
+
Resize the terminal.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
cols: New terminal width in columns
|
|
421
|
+
rows: New terminal height in rows
|
|
422
|
+
"""
|
|
423
|
+
if not self._is_active:
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
if IS_WINDOWS:
|
|
428
|
+
if self._pty_process is not None:
|
|
429
|
+
self._pty_process.setwinsize(rows, cols)
|
|
430
|
+
else:
|
|
431
|
+
if self._master_fd is not None:
|
|
432
|
+
# Unix-specific modules imported at top-level
|
|
433
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
434
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
|
|
435
|
+
|
|
436
|
+
logger.debug(f"Terminal resized for {self.project_name}: {cols}x{rows}")
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.warning(f"Failed to resize terminal: {e}")
|
|
439
|
+
|
|
440
|
+
async def stop(self) -> None:
|
|
441
|
+
"""Stop the terminal session and clean up resources."""
|
|
442
|
+
if not self._is_active:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
self._is_active = False
|
|
446
|
+
|
|
447
|
+
# Cancel output reading task
|
|
448
|
+
if self._output_task is not None:
|
|
449
|
+
self._output_task.cancel()
|
|
450
|
+
try:
|
|
451
|
+
await self._output_task
|
|
452
|
+
except asyncio.CancelledError:
|
|
453
|
+
pass
|
|
454
|
+
self._output_task = None
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
if IS_WINDOWS:
|
|
458
|
+
await self._stop_windows()
|
|
459
|
+
else:
|
|
460
|
+
await self._stop_unix()
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.warning(f"Error stopping terminal: {e}")
|
|
463
|
+
|
|
464
|
+
logger.info(f"Terminal stopped for {self.project_name}")
|
|
465
|
+
|
|
466
|
+
async def _stop_windows(self) -> None:
|
|
467
|
+
"""Stop Windows PTY process."""
|
|
468
|
+
if self._pty_process is None:
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
if self._pty_process.isalive():
|
|
473
|
+
self._pty_process.terminate()
|
|
474
|
+
# Give it a moment to terminate
|
|
475
|
+
await asyncio.sleep(0.1)
|
|
476
|
+
if self._pty_process.isalive():
|
|
477
|
+
self._pty_process.kill()
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.warning(f"Error terminating Windows PTY: {e}")
|
|
480
|
+
finally:
|
|
481
|
+
self._pty_process = None
|
|
482
|
+
|
|
483
|
+
async def _stop_unix(self) -> None:
|
|
484
|
+
"""Stop Unix PTY process."""
|
|
485
|
+
# Note: This method uses Unix-specific signal handling (signal imported at top-level)
|
|
486
|
+
|
|
487
|
+
# Close master file descriptor
|
|
488
|
+
if self._master_fd is not None:
|
|
489
|
+
try:
|
|
490
|
+
os.close(self._master_fd)
|
|
491
|
+
except OSError:
|
|
492
|
+
pass
|
|
493
|
+
self._master_fd = None
|
|
494
|
+
|
|
495
|
+
# Terminate child process
|
|
496
|
+
if self._child_pid is not None:
|
|
497
|
+
try:
|
|
498
|
+
os.kill(self._child_pid, signal.SIGTERM)
|
|
499
|
+
# Wait briefly for graceful shutdown
|
|
500
|
+
await asyncio.sleep(0.1)
|
|
501
|
+
# Check if still running and force kill if needed
|
|
502
|
+
try:
|
|
503
|
+
os.kill(self._child_pid, 0) # Check if process exists
|
|
504
|
+
# SIGKILL is Unix-specific (Windows would use SIGTERM)
|
|
505
|
+
os.kill(self._child_pid, signal.SIGKILL) # type: ignore[attr-defined]
|
|
506
|
+
except ProcessLookupError:
|
|
507
|
+
pass # Already terminated
|
|
508
|
+
# Reap the child process to prevent zombie
|
|
509
|
+
try:
|
|
510
|
+
os.waitpid(self._child_pid, 0)
|
|
511
|
+
except ChildProcessError:
|
|
512
|
+
pass
|
|
513
|
+
except ProcessLookupError:
|
|
514
|
+
pass # Already terminated
|
|
515
|
+
except Exception as e:
|
|
516
|
+
logger.warning(f"Error terminating Unix PTY child: {e}")
|
|
517
|
+
finally:
|
|
518
|
+
self._child_pid = None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# Global registry of terminal sessions per project with thread safety
|
|
522
|
+
# Structure: Dict[project_name, Dict[terminal_id, TerminalSession]]
|
|
523
|
+
_sessions: dict[str, dict[str, TerminalSession]] = {}
|
|
524
|
+
_sessions_lock = threading.Lock()
|
|
525
|
+
|
|
526
|
+
# Terminal metadata registry (in-memory, resets on server restart)
|
|
527
|
+
# Structure: Dict[project_name, List[TerminalInfo]]
|
|
528
|
+
_terminal_metadata: dict[str, list[TerminalInfo]] = {}
|
|
529
|
+
_metadata_lock = threading.Lock()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def create_terminal(project_name: str, name: str | None = None) -> TerminalInfo:
|
|
533
|
+
"""
|
|
534
|
+
Create a new terminal entry for a project.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
project_name: Name of the project
|
|
538
|
+
name: Optional terminal name (auto-generated if not provided)
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
TerminalInfo for the new terminal
|
|
542
|
+
"""
|
|
543
|
+
with _metadata_lock:
|
|
544
|
+
if project_name not in _terminal_metadata:
|
|
545
|
+
_terminal_metadata[project_name] = []
|
|
546
|
+
|
|
547
|
+
terminals = _terminal_metadata[project_name]
|
|
548
|
+
|
|
549
|
+
# Auto-generate name if not provided
|
|
550
|
+
if name is None:
|
|
551
|
+
existing_nums = []
|
|
552
|
+
for t in terminals:
|
|
553
|
+
if t.name.startswith("Terminal "):
|
|
554
|
+
try:
|
|
555
|
+
num = int(t.name.replace("Terminal ", ""))
|
|
556
|
+
existing_nums.append(num)
|
|
557
|
+
except ValueError:
|
|
558
|
+
pass
|
|
559
|
+
next_num = max(existing_nums, default=0) + 1
|
|
560
|
+
name = f"Terminal {next_num}"
|
|
561
|
+
|
|
562
|
+
terminal_id = str(uuid.uuid4())[:8]
|
|
563
|
+
info = TerminalInfo(id=terminal_id, name=name)
|
|
564
|
+
terminals.append(info)
|
|
565
|
+
|
|
566
|
+
logger.info(f"Created terminal '{name}' (ID: {terminal_id}) for project {project_name}")
|
|
567
|
+
return info
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def list_terminals(project_name: str) -> list[TerminalInfo]:
|
|
571
|
+
"""
|
|
572
|
+
List all terminals for a project.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
project_name: Name of the project
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
List of TerminalInfo for the project
|
|
579
|
+
"""
|
|
580
|
+
with _metadata_lock:
|
|
581
|
+
return list(_terminal_metadata.get(project_name, []))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def rename_terminal(project_name: str, terminal_id: str, new_name: str) -> bool:
|
|
585
|
+
"""
|
|
586
|
+
Rename a terminal.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
project_name: Name of the project
|
|
590
|
+
terminal_id: ID of the terminal to rename
|
|
591
|
+
new_name: New name for the terminal
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
True if renamed successfully, False if terminal not found
|
|
595
|
+
"""
|
|
596
|
+
with _metadata_lock:
|
|
597
|
+
terminals = _terminal_metadata.get(project_name, [])
|
|
598
|
+
for terminal in terminals:
|
|
599
|
+
if terminal.id == terminal_id:
|
|
600
|
+
old_name = terminal.name
|
|
601
|
+
terminal.name = new_name
|
|
602
|
+
logger.info(
|
|
603
|
+
f"Renamed terminal '{old_name}' to '{new_name}' "
|
|
604
|
+
f"(ID: {terminal_id}) for project {project_name}"
|
|
605
|
+
)
|
|
606
|
+
return True
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def delete_terminal(project_name: str, terminal_id: str) -> bool:
|
|
611
|
+
"""
|
|
612
|
+
Delete a terminal and stop its session if active.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
project_name: Name of the project
|
|
616
|
+
terminal_id: ID of the terminal to delete
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
True if deleted, False if not found
|
|
620
|
+
"""
|
|
621
|
+
# Remove from metadata
|
|
622
|
+
with _metadata_lock:
|
|
623
|
+
terminals = _terminal_metadata.get(project_name, [])
|
|
624
|
+
for i, terminal in enumerate(terminals):
|
|
625
|
+
if terminal.id == terminal_id:
|
|
626
|
+
terminals.pop(i)
|
|
627
|
+
logger.info(
|
|
628
|
+
f"Deleted terminal '{terminal.name}' (ID: {terminal_id}) "
|
|
629
|
+
f"for project {project_name}"
|
|
630
|
+
)
|
|
631
|
+
break
|
|
632
|
+
else:
|
|
633
|
+
return False
|
|
634
|
+
|
|
635
|
+
# Remove session if exists (will be stopped async by caller)
|
|
636
|
+
with _sessions_lock:
|
|
637
|
+
project_sessions = _sessions.get(project_name, {})
|
|
638
|
+
if terminal_id in project_sessions:
|
|
639
|
+
del project_sessions[terminal_id]
|
|
640
|
+
|
|
641
|
+
return True
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def get_terminal_session(
|
|
645
|
+
project_name: str, project_dir: Path, terminal_id: str | None = None
|
|
646
|
+
) -> TerminalSession:
|
|
647
|
+
"""
|
|
648
|
+
Get or create a terminal session for a project (thread-safe).
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
project_name: Name of the project
|
|
652
|
+
project_dir: Absolute path to the project directory
|
|
653
|
+
terminal_id: ID of the terminal (creates default if not provided)
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
TerminalSession instance for the project/terminal
|
|
657
|
+
"""
|
|
658
|
+
# Ensure terminal metadata exists
|
|
659
|
+
if terminal_id is None:
|
|
660
|
+
# Create default terminal if none exists
|
|
661
|
+
terminals = list_terminals(project_name)
|
|
662
|
+
if not terminals:
|
|
663
|
+
info = create_terminal(project_name)
|
|
664
|
+
terminal_id = info.id
|
|
665
|
+
else:
|
|
666
|
+
terminal_id = terminals[0].id
|
|
667
|
+
|
|
668
|
+
with _sessions_lock:
|
|
669
|
+
if project_name not in _sessions:
|
|
670
|
+
_sessions[project_name] = {}
|
|
671
|
+
|
|
672
|
+
project_sessions = _sessions[project_name]
|
|
673
|
+
if terminal_id not in project_sessions:
|
|
674
|
+
project_sessions[terminal_id] = TerminalSession(project_name, project_dir)
|
|
675
|
+
|
|
676
|
+
return project_sessions[terminal_id]
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def remove_terminal_session(project_name: str, terminal_id: str) -> TerminalSession | None:
|
|
680
|
+
"""
|
|
681
|
+
Remove a terminal session from the registry.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
project_name: Name of the project
|
|
685
|
+
terminal_id: ID of the terminal
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
The removed session, or None if not found
|
|
689
|
+
"""
|
|
690
|
+
with _sessions_lock:
|
|
691
|
+
project_sessions = _sessions.get(project_name, {})
|
|
692
|
+
return project_sessions.pop(terminal_id, None)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def get_terminal_info(project_name: str, terminal_id: str) -> TerminalInfo | None:
|
|
696
|
+
"""
|
|
697
|
+
Get terminal info by ID.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
project_name: Name of the project
|
|
701
|
+
terminal_id: ID of the terminal
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
TerminalInfo if found, None otherwise
|
|
705
|
+
"""
|
|
706
|
+
with _metadata_lock:
|
|
707
|
+
terminals = _terminal_metadata.get(project_name, [])
|
|
708
|
+
for terminal in terminals:
|
|
709
|
+
if terminal.id == terminal_id:
|
|
710
|
+
return terminal
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
async def stop_terminal_session(project_name: str, terminal_id: str) -> bool:
|
|
715
|
+
"""
|
|
716
|
+
Stop a specific terminal session.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
project_name: Name of the project
|
|
720
|
+
terminal_id: ID of the terminal
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
True if stopped, False if not found
|
|
724
|
+
"""
|
|
725
|
+
session = remove_terminal_session(project_name, terminal_id)
|
|
726
|
+
if session and session.is_active:
|
|
727
|
+
await session.stop()
|
|
728
|
+
return True
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
async def cleanup_all_terminals() -> None:
|
|
733
|
+
"""
|
|
734
|
+
Stop all active terminal sessions.
|
|
735
|
+
|
|
736
|
+
Called on server shutdown to ensure all PTY processes are terminated.
|
|
737
|
+
"""
|
|
738
|
+
with _sessions_lock:
|
|
739
|
+
all_sessions: list[TerminalSession] = []
|
|
740
|
+
for project_sessions in _sessions.values():
|
|
741
|
+
all_sessions.extend(project_sessions.values())
|
|
742
|
+
|
|
743
|
+
for session in all_sessions:
|
|
744
|
+
try:
|
|
745
|
+
if session.is_active:
|
|
746
|
+
await session.stop()
|
|
747
|
+
except Exception as e:
|
|
748
|
+
logger.warning(f"Error stopping terminal for {session.project_name}: {e}")
|
|
749
|
+
|
|
750
|
+
with _sessions_lock:
|
|
751
|
+
_sessions.clear()
|
|
752
|
+
|
|
753
|
+
with _metadata_lock:
|
|
754
|
+
_terminal_metadata.clear()
|
|
755
|
+
|
|
756
|
+
logger.info("All terminal sessions cleaned up")
|