portacode 0.2.1.dev0__tar.gz → 0.2.3.dev0__tar.gz
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.
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/PKG-INFO +1 -1
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/_version.py +2 -2
- portacode-0.2.3.dev0/portacode/connection/handlers/__init__.py +28 -0
- portacode-0.2.3.dev0/portacode/connection/handlers/base.py +115 -0
- portacode-0.2.3.dev0/portacode/connection/handlers/registry.py +111 -0
- portacode-0.2.3.dev0/portacode/connection/handlers/session.py +275 -0
- portacode-0.2.3.dev0/portacode/connection/handlers/system_handlers.py +32 -0
- portacode-0.2.3.dev0/portacode/connection/handlers/terminal_handlers.py +139 -0
- portacode-0.2.3.dev0/portacode/connection/terminal.py +177 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/PKG-INFO +1 -1
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/SOURCES.txt +7 -1
- portacode-0.2.1.dev0/portacode/connection/terminal.py +0 -501
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/.gitignore +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/.gitmodules +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/MANIFEST.in +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/Makefile +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/README.md +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/docker-compose.yaml +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/README.md +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/__init__.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/__main__.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/cli.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/README.md +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/__init__.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/client.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/multiplex.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/data.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/keypair.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/service.py +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/dependency_links.txt +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/entry_points.txt +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/requires.txt +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/top_level.txt +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/pyproject.toml +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/setup.cfg +0 -0
- {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/setup.py +0 -0
|
@@ -17,5 +17,5 @@ __version__: str
|
|
|
17
17
|
__version_tuple__: VERSION_TUPLE
|
|
18
18
|
version_tuple: VERSION_TUPLE
|
|
19
19
|
|
|
20
|
-
__version__ = version = '0.2.
|
|
21
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
20
|
+
__version__ = version = '0.2.3.dev'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 2, 3, 'dev0')
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Modular command handler system for Portacode client.
|
|
2
|
+
|
|
3
|
+
This package provides a flexible system for handling commands from the gateway.
|
|
4
|
+
Handlers can be easily added, removed, or modified without touching the main
|
|
5
|
+
terminal manager code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import BaseHandler, AsyncHandler, SyncHandler
|
|
9
|
+
from .registry import CommandRegistry
|
|
10
|
+
from .terminal_handlers import (
|
|
11
|
+
TerminalStartHandler,
|
|
12
|
+
TerminalSendHandler,
|
|
13
|
+
TerminalStopHandler,
|
|
14
|
+
TerminalListHandler,
|
|
15
|
+
)
|
|
16
|
+
from .system_handlers import SystemInfoHandler
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BaseHandler",
|
|
20
|
+
"AsyncHandler",
|
|
21
|
+
"SyncHandler",
|
|
22
|
+
"CommandRegistry",
|
|
23
|
+
"TerminalStartHandler",
|
|
24
|
+
"TerminalSendHandler",
|
|
25
|
+
"TerminalStopHandler",
|
|
26
|
+
"TerminalListHandler",
|
|
27
|
+
"SystemInfoHandler",
|
|
28
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Base handler classes for command processing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..multiplex import Channel
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseHandler(ABC):
|
|
15
|
+
"""Base class for all command handlers."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, control_channel: "Channel", context: Dict[str, Any]):
|
|
18
|
+
"""Initialize the handler.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
control_channel: The control channel for sending responses
|
|
22
|
+
context: Shared context containing terminal manager state
|
|
23
|
+
"""
|
|
24
|
+
self.control_channel = control_channel
|
|
25
|
+
self.context = context
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def command_name(self) -> str:
|
|
30
|
+
"""Return the command name this handler processes."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
35
|
+
"""Handle the command message.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
message: The command message dict
|
|
39
|
+
reply_channel: Optional reply channel for responses
|
|
40
|
+
"""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
44
|
+
"""Send a response back to the gateway.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
payload: Response payload
|
|
48
|
+
reply_channel: Optional reply channel
|
|
49
|
+
"""
|
|
50
|
+
if reply_channel:
|
|
51
|
+
payload["reply_channel"] = reply_channel
|
|
52
|
+
await self.control_channel.send(payload)
|
|
53
|
+
|
|
54
|
+
async def send_error(self, message: str, reply_channel: Optional[str] = None) -> None:
|
|
55
|
+
"""Send an error response.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
message: Error message
|
|
59
|
+
reply_channel: Optional reply channel
|
|
60
|
+
"""
|
|
61
|
+
payload = {"event": "error", "message": message}
|
|
62
|
+
if reply_channel:
|
|
63
|
+
payload["reply_channel"] = reply_channel
|
|
64
|
+
await self.control_channel.send(payload)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncHandler(BaseHandler):
|
|
68
|
+
"""Base class for asynchronous command handlers."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
72
|
+
"""Execute the command logic asynchronously.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
message: The command message dict
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Response payload dict
|
|
79
|
+
"""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
83
|
+
"""Handle the command by executing it and sending the response."""
|
|
84
|
+
try:
|
|
85
|
+
response = await self.execute(message)
|
|
86
|
+
await self.send_response(response, reply_channel)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logger.exception("Error in async handler %s: %s", self.command_name, exc)
|
|
89
|
+
await self.send_error(str(exc), reply_channel)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SyncHandler(BaseHandler):
|
|
93
|
+
"""Base class for synchronous command handlers."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
97
|
+
"""Execute the command logic synchronously.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
message: The command message dict
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Response payload dict
|
|
104
|
+
"""
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
108
|
+
"""Handle the command by executing it in an executor and sending the response."""
|
|
109
|
+
try:
|
|
110
|
+
loop = asyncio.get_running_loop()
|
|
111
|
+
response = await loop.run_in_executor(None, self.execute, message)
|
|
112
|
+
await self.send_response(response, reply_channel)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.exception("Error in sync handler %s: %s", self.command_name, exc)
|
|
115
|
+
await self.send_error(str(exc), reply_channel)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command registry for managing handler dispatch."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ..multiplex import Channel
|
|
8
|
+
from .base import BaseHandler
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandRegistry:
|
|
14
|
+
"""Registry for managing command handlers."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, control_channel: "Channel", context: Dict[str, Any]):
|
|
17
|
+
"""Initialize the command registry.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
control_channel: The control channel for handlers
|
|
21
|
+
context: Shared context for handlers
|
|
22
|
+
"""
|
|
23
|
+
self.control_channel = control_channel
|
|
24
|
+
self.context = context
|
|
25
|
+
self._handlers: Dict[str, "BaseHandler"] = {}
|
|
26
|
+
|
|
27
|
+
def register(self, handler_class: Type["BaseHandler"]) -> None:
|
|
28
|
+
"""Register a command handler.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
handler_class: The handler class to register
|
|
32
|
+
"""
|
|
33
|
+
handler_instance = handler_class(self.control_channel, self.context)
|
|
34
|
+
command_name = handler_instance.command_name
|
|
35
|
+
|
|
36
|
+
if command_name in self._handlers:
|
|
37
|
+
logger.warning("Overriding existing handler for command: %s", command_name)
|
|
38
|
+
|
|
39
|
+
self._handlers[command_name] = handler_instance
|
|
40
|
+
logger.debug("Registered handler for command: %s", command_name)
|
|
41
|
+
|
|
42
|
+
def unregister(self, command_name: str) -> None:
|
|
43
|
+
"""Unregister a command handler.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
command_name: The command name to unregister
|
|
47
|
+
"""
|
|
48
|
+
if command_name in self._handlers:
|
|
49
|
+
del self._handlers[command_name]
|
|
50
|
+
logger.debug("Unregistered handler for command: %s", command_name)
|
|
51
|
+
else:
|
|
52
|
+
logger.warning("Attempted to unregister non-existent handler: %s", command_name)
|
|
53
|
+
|
|
54
|
+
def get_handler(self, command_name: str) -> Optional["BaseHandler"]:
|
|
55
|
+
"""Get a handler by command name.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
command_name: The command name
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The handler instance or None if not found
|
|
62
|
+
"""
|
|
63
|
+
return self._handlers.get(command_name)
|
|
64
|
+
|
|
65
|
+
def list_commands(self) -> List[str]:
|
|
66
|
+
"""List all registered command names.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of command names
|
|
70
|
+
"""
|
|
71
|
+
return list(self._handlers.keys())
|
|
72
|
+
|
|
73
|
+
async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
|
|
74
|
+
"""Dispatch a command to its handler.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
command_name: The command name
|
|
78
|
+
message: The command message
|
|
79
|
+
reply_channel: Optional reply channel
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if handler was found and executed, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
handler = self.get_handler(command_name)
|
|
85
|
+
if handler is None:
|
|
86
|
+
logger.warning("No handler found for command: %s", command_name)
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
await handler.handle(message, reply_channel)
|
|
91
|
+
return True
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
logger.exception("Error dispatching command %s: %s", command_name, exc)
|
|
94
|
+
# Send error response
|
|
95
|
+
error_payload = {"event": "error", "message": str(exc)}
|
|
96
|
+
if reply_channel:
|
|
97
|
+
error_payload["reply_channel"] = reply_channel
|
|
98
|
+
await self.control_channel.send(error_payload)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def update_context(self, context: Dict[str, Any]) -> None:
|
|
102
|
+
"""Update the shared context for all handlers.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
context: New context dict
|
|
106
|
+
"""
|
|
107
|
+
self.context.update(context)
|
|
108
|
+
|
|
109
|
+
# Update context for all existing handlers
|
|
110
|
+
for handler in self._handlers.values():
|
|
111
|
+
handler.context = self.context
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Terminal session management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from asyncio.subprocess import Process
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional, List, TYPE_CHECKING
|
|
11
|
+
from collections import deque
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..multiplex import Channel
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_IS_WINDOWS = sys.platform.startswith("win")
|
|
19
|
+
|
|
20
|
+
# Minimal, safe defaults for interactive shells
|
|
21
|
+
_DEFAULT_ENV = {
|
|
22
|
+
"TERM": "xterm-256color",
|
|
23
|
+
"LANG": "C.UTF-8",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_child_env() -> Dict[str, str]:
|
|
28
|
+
"""Return a copy of os.environ with sensible fallbacks added."""
|
|
29
|
+
env = os.environ.copy()
|
|
30
|
+
for k, v in _DEFAULT_ENV.items():
|
|
31
|
+
env.setdefault(k, v)
|
|
32
|
+
return env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TerminalSession:
|
|
36
|
+
"""Represents a local shell subprocess bound to a mux channel."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session_id: str, proc: Process, channel: "Channel"):
|
|
39
|
+
self.id = session_id
|
|
40
|
+
self.proc = proc
|
|
41
|
+
self.channel = channel
|
|
42
|
+
self._reader_task: Optional[asyncio.Task[None]] = None
|
|
43
|
+
self._buffer: deque[str] = deque(maxlen=400)
|
|
44
|
+
|
|
45
|
+
async def start_io_forwarding(self) -> None:
|
|
46
|
+
"""Spawn background task that copies stdout/stderr to the channel."""
|
|
47
|
+
assert self.proc.stdout is not None, "stdout pipe not set"
|
|
48
|
+
|
|
49
|
+
async def _pump() -> None:
|
|
50
|
+
try:
|
|
51
|
+
while True:
|
|
52
|
+
data = await self.proc.stdout.read(1024)
|
|
53
|
+
if not data:
|
|
54
|
+
break
|
|
55
|
+
text = data.decode(errors="ignore")
|
|
56
|
+
logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
|
|
57
|
+
self._buffer.append(text)
|
|
58
|
+
try:
|
|
59
|
+
await self.channel.send(text)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
logger.warning("Failed to forward terminal output: %s", exc)
|
|
62
|
+
await asyncio.sleep(0.5)
|
|
63
|
+
continue
|
|
64
|
+
finally:
|
|
65
|
+
if self.proc and self.proc.returncode is None:
|
|
66
|
+
pass # Keep alive across reconnects
|
|
67
|
+
|
|
68
|
+
self._reader_task = asyncio.create_task(_pump())
|
|
69
|
+
|
|
70
|
+
async def write(self, data: str) -> None:
|
|
71
|
+
if self.proc.stdin is None:
|
|
72
|
+
logger.warning("stdin pipe closed for terminal %s", self.id)
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
self.proc.stdin.write(data.encode())
|
|
76
|
+
await self.proc.stdin.drain()
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
79
|
+
|
|
80
|
+
async def stop(self) -> None:
|
|
81
|
+
if self.proc.returncode is None:
|
|
82
|
+
self.proc.terminate()
|
|
83
|
+
if self._reader_task:
|
|
84
|
+
await self._reader_task
|
|
85
|
+
await self.proc.wait()
|
|
86
|
+
|
|
87
|
+
def snapshot_buffer(self) -> str:
|
|
88
|
+
"""Return concatenated last buffer contents suitable for UI."""
|
|
89
|
+
return "".join(self._buffer)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WindowsTerminalSession(TerminalSession):
|
|
93
|
+
"""Terminal session backed by a Windows ConPTY."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, session_id: str, pty, channel: "Channel"):
|
|
96
|
+
# Create a proxy for the PTY process
|
|
97
|
+
class _WinPTYProxy:
|
|
98
|
+
def __init__(self, pty):
|
|
99
|
+
self._pty = pty
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def pid(self):
|
|
103
|
+
return self._pty.pid
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def returncode(self):
|
|
107
|
+
return None if self._pty.isalive() else self._pty.exitstatus
|
|
108
|
+
|
|
109
|
+
async def wait(self):
|
|
110
|
+
loop = asyncio.get_running_loop()
|
|
111
|
+
await loop.run_in_executor(None, self._pty.wait)
|
|
112
|
+
|
|
113
|
+
super().__init__(session_id, _WinPTYProxy(pty), channel)
|
|
114
|
+
self._pty = pty
|
|
115
|
+
|
|
116
|
+
async def start_io_forwarding(self) -> None:
|
|
117
|
+
"""Spawn background task that copies stdout/stderr to the channel."""
|
|
118
|
+
loop = asyncio.get_running_loop()
|
|
119
|
+
|
|
120
|
+
async def _pump() -> None:
|
|
121
|
+
try:
|
|
122
|
+
while True:
|
|
123
|
+
data = await loop.run_in_executor(None, self._pty.read, 1024)
|
|
124
|
+
if not data:
|
|
125
|
+
if not self._pty.isalive():
|
|
126
|
+
break
|
|
127
|
+
await asyncio.sleep(0.05)
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(data, bytes):
|
|
130
|
+
text = data.decode(errors="ignore")
|
|
131
|
+
else:
|
|
132
|
+
text = data
|
|
133
|
+
logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
|
|
134
|
+
self._buffer.append(text)
|
|
135
|
+
try:
|
|
136
|
+
await self.channel.send(text)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logger.warning("Failed to forward terminal output: %s", exc)
|
|
139
|
+
await asyncio.sleep(0.5)
|
|
140
|
+
continue
|
|
141
|
+
finally:
|
|
142
|
+
if self._pty and self._pty.isalive():
|
|
143
|
+
self._pty.kill()
|
|
144
|
+
|
|
145
|
+
self._reader_task = asyncio.create_task(_pump())
|
|
146
|
+
|
|
147
|
+
async def write(self, data: str) -> None:
|
|
148
|
+
loop = asyncio.get_running_loop()
|
|
149
|
+
try:
|
|
150
|
+
await loop.run_in_executor(None, self._pty.write, data)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
153
|
+
|
|
154
|
+
async def stop(self) -> None:
|
|
155
|
+
if self._pty.isalive():
|
|
156
|
+
self._pty.kill()
|
|
157
|
+
if self._reader_task:
|
|
158
|
+
await self._reader_task
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SessionManager:
|
|
162
|
+
"""Manages terminal sessions."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, mux):
|
|
165
|
+
self.mux = mux
|
|
166
|
+
self._sessions: Dict[str, TerminalSession] = {}
|
|
167
|
+
self._next_channel = 100
|
|
168
|
+
|
|
169
|
+
def _allocate_channel_id(self) -> int:
|
|
170
|
+
"""Allocate a new channel ID for a terminal session."""
|
|
171
|
+
cid = self._next_channel
|
|
172
|
+
self._next_channel += 1
|
|
173
|
+
return cid
|
|
174
|
+
|
|
175
|
+
async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
176
|
+
"""Create a new terminal session."""
|
|
177
|
+
term_id = uuid.uuid4().hex
|
|
178
|
+
channel_id = self._allocate_channel_id()
|
|
179
|
+
channel = self.mux.get_channel(channel_id)
|
|
180
|
+
|
|
181
|
+
# Choose shell
|
|
182
|
+
if shell is None:
|
|
183
|
+
shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
|
|
184
|
+
|
|
185
|
+
logger.info("Launching terminal %s using shell=%s on channel=%d", term_id, shell, channel_id)
|
|
186
|
+
|
|
187
|
+
if _IS_WINDOWS:
|
|
188
|
+
try:
|
|
189
|
+
from winpty import PtyProcess
|
|
190
|
+
except ImportError as exc:
|
|
191
|
+
logger.error("winpty (pywinpty) not found: %s", exc)
|
|
192
|
+
raise RuntimeError("pywinpty not installed on client")
|
|
193
|
+
|
|
194
|
+
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=_build_child_env())
|
|
195
|
+
session = WindowsTerminalSession(term_id, pty_proc, channel)
|
|
196
|
+
else:
|
|
197
|
+
# Unix: try real PTY for proper TTY semantics
|
|
198
|
+
try:
|
|
199
|
+
import pty
|
|
200
|
+
master_fd, slave_fd = pty.openpty()
|
|
201
|
+
proc = await asyncio.create_subprocess_exec(
|
|
202
|
+
shell,
|
|
203
|
+
stdin=slave_fd,
|
|
204
|
+
stdout=slave_fd,
|
|
205
|
+
stderr=slave_fd,
|
|
206
|
+
preexec_fn=os.setsid,
|
|
207
|
+
cwd=cwd,
|
|
208
|
+
env=_build_child_env(),
|
|
209
|
+
)
|
|
210
|
+
# Wrap master_fd into a StreamReader
|
|
211
|
+
loop = asyncio.get_running_loop()
|
|
212
|
+
reader = asyncio.StreamReader()
|
|
213
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
214
|
+
await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
|
|
215
|
+
proc.stdout = reader
|
|
216
|
+
# Use writer for stdin
|
|
217
|
+
writer_transport, writer_protocol = await loop.connect_write_pipe(
|
|
218
|
+
lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0)
|
|
219
|
+
)
|
|
220
|
+
proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop)
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.warning("Failed to allocate PTY, falling back to pipes")
|
|
223
|
+
proc = await asyncio.create_subprocess_exec(
|
|
224
|
+
shell,
|
|
225
|
+
stdin=asyncio.subprocess.PIPE,
|
|
226
|
+
stdout=asyncio.subprocess.PIPE,
|
|
227
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
228
|
+
cwd=cwd,
|
|
229
|
+
env=_build_child_env(),
|
|
230
|
+
)
|
|
231
|
+
session = TerminalSession(term_id, proc, channel)
|
|
232
|
+
|
|
233
|
+
self._sessions[term_id] = session
|
|
234
|
+
await session.start_io_forwarding()
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"terminal_id": term_id,
|
|
238
|
+
"channel": channel_id,
|
|
239
|
+
"pid": session.proc.pid,
|
|
240
|
+
"shell": shell,
|
|
241
|
+
"cwd": cwd,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
245
|
+
"""Get a terminal session by ID."""
|
|
246
|
+
return self._sessions.get(terminal_id)
|
|
247
|
+
|
|
248
|
+
def remove_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
249
|
+
"""Remove and return a terminal session."""
|
|
250
|
+
return self._sessions.pop(terminal_id, None)
|
|
251
|
+
|
|
252
|
+
def list_sessions(self) -> List[Dict[str, Any]]:
|
|
253
|
+
"""List all terminal sessions."""
|
|
254
|
+
return [
|
|
255
|
+
{
|
|
256
|
+
"terminal_id": s.id,
|
|
257
|
+
"channel": s.channel.id,
|
|
258
|
+
"pid": s.proc.pid,
|
|
259
|
+
"returncode": s.proc.returncode,
|
|
260
|
+
"buffer": s.snapshot_buffer(),
|
|
261
|
+
}
|
|
262
|
+
for s in self._sessions.values()
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
def reattach_sessions(self, mux):
|
|
266
|
+
"""Reattach sessions to a new multiplexer after reconnection."""
|
|
267
|
+
self.mux = mux
|
|
268
|
+
highest_cid = self._next_channel
|
|
269
|
+
|
|
270
|
+
for sess in self._sessions.values():
|
|
271
|
+
cid = sess.channel.id
|
|
272
|
+
sess.channel = self.mux.get_channel(cid)
|
|
273
|
+
highest_cid = max(highest_cid, cid + 1)
|
|
274
|
+
|
|
275
|
+
self._next_channel = max(self._next_channel, highest_cid)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""System command handlers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
from .base import SyncHandler
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SystemInfoHandler(SyncHandler):
|
|
15
|
+
"""Handler for getting system information."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def command_name(self) -> str:
|
|
19
|
+
return "system_info"
|
|
20
|
+
|
|
21
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
22
|
+
"""Get system information."""
|
|
23
|
+
info = {
|
|
24
|
+
"cpu_percent": psutil.cpu_percent(interval=0.1),
|
|
25
|
+
"memory": psutil.virtual_memory()._asdict(),
|
|
26
|
+
"disk": psutil.disk_usage(str(Path.home()))._asdict(),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
"event": "system_info",
|
|
31
|
+
"info": info,
|
|
32
|
+
}
|