portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
|
@@ -1,26 +1,68 @@
|
|
|
1
1
|
"""Terminal session management."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
7
|
+
import struct
|
|
6
8
|
import sys
|
|
9
|
+
import time
|
|
7
10
|
import uuid
|
|
8
11
|
from asyncio.subprocess import Process
|
|
9
12
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Dict, Optional, List, TYPE_CHECKING
|
|
11
|
-
|
|
13
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, List, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from platformdirs import user_data_dir
|
|
16
|
+
|
|
17
|
+
from portacode.link_capture import prepare_link_capture_bin
|
|
18
|
+
|
|
19
|
+
import pyte
|
|
12
20
|
|
|
13
21
|
if TYPE_CHECKING:
|
|
14
22
|
from ..multiplex import Channel
|
|
15
23
|
|
|
24
|
+
# Terminal data rate limiting configuration
|
|
25
|
+
TERMINAL_DATA_RATE_LIMIT_MS = 60 # Minimum time between terminal_data events (milliseconds)
|
|
26
|
+
TERMINAL_DATA_MAX_WAIT_MS = 1000 # Maximum time to wait before sending accumulated data (milliseconds)
|
|
27
|
+
TERMINAL_DATA_INITIAL_WAIT_MS = 10 # Time to wait for additional data even on first event (milliseconds)
|
|
28
|
+
|
|
29
|
+
# Terminal buffer configuration - using pyte for proper screen state management
|
|
30
|
+
TERMINAL_COLUMNS = 80 # Default terminal width
|
|
31
|
+
TERMINAL_ROWS = 24 # Default terminal height (visible area)
|
|
32
|
+
TERMINAL_SCROLLBACK_LIMIT = 1000 # Maximum number of scrollback lines to preserve
|
|
33
|
+
|
|
34
|
+
# Link event folder for capturing helper notifications
|
|
35
|
+
_LINK_EVENT_ROOT = Path(user_data_dir("portacode", "portacode")) / "link_events"
|
|
36
|
+
_LINK_EVENT_POLL_INTERVAL = 0.5 # seconds
|
|
37
|
+
LINK_EVENT_THROTTLE_SECONDS = 5.0
|
|
38
|
+
LINK_CAPTURE_ORIGINAL_BROWSER_ENV = "PORTACODE_LINK_CAPTURE_ORIGINAL_BROWSER"
|
|
39
|
+
|
|
16
40
|
logger = logging.getLogger(__name__)
|
|
17
41
|
|
|
18
42
|
_IS_WINDOWS = sys.platform.startswith("win")
|
|
19
43
|
|
|
44
|
+
|
|
45
|
+
def _configure_pty_window_size(fd: int, rows: int, cols: int) -> None:
|
|
46
|
+
"""Set the PTY window size so subprocesses see a real terminal."""
|
|
47
|
+
if _IS_WINDOWS:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
import fcntl
|
|
51
|
+
import termios
|
|
52
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
53
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
54
|
+
except ImportError:
|
|
55
|
+
logger.debug("termios/fcntl unavailable; skipping PTY window sizing")
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
logger.warning("Failed to set PTY window size (%sx%s): %s", cols, rows, exc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
20
61
|
# Minimal, safe defaults for interactive shells
|
|
21
62
|
_DEFAULT_ENV = {
|
|
22
63
|
"TERM": "xterm-256color",
|
|
23
64
|
"LANG": "C.UTF-8",
|
|
65
|
+
"SHELL": "/bin/bash",
|
|
24
66
|
}
|
|
25
67
|
|
|
26
68
|
|
|
@@ -29,18 +71,124 @@ def _build_child_env() -> Dict[str, str]:
|
|
|
29
71
|
env = os.environ.copy()
|
|
30
72
|
for k, v in _DEFAULT_ENV.items():
|
|
31
73
|
env.setdefault(k, v)
|
|
74
|
+
env.setdefault("COLUMNS", str(TERMINAL_COLUMNS))
|
|
75
|
+
env.setdefault("LINES", str(TERMINAL_ROWS))
|
|
32
76
|
return env
|
|
33
77
|
|
|
34
78
|
|
|
79
|
+
_LINK_EVENT_DISPATCHER: Optional["LinkEventDispatcher"] = None
|
|
80
|
+
|
|
81
|
+
class LinkEventDispatcher:
|
|
82
|
+
"""Watch a shared folder for link capture files."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, directory: Optional[Path]):
|
|
85
|
+
self.directory = directory
|
|
86
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
87
|
+
|
|
88
|
+
# Callbacks that are notified whenever a new event file is processed
|
|
89
|
+
self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
|
|
90
|
+
|
|
91
|
+
def start(self) -> None:
|
|
92
|
+
if not self.directory:
|
|
93
|
+
return
|
|
94
|
+
if self._task and not self._task.done():
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
logger.warning("link_watcher: Failed to create directory %s: %s", self.directory, exc)
|
|
100
|
+
return
|
|
101
|
+
self._task = asyncio.create_task(self._run())
|
|
102
|
+
|
|
103
|
+
async def stop(self) -> None:
|
|
104
|
+
if not self._task:
|
|
105
|
+
return
|
|
106
|
+
self._task.cancel()
|
|
107
|
+
try:
|
|
108
|
+
await self._task
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
pass
|
|
111
|
+
self._task = None
|
|
112
|
+
|
|
113
|
+
async def _run(self) -> None:
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
if self.directory.exists():
|
|
117
|
+
for entry in sorted(self.directory.iterdir()):
|
|
118
|
+
if not entry.is_file():
|
|
119
|
+
continue
|
|
120
|
+
await self._process_entry(entry)
|
|
121
|
+
await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
|
|
122
|
+
except asyncio.CancelledError:
|
|
123
|
+
break
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.warning("link_watcher: error scanning %s: %s", self.directory, exc)
|
|
126
|
+
await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
|
|
127
|
+
|
|
128
|
+
async def _process_entry(self, entry: Path) -> None:
|
|
129
|
+
try:
|
|
130
|
+
raw = entry.read_text(encoding="utf-8")
|
|
131
|
+
payload = json.loads(raw)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning("link_watcher: failed to read %s: %s", entry, exc)
|
|
134
|
+
else:
|
|
135
|
+
terminal_id = payload.get("terminal_id")
|
|
136
|
+
link = payload.get("url")
|
|
137
|
+
if link:
|
|
138
|
+
logger.info("link_watcher: terminal %s captured link %s", terminal_id, link)
|
|
139
|
+
else:
|
|
140
|
+
logger.info("link_watcher: terminal %s observed link capture without url (%s)", terminal_id, payload)
|
|
141
|
+
await self._notify_callbacks(payload)
|
|
142
|
+
finally:
|
|
143
|
+
try:
|
|
144
|
+
entry.unlink(missing_ok=True)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("link_watcher: failed to remove %s: %s", entry, exc)
|
|
147
|
+
|
|
148
|
+
def register_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
|
|
149
|
+
"""Register a coroutine callback for processed link events."""
|
|
150
|
+
if callback in self._callbacks:
|
|
151
|
+
return
|
|
152
|
+
self._callbacks.append(callback)
|
|
153
|
+
|
|
154
|
+
async def _notify_callbacks(self, payload: Dict[str, Any]) -> None:
|
|
155
|
+
if not self._callbacks:
|
|
156
|
+
return
|
|
157
|
+
for callback in list(self._callbacks):
|
|
158
|
+
try:
|
|
159
|
+
result = callback(payload)
|
|
160
|
+
if asyncio.iscoroutine(result):
|
|
161
|
+
await result
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logger.warning("link_watcher: callback raised an exception: %s", exc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_link_event_dispatcher() -> "LinkEventDispatcher":
|
|
167
|
+
global _LINK_EVENT_DISPATCHER
|
|
168
|
+
if _LINK_EVENT_DISPATCHER is None:
|
|
169
|
+
_LINK_EVENT_DISPATCHER = LinkEventDispatcher(_LINK_EVENT_ROOT)
|
|
170
|
+
return _LINK_EVENT_DISPATCHER
|
|
171
|
+
|
|
172
|
+
|
|
35
173
|
class TerminalSession:
|
|
36
174
|
"""Represents a local shell subprocess bound to a mux channel."""
|
|
37
175
|
|
|
38
|
-
def __init__(self, session_id: str, proc: Process, channel: "Channel"):
|
|
176
|
+
def __init__(self, session_id: str, proc: Process, channel: "Channel", project_id: Optional[str] = None, terminal_manager: Optional["TerminalManager"] = None):
|
|
39
177
|
self.id = session_id
|
|
40
178
|
self.proc = proc
|
|
41
179
|
self.channel = channel
|
|
180
|
+
self.project_id = project_id
|
|
181
|
+
self.terminal_manager = terminal_manager
|
|
42
182
|
self._reader_task: Optional[asyncio.Task[None]] = None
|
|
43
|
-
|
|
183
|
+
|
|
184
|
+
# Use pyte for proper terminal screen state management
|
|
185
|
+
self._screen = pyte.HistoryScreen(TERMINAL_COLUMNS, TERMINAL_ROWS, history=TERMINAL_SCROLLBACK_LIMIT)
|
|
186
|
+
self._stream = pyte.Stream(self._screen) # Use Stream (not ByteStream) since data is already decoded to strings
|
|
187
|
+
|
|
188
|
+
# Rate limiting for terminal_data events
|
|
189
|
+
self._last_send_time: float = 0
|
|
190
|
+
self._pending_data: str = ""
|
|
191
|
+
self._debounce_task: Optional[asyncio.Task[None]] = None
|
|
44
192
|
|
|
45
193
|
async def start_io_forwarding(self) -> None:
|
|
46
194
|
"""Spawn background task that copies stdout/stderr to the channel."""
|
|
@@ -54,13 +202,9 @@ class TerminalSession:
|
|
|
54
202
|
break
|
|
55
203
|
text = data.decode(errors="ignore")
|
|
56
204
|
logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
except Exception as exc:
|
|
61
|
-
logger.warning("Failed to forward terminal output: %s", exc)
|
|
62
|
-
await asyncio.sleep(0.5)
|
|
63
|
-
continue
|
|
205
|
+
|
|
206
|
+
# Use rate-limited sending instead of immediate sending
|
|
207
|
+
await self._handle_terminal_data(text)
|
|
64
208
|
finally:
|
|
65
209
|
if self.proc and self.proc.returncode is None:
|
|
66
210
|
pass # Keep alive across reconnects
|
|
@@ -68,7 +212,7 @@ class TerminalSession:
|
|
|
68
212
|
# Cancel existing reader task if it exists
|
|
69
213
|
if self._reader_task and not self._reader_task.done():
|
|
70
214
|
self._reader_task.cancel()
|
|
71
|
-
|
|
215
|
+
|
|
72
216
|
self._reader_task = asyncio.create_task(_pump())
|
|
73
217
|
|
|
74
218
|
async def write(self, data: str) -> None:
|
|
@@ -76,21 +220,416 @@ class TerminalSession:
|
|
|
76
220
|
logger.warning("stdin pipe closed for terminal %s", self.id)
|
|
77
221
|
return
|
|
78
222
|
try:
|
|
79
|
-
self.proc.stdin
|
|
80
|
-
|
|
223
|
+
if hasattr(self.proc.stdin, 'write') and hasattr(self.proc.stdin, 'drain'):
|
|
224
|
+
# StreamWriter (pipe fallback)
|
|
225
|
+
self.proc.stdin.write(data.encode())
|
|
226
|
+
await self.proc.stdin.drain()
|
|
227
|
+
else:
|
|
228
|
+
# File object (PTY)
|
|
229
|
+
loop = asyncio.get_running_loop()
|
|
230
|
+
await loop.run_in_executor(None, self.proc.stdin.write, data.encode())
|
|
231
|
+
await loop.run_in_executor(None, self.proc.stdin.flush)
|
|
81
232
|
except Exception as exc:
|
|
82
233
|
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
83
234
|
|
|
84
235
|
async def stop(self) -> None:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
236
|
+
"""Stop the terminal session with comprehensive logging."""
|
|
237
|
+
logger.info("session.stop: Starting stop process for session %s (PID: %s)",
|
|
238
|
+
self.id, getattr(self.proc, 'pid', 'unknown'))
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
# Check if process is still running
|
|
242
|
+
if self.proc.returncode is None:
|
|
243
|
+
logger.info("session.stop: Terminating process for session %s", self.id)
|
|
244
|
+
self.proc.terminate()
|
|
245
|
+
else:
|
|
246
|
+
logger.info("session.stop: Process for session %s already exited (returncode: %s)",
|
|
247
|
+
self.id, self.proc.returncode)
|
|
248
|
+
|
|
249
|
+
# Wait for reader task to complete
|
|
250
|
+
if self._reader_task and not self._reader_task.done():
|
|
251
|
+
logger.info("session.stop: Waiting for reader task to complete for session %s", self.id)
|
|
252
|
+
try:
|
|
253
|
+
await asyncio.wait_for(self._reader_task, timeout=5.0)
|
|
254
|
+
logger.info("session.stop: Reader task completed for session %s", self.id)
|
|
255
|
+
except asyncio.TimeoutError:
|
|
256
|
+
logger.warning("session.stop: Reader task timeout for session %s, cancelling", self.id)
|
|
257
|
+
self._reader_task.cancel()
|
|
258
|
+
try:
|
|
259
|
+
await self._reader_task
|
|
260
|
+
except asyncio.CancelledError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Cancel and flush any pending terminal data
|
|
264
|
+
if self._debounce_task and not self._debounce_task.done():
|
|
265
|
+
logger.info("session.stop: Cancelling debounce task for session %s", self.id)
|
|
266
|
+
self._debounce_task.cancel()
|
|
267
|
+
try:
|
|
268
|
+
await self._debounce_task
|
|
269
|
+
except asyncio.CancelledError:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
# Send any remaining pending data
|
|
273
|
+
if self._pending_data:
|
|
274
|
+
logger.info("session.stop: Flushing pending terminal data for session %s", self.id)
|
|
275
|
+
await self._flush_pending_data()
|
|
276
|
+
|
|
277
|
+
# Wait for process to exit
|
|
278
|
+
if self.proc.returncode is None:
|
|
279
|
+
logger.info("session.stop: Waiting for process to exit for session %s", self.id)
|
|
280
|
+
await self.proc.wait()
|
|
281
|
+
logger.info("session.stop: Process exited for session %s (returncode: %s)",
|
|
282
|
+
self.id, self.proc.returncode)
|
|
283
|
+
else:
|
|
284
|
+
logger.info("session.stop: Process already exited for session %s (returncode: %s)",
|
|
285
|
+
self.id, self.proc.returncode)
|
|
286
|
+
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
logger.exception("session.stop: Error stopping session %s: %s", self.id, exc)
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
async def _send_terminal_data_now(self, data: str) -> None:
|
|
292
|
+
"""Send terminal data immediately and update last send time."""
|
|
293
|
+
self._last_send_time = time.time()
|
|
294
|
+
data_size = len(data.encode('utf-8'))
|
|
295
|
+
|
|
296
|
+
logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
|
|
297
|
+
self.id, data_size)
|
|
298
|
+
|
|
299
|
+
# Feed data to pyte screen for proper terminal state management
|
|
300
|
+
self._add_to_buffer(data)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
# Send terminal data via control channel with client session targeting
|
|
304
|
+
if self.terminal_manager:
|
|
305
|
+
await self.terminal_manager._send_session_aware({
|
|
306
|
+
"event": "terminal_data",
|
|
307
|
+
"channel": self.id,
|
|
308
|
+
"data": data,
|
|
309
|
+
"project_id": self.project_id
|
|
310
|
+
}, project_id=self.project_id)
|
|
311
|
+
logger.info("session: Successfully queued terminal_data for terminal %s via terminal_manager", self.id)
|
|
312
|
+
else:
|
|
313
|
+
# Fallback to raw channel for backward compatibility
|
|
314
|
+
await self.channel.send(data)
|
|
315
|
+
logger.info("session: Successfully sent terminal_data for terminal %s via raw channel", self.id)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
logger.warning("session: Failed to forward terminal output for terminal %s: %s", self.id, exc)
|
|
318
|
+
|
|
319
|
+
async def _flush_pending_data(self) -> None:
|
|
320
|
+
"""Send accumulated pending data and reset pending buffer."""
|
|
321
|
+
if self._pending_data:
|
|
322
|
+
pending_size = len(self._pending_data.encode('utf-8'))
|
|
323
|
+
logger.info("session: Flushing pending terminal_data for terminal %s (pending_size=%d bytes)",
|
|
324
|
+
self.id, pending_size)
|
|
325
|
+
data_to_send = self._pending_data
|
|
326
|
+
self._pending_data = ""
|
|
327
|
+
await self._send_terminal_data_now(data_to_send)
|
|
328
|
+
else:
|
|
329
|
+
logger.debug("session: No pending data to flush for terminal %s", self.id)
|
|
330
|
+
|
|
331
|
+
# Clear the debounce task
|
|
332
|
+
self._debounce_task = None
|
|
333
|
+
|
|
334
|
+
async def _handle_terminal_data(self, data: str) -> None:
|
|
335
|
+
"""Handle new terminal data with rate limiting and debouncing."""
|
|
336
|
+
current_time = time.time()
|
|
337
|
+
time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
|
|
338
|
+
data_size = len(data.encode('utf-8'))
|
|
339
|
+
|
|
340
|
+
logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
|
|
341
|
+
self.id, data_size, time_since_last_send)
|
|
342
|
+
|
|
343
|
+
# Add new data to pending buffer (no trimming needed - pyte handles screen state)
|
|
344
|
+
self._pending_data += data
|
|
345
|
+
|
|
346
|
+
# Cancel existing debounce task if any
|
|
347
|
+
if self._debounce_task and not self._debounce_task.done():
|
|
348
|
+
logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
|
|
349
|
+
self._debounce_task.cancel()
|
|
350
|
+
|
|
351
|
+
# Always set up a debounce timer to catch rapid consecutive outputs
|
|
352
|
+
async def _debounce_timer():
|
|
353
|
+
try:
|
|
354
|
+
if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
|
|
355
|
+
# Enough time has passed since last send, wait initial delay for more data
|
|
356
|
+
wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
|
|
357
|
+
logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
|
|
358
|
+
self.id, wait_time * 1000)
|
|
359
|
+
else:
|
|
360
|
+
# Too soon since last send, wait for either the rate limit period or max wait time
|
|
361
|
+
wait_time = min(
|
|
362
|
+
(TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
|
|
363
|
+
TERMINAL_DATA_MAX_WAIT_MS / 1000
|
|
364
|
+
)
|
|
365
|
+
logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
|
|
366
|
+
self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
|
|
367
|
+
|
|
368
|
+
await asyncio.sleep(wait_time)
|
|
369
|
+
logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
|
|
370
|
+
await self._flush_pending_data()
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
|
|
373
|
+
# Timer was cancelled, another data event came in
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
self._debounce_task = asyncio.create_task(_debounce_timer())
|
|
377
|
+
logger.info("session: Started debounce timer for terminal %s", self.id)
|
|
378
|
+
|
|
379
|
+
def _add_to_buffer(self, data: str) -> None:
|
|
380
|
+
"""Feed data to pyte virtual terminal screen."""
|
|
381
|
+
# Feed the data to pyte - it handles all ANSI parsing and screen state management
|
|
382
|
+
self._stream.feed(data)
|
|
90
383
|
|
|
91
384
|
def snapshot_buffer(self) -> str:
|
|
92
|
-
"""Return
|
|
93
|
-
|
|
385
|
+
"""Return the visible terminal content as ANSI sequences suitable for XTerm.js."""
|
|
386
|
+
# Render screen content to ANSI
|
|
387
|
+
result = self._render_screen_to_ansi()
|
|
388
|
+
|
|
389
|
+
# Add cursor positioning at the end so XTerm.js knows where the cursor should be
|
|
390
|
+
# This is critical - without it, new data gets written at the wrong position causing duplication
|
|
391
|
+
cursor_y = self._screen.cursor.y + 1 # Convert 0-indexed to 1-indexed
|
|
392
|
+
cursor_x = self._screen.cursor.x + 1 # Convert 0-indexed to 1-indexed
|
|
393
|
+
|
|
394
|
+
# Move cursor to the correct position
|
|
395
|
+
result += f'\x1b[{cursor_y};{cursor_x}H'
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
def _render_screen_to_ansi(self) -> str:
|
|
400
|
+
"""Convert pyte screen state to ANSI escape sequences.
|
|
401
|
+
|
|
402
|
+
This renders both scrollback history and visible screen with full formatting
|
|
403
|
+
(colors, bold, italics, underline) preserved as ANSI sequences.
|
|
404
|
+
"""
|
|
405
|
+
lines = []
|
|
406
|
+
|
|
407
|
+
# Get scrollback history if available (HistoryScreen provides this)
|
|
408
|
+
if hasattr(self._screen, 'history'):
|
|
409
|
+
# Process scrollback lines (lines that have scrolled off the top)
|
|
410
|
+
history_top = self._screen.history.top
|
|
411
|
+
for line_data in history_top:
|
|
412
|
+
# line_data is a dict mapping column positions to Char objects
|
|
413
|
+
line = self._render_line_to_ansi(line_data, self._screen.columns)
|
|
414
|
+
lines.append(line)
|
|
415
|
+
|
|
416
|
+
# Process visible screen lines
|
|
417
|
+
for y in range(self._screen.lines):
|
|
418
|
+
line_data = self._screen.buffer[y]
|
|
419
|
+
line = self._render_line_to_ansi(line_data, self._screen.columns)
|
|
420
|
+
lines.append(line)
|
|
421
|
+
|
|
422
|
+
# Join all lines with CRLF for proper terminal display
|
|
423
|
+
return '\r\n'.join(lines)
|
|
424
|
+
|
|
425
|
+
def _render_line_to_ansi(self, line_data: Dict[int, 'pyte.screens.Char'], columns: int) -> str:
|
|
426
|
+
"""Convert a single line from pyte format to ANSI escape sequences.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
line_data: Dict mapping column index to Char objects
|
|
430
|
+
columns: Number of columns in the terminal
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
String with ANSI escape codes for formatting
|
|
434
|
+
"""
|
|
435
|
+
result = []
|
|
436
|
+
last_char = None
|
|
437
|
+
did_reset = False # Track if we just emitted a reset code
|
|
438
|
+
|
|
439
|
+
for x in range(columns):
|
|
440
|
+
char = line_data.get(x)
|
|
441
|
+
if char is None:
|
|
442
|
+
# Empty cell - reset formatting if we had any
|
|
443
|
+
if last_char is not None and self._char_has_formatting(last_char):
|
|
444
|
+
result.append('\x1b[0m')
|
|
445
|
+
did_reset = True
|
|
446
|
+
result.append(' ')
|
|
447
|
+
last_char = None
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
# Check if formatting changed from previous character
|
|
451
|
+
format_changed = last_char is None or self._char_format_changed(last_char, char) or did_reset
|
|
452
|
+
|
|
453
|
+
if format_changed:
|
|
454
|
+
# If previous char had formatting and current is different, reset first
|
|
455
|
+
if last_char is not None and self._char_has_formatting(last_char) and not did_reset:
|
|
456
|
+
result.append('\x1b[0m')
|
|
457
|
+
|
|
458
|
+
# Apply new formatting (always apply after reset)
|
|
459
|
+
ansi_codes = self._get_ansi_codes_for_char(char)
|
|
460
|
+
if ansi_codes:
|
|
461
|
+
result.append(f'\x1b[{ansi_codes}m')
|
|
462
|
+
did_reset = False
|
|
463
|
+
else:
|
|
464
|
+
did_reset = True # No formatting to apply after reset
|
|
465
|
+
|
|
466
|
+
# Add the character data
|
|
467
|
+
result.append(char.data)
|
|
468
|
+
last_char = char
|
|
469
|
+
|
|
470
|
+
# Reset formatting at end of line if we had any
|
|
471
|
+
if last_char is not None and self._char_has_formatting(last_char):
|
|
472
|
+
result.append('\x1b[0m')
|
|
473
|
+
|
|
474
|
+
# Strip trailing whitespace from the line
|
|
475
|
+
line_text = ''.join(result).rstrip()
|
|
476
|
+
return line_text
|
|
477
|
+
|
|
478
|
+
def _char_has_formatting(self, char: 'pyte.screens.Char') -> bool:
|
|
479
|
+
"""Check if a character has any formatting applied."""
|
|
480
|
+
return (char.bold or
|
|
481
|
+
(hasattr(char, 'dim') and char.dim) or
|
|
482
|
+
char.italics or
|
|
483
|
+
char.underscore or
|
|
484
|
+
(hasattr(char, 'blink') and char.blink) or
|
|
485
|
+
char.reverse or
|
|
486
|
+
(hasattr(char, 'hidden') and char.hidden) or
|
|
487
|
+
char.strikethrough or
|
|
488
|
+
char.fg != 'default' or
|
|
489
|
+
char.bg != 'default')
|
|
490
|
+
|
|
491
|
+
def _char_format_changed(self, char1: 'pyte.screens.Char', char2: 'pyte.screens.Char') -> bool:
|
|
492
|
+
"""Check if formatting changed between two characters."""
|
|
493
|
+
return (char1.bold != char2.bold or
|
|
494
|
+
(hasattr(char1, 'dim') and hasattr(char2, 'dim') and char1.dim != char2.dim) or
|
|
495
|
+
char1.italics != char2.italics or
|
|
496
|
+
char1.underscore != char2.underscore or
|
|
497
|
+
(hasattr(char1, 'blink') and hasattr(char2, 'blink') and char1.blink != char2.blink) or
|
|
498
|
+
char1.reverse != char2.reverse or
|
|
499
|
+
(hasattr(char1, 'hidden') and hasattr(char2, 'hidden') and char1.hidden != char2.hidden) or
|
|
500
|
+
char1.strikethrough != char2.strikethrough or
|
|
501
|
+
char1.fg != char2.fg or
|
|
502
|
+
char1.bg != char2.bg)
|
|
503
|
+
|
|
504
|
+
def _get_ansi_codes_for_char(self, char: 'pyte.screens.Char') -> str:
|
|
505
|
+
"""Convert pyte Char formatting to ANSI escape codes.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
String of semicolon-separated ANSI codes (e.g., "1;32;44")
|
|
509
|
+
"""
|
|
510
|
+
codes = []
|
|
511
|
+
|
|
512
|
+
# Text attributes - comprehensive list matching ANSI SGR codes
|
|
513
|
+
if char.bold:
|
|
514
|
+
codes.append('1')
|
|
515
|
+
if hasattr(char, 'dim') and char.dim:
|
|
516
|
+
codes.append('2')
|
|
517
|
+
if char.italics:
|
|
518
|
+
codes.append('3')
|
|
519
|
+
if char.underscore:
|
|
520
|
+
codes.append('4')
|
|
521
|
+
if hasattr(char, 'blink') and char.blink:
|
|
522
|
+
codes.append('5')
|
|
523
|
+
if char.reverse:
|
|
524
|
+
codes.append('7')
|
|
525
|
+
if hasattr(char, 'hidden') and char.hidden:
|
|
526
|
+
codes.append('8')
|
|
527
|
+
if char.strikethrough:
|
|
528
|
+
codes.append('9')
|
|
529
|
+
|
|
530
|
+
# Foreground color
|
|
531
|
+
if char.fg != 'default':
|
|
532
|
+
fg_code = self._color_to_ansi(char.fg, is_background=False)
|
|
533
|
+
if fg_code:
|
|
534
|
+
codes.append(fg_code)
|
|
535
|
+
|
|
536
|
+
# Background color
|
|
537
|
+
if char.bg != 'default':
|
|
538
|
+
bg_code = self._color_to_ansi(char.bg, is_background=True)
|
|
539
|
+
if bg_code:
|
|
540
|
+
codes.append(bg_code)
|
|
541
|
+
|
|
542
|
+
return ';'.join(codes)
|
|
543
|
+
|
|
544
|
+
def _color_to_ansi(self, color, is_background: bool = False) -> Optional[str]:
|
|
545
|
+
"""Convert pyte color to ANSI color code.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
color: Color value (can be string name, int for 256-color, hex string, or tuple for RGB)
|
|
549
|
+
is_background: True for background color, False for foreground
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
ANSI color code string or None
|
|
553
|
+
"""
|
|
554
|
+
# Handle default/None
|
|
555
|
+
if color == 'default' or color is None:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# Standard base for 8 basic colors
|
|
559
|
+
base = 40 if is_background else 30
|
|
560
|
+
|
|
561
|
+
if isinstance(color, str):
|
|
562
|
+
# pyte stores colors as lowercase strings
|
|
563
|
+
color_lower = color.lower()
|
|
564
|
+
|
|
565
|
+
# Check for hex color format (pyte stores RGB as hex strings like '4782c8')
|
|
566
|
+
# Hex strings are 6 characters (RRGGBB)
|
|
567
|
+
if len(color_lower) == 6 and all(c in '0123456789abcdef' for c in color_lower):
|
|
568
|
+
try:
|
|
569
|
+
# Parse hex string to RGB
|
|
570
|
+
r = int(color_lower[0:2], 16)
|
|
571
|
+
g = int(color_lower[2:4], 16)
|
|
572
|
+
b = int(color_lower[4:6], 16)
|
|
573
|
+
return f'{"48" if is_background else "38"};2;{r};{g};{b}'
|
|
574
|
+
except ValueError:
|
|
575
|
+
pass # Not a valid hex color, continue to other checks
|
|
576
|
+
|
|
577
|
+
# Named colors (black, red, green, yellow, blue, magenta, cyan, white)
|
|
578
|
+
color_map = {
|
|
579
|
+
'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
|
|
580
|
+
'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
# Check for bright/intense colors first (pyte may use different formats)
|
|
584
|
+
# Format 1: "brightred", "brightblue", etc.
|
|
585
|
+
if color_lower.startswith('bright') and len(color_lower) > 6:
|
|
586
|
+
color_base = color_lower[6:] # Remove 'bright' prefix
|
|
587
|
+
if color_base in color_map:
|
|
588
|
+
# Bright colors: 90-97 (fg), 100-107 (bg)
|
|
589
|
+
return str(base + 60 + color_map[color_base])
|
|
590
|
+
|
|
591
|
+
# Format 2: "bright_red", "bright_blue", etc.
|
|
592
|
+
if color_lower.startswith('bright_'):
|
|
593
|
+
color_base = color_lower[7:] # Remove 'bright_' prefix
|
|
594
|
+
if color_base in color_map:
|
|
595
|
+
return str(base + 60 + color_map[color_base])
|
|
596
|
+
|
|
597
|
+
# Standard color names
|
|
598
|
+
if color_lower in color_map:
|
|
599
|
+
return str(base + color_map[color_lower])
|
|
600
|
+
|
|
601
|
+
# Some terminals use color names like "brown" instead of "yellow"
|
|
602
|
+
color_aliases = {
|
|
603
|
+
'brown': 3, # yellow
|
|
604
|
+
'lightgray': 7, 'lightgrey': 7, # white
|
|
605
|
+
'darkgray': 0, 'darkgrey': 0, # black
|
|
606
|
+
}
|
|
607
|
+
if color_lower in color_aliases:
|
|
608
|
+
return str(base + color_aliases[color_lower])
|
|
609
|
+
|
|
610
|
+
elif isinstance(color, int):
|
|
611
|
+
# 256-color palette (0-255)
|
|
612
|
+
# Note: 0-15 are the basic and bright colors, 16-231 are 216 color cube, 232-255 are grayscale
|
|
613
|
+
if 0 <= color <= 255:
|
|
614
|
+
return f'{"48" if is_background else "38"};5;{color}'
|
|
615
|
+
|
|
616
|
+
elif isinstance(color, tuple) and len(color) == 3:
|
|
617
|
+
# RGB color (true color / 24-bit)
|
|
618
|
+
try:
|
|
619
|
+
r, g, b = color
|
|
620
|
+
# Ensure values are in valid range
|
|
621
|
+
r = max(0, min(255, int(r)))
|
|
622
|
+
g = max(0, min(255, int(g)))
|
|
623
|
+
b = max(0, min(255, int(b)))
|
|
624
|
+
return f'{"48" if is_background else "38"};2;{r};{g};{b}'
|
|
625
|
+
except (ValueError, TypeError):
|
|
626
|
+
logger.warning("Invalid RGB color tuple: %s", color)
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
# If we got here, we don't recognize the color format
|
|
630
|
+
logger.info("PYTE_COLOR_DEBUG: Unrecognized color format - type: %s, value: %r, is_bg: %s",
|
|
631
|
+
type(color).__name__, color, is_background)
|
|
632
|
+
return None
|
|
94
633
|
|
|
95
634
|
async def reattach_channel(self, new_channel: "Channel") -> None:
|
|
96
635
|
"""Reattach this session to a new channel after reconnection."""
|
|
@@ -103,7 +642,7 @@ class TerminalSession:
|
|
|
103
642
|
class WindowsTerminalSession(TerminalSession):
|
|
104
643
|
"""Terminal session backed by a Windows ConPTY."""
|
|
105
644
|
|
|
106
|
-
def __init__(self, session_id: str, pty, channel: "Channel"):
|
|
645
|
+
def __init__(self, session_id: str, pty, channel: "Channel", project_id: Optional[str] = None, terminal_manager: Optional["TerminalManager"] = None):
|
|
107
646
|
# Create a proxy for the PTY process
|
|
108
647
|
class _WinPTYProxy:
|
|
109
648
|
def __init__(self, pty):
|
|
@@ -121,7 +660,7 @@ class WindowsTerminalSession(TerminalSession):
|
|
|
121
660
|
loop = asyncio.get_running_loop()
|
|
122
661
|
await loop.run_in_executor(None, self._pty.wait)
|
|
123
662
|
|
|
124
|
-
super().__init__(session_id, _WinPTYProxy(pty), channel)
|
|
663
|
+
super().__init__(session_id, _WinPTYProxy(pty), channel, project_id, terminal_manager)
|
|
125
664
|
self._pty = pty
|
|
126
665
|
|
|
127
666
|
async def start_io_forwarding(self) -> None:
|
|
@@ -142,13 +681,9 @@ class WindowsTerminalSession(TerminalSession):
|
|
|
142
681
|
else:
|
|
143
682
|
text = data
|
|
144
683
|
logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
except Exception as exc:
|
|
149
|
-
logger.warning("Failed to forward terminal output: %s", exc)
|
|
150
|
-
await asyncio.sleep(0.5)
|
|
151
|
-
continue
|
|
684
|
+
|
|
685
|
+
# Use rate-limited sending instead of immediate sending
|
|
686
|
+
await self._handle_terminal_data(text)
|
|
152
687
|
finally:
|
|
153
688
|
if self._pty and self._pty.isalive():
|
|
154
689
|
self._pty.kill()
|
|
@@ -167,27 +702,74 @@ class WindowsTerminalSession(TerminalSession):
|
|
|
167
702
|
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
168
703
|
|
|
169
704
|
async def stop(self) -> None:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
705
|
+
"""Stop the Windows terminal session with comprehensive logging."""
|
|
706
|
+
logger.info("session.stop: Starting stop process for Windows session %s (PID: %s)",
|
|
707
|
+
self.id, getattr(self._pty, 'pid', 'unknown'))
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
# Check if PTY is still alive
|
|
711
|
+
if self._pty.isalive():
|
|
712
|
+
logger.info("session.stop: Killing PTY process for session %s", self.id)
|
|
713
|
+
self._pty.kill()
|
|
714
|
+
else:
|
|
715
|
+
logger.info("session.stop: PTY process for session %s already exited", self.id)
|
|
716
|
+
|
|
717
|
+
# Wait for reader task to complete
|
|
718
|
+
if self._reader_task and not self._reader_task.done():
|
|
719
|
+
logger.info("session.stop: Waiting for reader task to complete for Windows session %s", self.id)
|
|
720
|
+
try:
|
|
721
|
+
await asyncio.wait_for(self._reader_task, timeout=5.0)
|
|
722
|
+
logger.info("session.stop: Reader task completed for Windows session %s", self.id)
|
|
723
|
+
except asyncio.TimeoutError:
|
|
724
|
+
logger.warning("session.stop: Reader task timeout for Windows session %s, cancelling", self.id)
|
|
725
|
+
self._reader_task.cancel()
|
|
726
|
+
try:
|
|
727
|
+
await self._reader_task
|
|
728
|
+
except asyncio.CancelledError:
|
|
729
|
+
pass
|
|
730
|
+
|
|
731
|
+
# Cancel and flush any pending terminal data
|
|
732
|
+
if self._debounce_task and not self._debounce_task.done():
|
|
733
|
+
logger.info("session.stop: Cancelling debounce task for Windows session %s", self.id)
|
|
734
|
+
self._debounce_task.cancel()
|
|
735
|
+
try:
|
|
736
|
+
await self._debounce_task
|
|
737
|
+
except asyncio.CancelledError:
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
# Send any remaining pending data
|
|
741
|
+
if self._pending_data:
|
|
742
|
+
logger.info("session.stop: Flushing pending terminal data for Windows session %s", self.id)
|
|
743
|
+
await self._flush_pending_data()
|
|
744
|
+
|
|
745
|
+
logger.info("session.stop: Successfully stopped Windows session %s", self.id)
|
|
746
|
+
|
|
747
|
+
except Exception as exc:
|
|
748
|
+
logger.exception("session.stop: Error stopping Windows session %s: %s", self.id, exc)
|
|
749
|
+
raise
|
|
174
750
|
|
|
175
751
|
|
|
176
752
|
class SessionManager:
|
|
177
753
|
"""Manages terminal sessions."""
|
|
178
754
|
|
|
179
|
-
def __init__(self, mux):
|
|
755
|
+
def __init__(self, mux, terminal_manager=None):
|
|
180
756
|
self.mux = mux
|
|
757
|
+
self.terminal_manager = terminal_manager
|
|
181
758
|
self._sessions: Dict[str, TerminalSession] = {}
|
|
759
|
+
self._link_event_dispatcher = _get_link_event_dispatcher()
|
|
760
|
+
self._link_event_dispatcher.register_callback(self._handle_link_capture_event)
|
|
761
|
+
self._link_event_dispatcher.start()
|
|
182
762
|
|
|
183
763
|
def _allocate_channel_id(self) -> str:
|
|
184
764
|
"""Allocate a new unique channel ID for a terminal session using UUID."""
|
|
185
765
|
return uuid.uuid4().hex
|
|
186
766
|
|
|
187
|
-
async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
767
|
+
async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None, project_id: Optional[str] = None) -> Dict[str, Any]:
|
|
188
768
|
"""Create a new terminal session."""
|
|
189
|
-
|
|
190
|
-
|
|
769
|
+
# Use the same UUID for both terminal_id and channel_id to ensure consistency
|
|
770
|
+
session_uuid = uuid.uuid4().hex
|
|
771
|
+
term_id = session_uuid
|
|
772
|
+
channel_id = session_uuid
|
|
191
773
|
channel = self.mux.get_channel(channel_id)
|
|
192
774
|
|
|
193
775
|
# Choose shell - prefer bash over sh for better terminal compatibility
|
|
@@ -206,6 +788,27 @@ class SessionManager:
|
|
|
206
788
|
|
|
207
789
|
logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
|
|
208
790
|
|
|
791
|
+
env = _build_child_env()
|
|
792
|
+
|
|
793
|
+
env["PORTACODE_LINK_CHANNEL"] = str(_LINK_EVENT_ROOT)
|
|
794
|
+
env["PORTACODE_TERMINAL_ID"] = term_id
|
|
795
|
+
|
|
796
|
+
bin_dir = prepare_link_capture_bin()
|
|
797
|
+
if bin_dir:
|
|
798
|
+
current_path = env.get("PATH", os.environ.get("PATH", ""))
|
|
799
|
+
path_entries = current_path.split(os.pathsep) if current_path else []
|
|
800
|
+
bin_str = str(bin_dir)
|
|
801
|
+
if bin_str not in path_entries:
|
|
802
|
+
env["PATH"] = os.pathsep.join([bin_str] + path_entries) if path_entries else bin_str
|
|
803
|
+
browser_path = bin_dir / "xdg-open"
|
|
804
|
+
if browser_path.exists():
|
|
805
|
+
original_browser = env.get("BROWSER")
|
|
806
|
+
if original_browser:
|
|
807
|
+
env[LINK_CAPTURE_ORIGINAL_BROWSER_ENV] = original_browser
|
|
808
|
+
elif LINK_CAPTURE_ORIGINAL_BROWSER_ENV in env:
|
|
809
|
+
env.pop(LINK_CAPTURE_ORIGINAL_BROWSER_ENV, None)
|
|
810
|
+
env["BROWSER"] = str(browser_path)
|
|
811
|
+
|
|
209
812
|
if _IS_WINDOWS:
|
|
210
813
|
try:
|
|
211
814
|
from winpty import PtyProcess
|
|
@@ -213,13 +816,14 @@ class SessionManager:
|
|
|
213
816
|
logger.error("winpty (pywinpty) not found: %s", exc)
|
|
214
817
|
raise RuntimeError("pywinpty not installed on client")
|
|
215
818
|
|
|
216
|
-
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=
|
|
217
|
-
session = WindowsTerminalSession(term_id, pty_proc, channel)
|
|
819
|
+
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=env)
|
|
820
|
+
session = WindowsTerminalSession(term_id, pty_proc, channel, project_id, self.terminal_manager)
|
|
218
821
|
else:
|
|
219
822
|
# Unix: try real PTY for proper TTY semantics
|
|
220
823
|
try:
|
|
221
824
|
import pty
|
|
222
825
|
master_fd, slave_fd = pty.openpty()
|
|
826
|
+
_configure_pty_window_size(slave_fd, TERMINAL_ROWS, TERMINAL_COLUMNS)
|
|
223
827
|
proc = await asyncio.create_subprocess_exec(
|
|
224
828
|
shell,
|
|
225
829
|
stdin=slave_fd,
|
|
@@ -227,7 +831,7 @@ class SessionManager:
|
|
|
227
831
|
stderr=slave_fd,
|
|
228
832
|
preexec_fn=os.setsid,
|
|
229
833
|
cwd=cwd,
|
|
230
|
-
env=
|
|
834
|
+
env=env,
|
|
231
835
|
)
|
|
232
836
|
# Wrap master_fd into a StreamReader
|
|
233
837
|
loop = asyncio.get_running_loop()
|
|
@@ -235,11 +839,8 @@ class SessionManager:
|
|
|
235
839
|
protocol = asyncio.StreamReaderProtocol(reader)
|
|
236
840
|
await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
|
|
237
841
|
proc.stdout = reader
|
|
238
|
-
# Use writer for stdin
|
|
239
|
-
|
|
240
|
-
lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0)
|
|
241
|
-
)
|
|
242
|
-
proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop)
|
|
842
|
+
# Use writer for stdin - create a simple file-like wrapper
|
|
843
|
+
proc.stdin = os.fdopen(master_fd, "wb", buffering=0)
|
|
243
844
|
except Exception:
|
|
244
845
|
logger.warning("Failed to allocate PTY, falling back to pipes")
|
|
245
846
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -248,9 +849,9 @@ class SessionManager:
|
|
|
248
849
|
stdout=asyncio.subprocess.PIPE,
|
|
249
850
|
stderr=asyncio.subprocess.STDOUT,
|
|
250
851
|
cwd=cwd,
|
|
251
|
-
env=
|
|
852
|
+
env=env,
|
|
252
853
|
)
|
|
253
|
-
session = TerminalSession(term_id, proc, channel)
|
|
854
|
+
session = TerminalSession(term_id, proc, channel, project_id, self.terminal_manager)
|
|
254
855
|
|
|
255
856
|
self._sessions[term_id] = session
|
|
256
857
|
await session.start_io_forwarding()
|
|
@@ -261,7 +862,40 @@ class SessionManager:
|
|
|
261
862
|
"pid": session.proc.pid,
|
|
262
863
|
"shell": shell,
|
|
263
864
|
"cwd": cwd,
|
|
865
|
+
"project_id": project_id,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async def _handle_link_capture_event(self, payload: Dict[str, Any]) -> None:
|
|
869
|
+
"""Translate link capture files into websocket events."""
|
|
870
|
+
link = payload.get("url")
|
|
871
|
+
terminal_id = payload.get("terminal_id")
|
|
872
|
+
if not link:
|
|
873
|
+
logger.debug("session_manager: Ignoring link capture without URL (%s)", payload)
|
|
874
|
+
return
|
|
875
|
+
if not terminal_id:
|
|
876
|
+
logger.warning("session_manager: Link capture missing terminal_id: %s", payload)
|
|
877
|
+
return
|
|
878
|
+
session = self.get_session(terminal_id)
|
|
879
|
+
if not session:
|
|
880
|
+
logger.info("session_manager: No active session for terminal %s, dropping link event", terminal_id)
|
|
881
|
+
return
|
|
882
|
+
if not self.terminal_manager:
|
|
883
|
+
logger.warning("session_manager: No terminal_manager available for link event")
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
event_payload = {
|
|
887
|
+
"event": "terminal_link_request",
|
|
888
|
+
"terminal_id": session.id,
|
|
889
|
+
"channel": getattr(session.channel, "id", session.id),
|
|
890
|
+
"url": link,
|
|
891
|
+
"command": payload.get("command"),
|
|
892
|
+
"args": payload.get("args"),
|
|
893
|
+
"pid": getattr(session.proc, "pid", None),
|
|
894
|
+
"timestamp": payload.get("timestamp"),
|
|
895
|
+
"project_id": session.project_id,
|
|
264
896
|
}
|
|
897
|
+
logger.info("session_manager: Dispatching link request for terminal %s to clients", terminal_id)
|
|
898
|
+
await self.terminal_manager._send_session_aware(event_payload, project_id=session.project_id)
|
|
265
899
|
|
|
266
900
|
def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
267
901
|
"""Get a terminal session by ID."""
|
|
@@ -269,10 +903,27 @@ class SessionManager:
|
|
|
269
903
|
|
|
270
904
|
def remove_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
271
905
|
"""Remove and return a terminal session."""
|
|
272
|
-
|
|
906
|
+
session = self._sessions.pop(terminal_id, None)
|
|
907
|
+
if session:
|
|
908
|
+
logger.info("session_manager: Removed session %s (PID: %s) from session manager",
|
|
909
|
+
terminal_id, getattr(session.proc, 'pid', 'unknown'))
|
|
910
|
+
else:
|
|
911
|
+
logger.warning("session_manager: Attempted to remove non-existent session %s", terminal_id)
|
|
912
|
+
return session
|
|
913
|
+
|
|
914
|
+
def list_sessions(self, project_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
915
|
+
"""List all terminal sessions, optionally filtered by project_id."""
|
|
916
|
+
filtered_sessions = []
|
|
917
|
+
for s in self._sessions.values():
|
|
918
|
+
if project_id == "all":
|
|
919
|
+
filtered_sessions.append(s)
|
|
920
|
+
elif project_id is None:
|
|
921
|
+
if s.project_id is None:
|
|
922
|
+
filtered_sessions.append(s)
|
|
923
|
+
else:
|
|
924
|
+
if s.project_id == project_id:
|
|
925
|
+
filtered_sessions.append(s)
|
|
273
926
|
|
|
274
|
-
def list_sessions(self) -> List[Dict[str, Any]]:
|
|
275
|
-
"""List all terminal sessions."""
|
|
276
927
|
return [
|
|
277
928
|
{
|
|
278
929
|
"terminal_id": s.id,
|
|
@@ -284,8 +935,9 @@ class SessionManager:
|
|
|
284
935
|
"created_at": None, # Could add timestamp if needed
|
|
285
936
|
"shell": None, # Could store shell info if needed
|
|
286
937
|
"cwd": None, # Could store cwd info if needed
|
|
938
|
+
"project_id": s.project_id,
|
|
287
939
|
}
|
|
288
|
-
for s in
|
|
940
|
+
for s in filtered_sessions
|
|
289
941
|
]
|
|
290
942
|
|
|
291
943
|
async def reattach_sessions(self, mux):
|
|
@@ -312,4 +964,4 @@ class SessionManager:
|
|
|
312
964
|
await sess.reattach_channel(new_channel)
|
|
313
965
|
logger.info("Successfully reattached terminal %s", sess.id)
|
|
314
966
|
except Exception as exc:
|
|
315
|
-
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|
|
967
|
+
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|