mcp-interminal 0.2.0__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.
- .gitignore +6 -0
- CLAUDE.md +148 -0
- LICENSE +21 -0
- PKG-INFO +11 -0
- channel.py +242 -0
- command.py +105 -0
- mcp_interminal-0.2.0.dist-info/METADATA +11 -0
- mcp_interminal-0.2.0.dist-info/RECORD +16 -0
- mcp_interminal-0.2.0.dist-info/WHEEL +4 -0
- mcp_interminal-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_interminal-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server.py +234 -0
- proj.md +94 -0
- pyproject.toml +19 -0
- readme.md +66 -0
- session_manager.py +446 -0
.gitignore
ADDED
CLAUDE.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
**Interminal** is an MCP server that gives AI assistants terminal access — SSH and local shells — with support for interactive commands. It is distributed via `uvx` and has no build step.
|
|
8
|
+
|
|
9
|
+
## Running & Installation
|
|
10
|
+
|
|
11
|
+
```powershell
|
|
12
|
+
# Run directly (no install)
|
|
13
|
+
python mcp_server.py
|
|
14
|
+
|
|
15
|
+
# Install from source
|
|
16
|
+
pip install .
|
|
17
|
+
|
|
18
|
+
# Run via uvx (published package)
|
|
19
|
+
uvx interminal
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
There are no test, lint, or build commands configured in this project.
|
|
23
|
+
|
|
24
|
+
## Architecture
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
mcp_server.py ← FastMCP tool definitions (7 tools)
|
|
28
|
+
↓
|
|
29
|
+
session_manager.py ← SessionManager: SSH + local session lifecycle
|
|
30
|
+
↓
|
|
31
|
+
channel.py ← Channel abstraction (SSHChannel | LocalChannel | PtyChannel)
|
|
32
|
+
↓
|
|
33
|
+
command.py ← RunningCommand: background read loop, UTF-8 decoding
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Key design decisions
|
|
37
|
+
|
|
38
|
+
**Channel duck-typing** — `SSHChannel`, `LocalChannel`, and `PtyChannel` share an interface without ABC/abstractmethod. All three implement `read()`, `write()`, `close()`, `send_control()`, and `is_finished()`.
|
|
39
|
+
|
|
40
|
+
**Two-phase timeout** (`_wait_for_result` in `session_manager.py`) — Phase 1 waits up to `pause_timeout + 1s` for the first byte; if it times out we return immediately (already satisfies the "silent for pause_timeout" contract — going into Phase 2 would waste another full pause_timeout). Phase 2 only runs when bytes are actually arriving, collecting until a `pause_timeout` gap or `total_timeout` hits.
|
|
41
|
+
|
|
42
|
+
**PtyChannel platform split** — Windows uses `pywinpty.PtyProcess`; Unix uses `stdlib pty` with `fcntl` non-blocking I/O. `pywinpty` is an optional dependency.
|
|
43
|
+
|
|
44
|
+
**SSHChannel stdin reference** — `session_manager.py` explicitly retains a reference to `stdin` to prevent Python GC from calling `__del__` and closing the paramiko channel prematurely.
|
|
45
|
+
|
|
46
|
+
**Incremental UTF-8 decoding** — `command.py` uses `codecs.getincrementaldecoder` so multibyte characters split across reads are assembled correctly.
|
|
47
|
+
|
|
48
|
+
**pyte integration** (`_render_pyte` in `session_manager.py`) — optional ANSI escape sequence cleaning via a `pyte.Screen` subclass that works around a signature mismatch in the upstream library.
|
|
49
|
+
|
|
50
|
+
**`RunningCommand.close` ordering** — closes the channel *before* awaiting `read_task`. Windows PTY's `read()` runs inside `asyncio.to_thread(pty.read)`, which `task.cancel()` cannot actually interrupt — the future stays pending until the underlying thread exits, and the thread won't exit until the PTY is closed. Closing the channel first unblocks the read, then cancel + await tears the task down cleanly.
|
|
51
|
+
|
|
52
|
+
**`_read_loop` uses `try/finally`** — `CancelledError` is a `BaseException` subclass, not `Exception`, so a cancel on the read task bypasses the inner `except Exception`. The `finally` block guarantees `running=False` + `new_data_event.set()` always run, so any `_wait_for_result` waiter wakes immediately on cancel instead of stalling until its own `pause_timeout` fires.
|
|
53
|
+
|
|
54
|
+
### Session lifecycle
|
|
55
|
+
|
|
56
|
+
1. `create_local()` or `connect_ssh()` creates a `Session` dataclass entry in `SessionManager.sessions`.
|
|
57
|
+
2. `execute()` wraps the session's shell in a `Channel`, then a `RunningCommand` with a background `asyncio` read loop.
|
|
58
|
+
3. `respond()` / `send_control()` write directly to the running command's channel.
|
|
59
|
+
4. `disconnect()` closes the channel, terminates the subprocess/SSH client, and removes the session.
|
|
60
|
+
|
|
61
|
+
## Using TUI multiplexers (zellij, tmux) over interminal
|
|
62
|
+
|
|
63
|
+
Each `execute()` call creates an independent `exec_command` channel — there is no
|
|
64
|
+
persistent shell between calls.
|
|
65
|
+
|
|
66
|
+
### Why `zellij ... &` fails
|
|
67
|
+
|
|
68
|
+
Backgrounding a TUI multiplexer with `&` does **not** work, but not because of
|
|
69
|
+
SIGHUP. The failure is in zellij's init sequence:
|
|
70
|
+
|
|
71
|
+
1. Shell parses `zellij &`, forks zellij into a **background process group**, and
|
|
72
|
+
immediately exits (only command in the shell was the backgrounded one).
|
|
73
|
+
2. zellij's early init does TTY setup (`tcsetattr`, query terminal size, raw
|
|
74
|
+
mode). A background process group can't safely do this — it triggers
|
|
75
|
+
SIGTTOU/SIGTTIN, and the operations fail.
|
|
76
|
+
3. Meanwhile the shell has exited, so the PTY is also collapsing.
|
|
77
|
+
4. zellij aborts before it ever reaches the `fork()` + `setsid()` that would
|
|
78
|
+
daemonize the server. No server is ever created.
|
|
79
|
+
|
|
80
|
+
So the surface symptom "`list-sessions` shows nothing" is correct, but the cause
|
|
81
|
+
is "init never completed", not "SIGHUP killed a running server".
|
|
82
|
+
|
|
83
|
+
### The working pattern
|
|
84
|
+
|
|
85
|
+
Start zellij **in the foreground** (no `&`) and let `execute` return `partial`.
|
|
86
|
+
zellij's server has time to fork + setsid, becoming a daemon in its own session.
|
|
87
|
+
After that, the client/channel state is irrelevant — the server lives until
|
|
88
|
+
explicitly killed.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
# 1. Start the session. execute returns "partial" once init is done.
|
|
92
|
+
execute("TERM=xterm-256color ~/work/zellij --session train", total_timeout=4)
|
|
93
|
+
|
|
94
|
+
# 2. The partial channel can be ignored, disconnected, or left to time out.
|
|
95
|
+
# The daemonized server survives all of these.
|
|
96
|
+
|
|
97
|
+
# 3. Drive the session from independent execute calls:
|
|
98
|
+
execute("~/work/zellij --session train action new-pane -- bash start.sh")
|
|
99
|
+
execute("~/work/zellij --session train action dump-screen")
|
|
100
|
+
execute("~/work/zellij list-sessions")
|
|
101
|
+
execute("~/work/zellij delete-session train --force")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Running a command in a new tab's default pane
|
|
105
|
+
|
|
106
|
+
zellij's `action new-tab` does **not** accept `-- command` (unlike `new-pane`).
|
|
107
|
+
A naive "open a tab and train" therefore ends up as `new-tab` (creates an empty
|
|
108
|
+
default pane running the shell) + `new-pane -- bash start.sh` (a second pane
|
|
109
|
+
split into the tab), which leaves a leftover empty pane.
|
|
110
|
+
|
|
111
|
+
To run the command in the new tab's default pane instead, chain `write-chars`
|
|
112
|
+
+ `write 13` — `new-tab` focuses the new tab automatically, so the keystrokes
|
|
113
|
+
land in its default pane:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
execute("~/work/zellij --session train action new-tab --name v4")
|
|
117
|
+
execute("~/work/zellij --session train action write-chars 'bash start.sh'")
|
|
118
|
+
execute("~/work/zellij --session train action write 13") # 13 = Enter (CR)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
tmux is simpler — `new-window` takes the command directly:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
execute("tmux new-window -t main -n train 'bash start.sh'")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This is NOT the same as "puppeting the TUI": `write-chars` / `send-keys` are
|
|
128
|
+
the multiplexer's own structured CLI for injecting input. The thing to avoid
|
|
129
|
+
is using interminal's `respond` / `send_control` against the live TUI display
|
|
130
|
+
— that races against the renderer.
|
|
131
|
+
|
|
132
|
+
`TERM=xterm-256color` is required: zellij reads `TERM` at startup to pick its
|
|
133
|
+
renderer, and the default inherited from paramiko's PTY is often missing or
|
|
134
|
+
minimal. The same prefix is useful for other TUIs.
|
|
135
|
+
|
|
136
|
+
tmux works similarly but supports detached startup directly:
|
|
137
|
+
`tmux new-session -d -s train "bash start.sh"` returns immediately and leaves
|
|
138
|
+
a usable session — no partial-channel dance needed, because tmux's `-d` flag
|
|
139
|
+
explicitly forks the server before any TTY operations.
|
|
140
|
+
|
|
141
|
+
## Dependencies
|
|
142
|
+
|
|
143
|
+
- `mcp[cli]` — FastMCP framework
|
|
144
|
+
- `paramiko` — SSH client
|
|
145
|
+
- `pywinpty` (optional) — Windows PTY support
|
|
146
|
+
- `pyte` (optional) — ANSI escape sequence rendering
|
|
147
|
+
|
|
148
|
+
Python ≥ 3.11 required.
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 QiuwenZheng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-interminal
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: mcp[cli]
|
|
7
|
+
Requires-Dist: paramiko
|
|
8
|
+
Provides-Extra: ansi
|
|
9
|
+
Requires-Dist: pyte; extra == 'ansi'
|
|
10
|
+
Provides-Extra: pty
|
|
11
|
+
Requires-Dist: pywinpty; extra == 'pty'
|
channel.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import signal as _signal
|
|
5
|
+
|
|
6
|
+
PTY_AVAILABLE = False
|
|
7
|
+
if sys.platform == "win32":
|
|
8
|
+
try:
|
|
9
|
+
from pywinpty import PtyProcess # type: ignore
|
|
10
|
+
PTY_AVAILABLE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
else:
|
|
14
|
+
import pty as pty_mod
|
|
15
|
+
import fcntl
|
|
16
|
+
PTY_AVAILABLE = True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if sys.platform == "win32":
|
|
20
|
+
# CREATE_NEW_PROCESS_GROUP disables CTRL_C_EVENT for the child group;
|
|
21
|
+
# CTRL_BREAK_EVENT is the only interrupt that works with that flag.
|
|
22
|
+
# Windows has no SIGTSTP, so Ctrl+Z is intentionally left unmapped.
|
|
23
|
+
_LOCAL_SIG_MAP = {b'\x03': _signal.CTRL_BREAK_EVENT}
|
|
24
|
+
else:
|
|
25
|
+
_LOCAL_SIG_MAP = {b'\x03': _signal.SIGINT}
|
|
26
|
+
if hasattr(_signal, 'SIGTSTP'):
|
|
27
|
+
_LOCAL_SIG_MAP[b'\x1a'] = _signal.SIGTSTP
|
|
28
|
+
if hasattr(_signal, 'SIGQUIT'):
|
|
29
|
+
_LOCAL_SIG_MAP[b'\x1c'] = _signal.SIGQUIT
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Channel:
|
|
33
|
+
"""I/O interface for a running command's data stream."""
|
|
34
|
+
|
|
35
|
+
async def read(self) -> bytes | None:
|
|
36
|
+
"""Return data, None (no data yet), or b"" (EOF)."""
|
|
37
|
+
|
|
38
|
+
async def write(self, data: bytes) -> None:
|
|
39
|
+
"""Send data to the command's stdin."""
|
|
40
|
+
|
|
41
|
+
async def is_finished(self) -> bool:
|
|
42
|
+
"""True when the remote process has exited."""
|
|
43
|
+
|
|
44
|
+
async def get_exit_code(self) -> int | None:
|
|
45
|
+
"""Return the exit code (only valid after is_finished)."""
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None:
|
|
48
|
+
"""Release underlying resources."""
|
|
49
|
+
|
|
50
|
+
async def send_signal(self, sig: bytes) -> None:
|
|
51
|
+
"""Send a control byte (e.g., b'\\x03' for Ctrl+C)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# SSH implementation (paramiko)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
class SSHChannel(Channel):
|
|
59
|
+
"""Wraps a paramiko.Channel into the Channel interface."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, paramiko_channel, stdin=None):
|
|
62
|
+
self._ch = paramiko_channel
|
|
63
|
+
# Keep a reference to the stdin ChannelFile so Python GC does NOT call
|
|
64
|
+
# its __del__ / shutdown_write() early, which would set eof_sent=True
|
|
65
|
+
# on the underlying channel and prevent any future writes.
|
|
66
|
+
self._stdin = stdin
|
|
67
|
+
|
|
68
|
+
async def read(self) -> bytes | None:
|
|
69
|
+
if self._ch.recv_ready():
|
|
70
|
+
return self._ch.recv(4096)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async def write(self, data: bytes) -> None:
|
|
74
|
+
await asyncio.to_thread(self._ch.sendall, data)
|
|
75
|
+
|
|
76
|
+
async def is_finished(self) -> bool:
|
|
77
|
+
return self._ch.exit_status_ready()
|
|
78
|
+
|
|
79
|
+
async def get_exit_code(self) -> int | None:
|
|
80
|
+
# recv_exit_status() is a blocking socket read. Normally is_finished()
|
|
81
|
+
# has already returned True (exit_status_ready), so it returns at
|
|
82
|
+
# once — but EOF can arrive a beat before the exit-status message,
|
|
83
|
+
# and a sync call there would stall the whole event loop.
|
|
84
|
+
return await asyncio.to_thread(self._ch.recv_exit_status)
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
self._ch.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
async def send_signal(self, sig: bytes) -> None:
|
|
93
|
+
await self.write(sig)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Local subprocess implementation
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
class LocalChannel(Channel):
|
|
101
|
+
"""Wraps an asyncio.subprocess.Process into the Channel interface."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, process: asyncio.subprocess.Process):
|
|
104
|
+
self._proc = process
|
|
105
|
+
|
|
106
|
+
async def read(self) -> bytes | None:
|
|
107
|
+
try:
|
|
108
|
+
data = await asyncio.wait_for(
|
|
109
|
+
self._proc.stdout.read(4096), timeout=0.05
|
|
110
|
+
)
|
|
111
|
+
# read() returns b"" on EOF
|
|
112
|
+
return data
|
|
113
|
+
except asyncio.TimeoutError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
async def write(self, data: bytes) -> None:
|
|
117
|
+
self._proc.stdin.write(data)
|
|
118
|
+
await self._proc.stdin.drain()
|
|
119
|
+
|
|
120
|
+
async def is_finished(self) -> bool:
|
|
121
|
+
return self._proc.returncode is not None
|
|
122
|
+
|
|
123
|
+
async def get_exit_code(self) -> int | None:
|
|
124
|
+
return self._proc.returncode
|
|
125
|
+
|
|
126
|
+
async def close(self) -> None:
|
|
127
|
+
if self._proc.returncode is None:
|
|
128
|
+
self._proc.terminate()
|
|
129
|
+
try:
|
|
130
|
+
await asyncio.wait_for(self._proc.wait(), timeout=2.0)
|
|
131
|
+
except asyncio.TimeoutError:
|
|
132
|
+
self._proc.kill()
|
|
133
|
+
await self._proc.wait()
|
|
134
|
+
|
|
135
|
+
async def send_signal(self, sig: bytes) -> None:
|
|
136
|
+
os_sig = _LOCAL_SIG_MAP.get(sig)
|
|
137
|
+
if os_sig:
|
|
138
|
+
self._proc.send_signal(os_sig)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# PTY implementation (pywinpty on Windows, stdlib pty on Linux)
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
if PTY_AVAILABLE:
|
|
146
|
+
import subprocess
|
|
147
|
+
|
|
148
|
+
class PtyChannel(Channel):
|
|
149
|
+
"""PTY-backed channel. Windows: pywinpty. Linux: stdlib pty."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, command: str, shell: str):
|
|
152
|
+
self._finished = False
|
|
153
|
+
self._exit_code = None
|
|
154
|
+
|
|
155
|
+
if sys.platform == "win32":
|
|
156
|
+
self._pty = PtyProcess.spawn(f'{shell} /c {command}')
|
|
157
|
+
self._fd = None
|
|
158
|
+
self._proc = None
|
|
159
|
+
else:
|
|
160
|
+
master, slave = pty_mod.openpty()
|
|
161
|
+
try:
|
|
162
|
+
self._proc = subprocess.Popen(
|
|
163
|
+
[shell, "-c", command],
|
|
164
|
+
stdin=slave, stdout=slave, stderr=slave,
|
|
165
|
+
preexec_fn=os.setsid,
|
|
166
|
+
)
|
|
167
|
+
except Exception:
|
|
168
|
+
# Popen failed (bad shell path, ENOMEM, EPERM, ...).
|
|
169
|
+
# Without this both PTY fds would leak.
|
|
170
|
+
os.close(master)
|
|
171
|
+
os.close(slave)
|
|
172
|
+
raise
|
|
173
|
+
os.close(slave)
|
|
174
|
+
self._fd = master
|
|
175
|
+
flags = fcntl.fcntl(self._fd, fcntl.F_GETFL)
|
|
176
|
+
fcntl.fcntl(self._fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
177
|
+
self._pty = None
|
|
178
|
+
|
|
179
|
+
async def read(self) -> bytes | None:
|
|
180
|
+
if sys.platform == "win32":
|
|
181
|
+
try:
|
|
182
|
+
data = await asyncio.to_thread(self._pty.read, 4096)
|
|
183
|
+
return data.encode() if isinstance(data, str) else data
|
|
184
|
+
except EOFError:
|
|
185
|
+
self._finished = True
|
|
186
|
+
return b""
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
return os.read(self._fd, 4096)
|
|
190
|
+
except BlockingIOError:
|
|
191
|
+
return None
|
|
192
|
+
except OSError:
|
|
193
|
+
return b""
|
|
194
|
+
|
|
195
|
+
async def write(self, data: bytes) -> None:
|
|
196
|
+
if sys.platform == "win32":
|
|
197
|
+
# bytes.decode() defaults to utf-8 regardless of locale, so
|
|
198
|
+
# the normal path is fine. errors='replace' is defensive:
|
|
199
|
+
# if upstream ever hands us non-utf-8 bytes, we'd rather
|
|
200
|
+
# write a replacement char than crash the read loop.
|
|
201
|
+
self._pty.write(data.decode('utf-8', errors='replace'))
|
|
202
|
+
else:
|
|
203
|
+
os.write(self._fd, data)
|
|
204
|
+
|
|
205
|
+
async def is_finished(self) -> bool:
|
|
206
|
+
if self._finished:
|
|
207
|
+
return True
|
|
208
|
+
if sys.platform == "win32":
|
|
209
|
+
if not self._pty.isalive():
|
|
210
|
+
self._exit_code = self._pty.exitstatus
|
|
211
|
+
self._finished = True
|
|
212
|
+
else:
|
|
213
|
+
ret = self._proc.poll()
|
|
214
|
+
if ret is not None:
|
|
215
|
+
self._exit_code = ret
|
|
216
|
+
self._finished = True
|
|
217
|
+
return self._finished
|
|
218
|
+
|
|
219
|
+
async def get_exit_code(self) -> int | None:
|
|
220
|
+
return self._exit_code
|
|
221
|
+
|
|
222
|
+
async def close(self) -> None:
|
|
223
|
+
if sys.platform == "win32":
|
|
224
|
+
if self._pty.isalive():
|
|
225
|
+
self._pty.close()
|
|
226
|
+
else:
|
|
227
|
+
try:
|
|
228
|
+
os.close(self._fd)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
if not self._finished:
|
|
232
|
+
self._proc.terminate()
|
|
233
|
+
try:
|
|
234
|
+
await asyncio.to_thread(self._proc.wait, 2.0)
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
self._proc.kill()
|
|
237
|
+
await asyncio.to_thread(self._proc.wait)
|
|
238
|
+
|
|
239
|
+
async def send_signal(self, sig: bytes) -> None:
|
|
240
|
+
await self.write(sig)
|
|
241
|
+
if sys.platform == "win32" and sig == b'\x03':
|
|
242
|
+
self._finished = True
|
command.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import codecs
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from channel import Channel
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("interminal.command")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RunningCommand:
|
|
11
|
+
"""Manages one running command with background output buffering."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, channel: Channel, session_id: str):
|
|
14
|
+
self.channel = channel
|
|
15
|
+
self.session_id = session_id
|
|
16
|
+
|
|
17
|
+
self.buffer = ""
|
|
18
|
+
self.running = True
|
|
19
|
+
self.exit_code = None
|
|
20
|
+
self.new_data_event = asyncio.Event()
|
|
21
|
+
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
|
22
|
+
# Wall-clock of the last byte arrival. Lets _wait_for_result compute
|
|
23
|
+
# "already silent for X seconds" across calls so a poll on a quiet
|
|
24
|
+
# stream returns without re-waiting the full pause_timeout.
|
|
25
|
+
# Initialized to now so the first Phase 2 entry without prior data
|
|
26
|
+
# doesn't short-circuit.
|
|
27
|
+
self.last_data_time = asyncio.get_running_loop().time()
|
|
28
|
+
self.read_task = asyncio.create_task(self._read_loop())
|
|
29
|
+
|
|
30
|
+
async def _read_loop(self):
|
|
31
|
+
# The finally block matters: CancelledError isn't an Exception subclass
|
|
32
|
+
# (it's BaseException), so a cancel on this task skips the except below.
|
|
33
|
+
# Without finally, running=False + event.set() would never run on
|
|
34
|
+
# cancel, leaving any waiter on new_data_event stuck until its own
|
|
35
|
+
# pause_timeout fires.
|
|
36
|
+
try:
|
|
37
|
+
while self.running:
|
|
38
|
+
try:
|
|
39
|
+
raw = await self.channel.read()
|
|
40
|
+
has_data = False
|
|
41
|
+
|
|
42
|
+
if raw is not None and raw != b"":
|
|
43
|
+
self.buffer += self.decoder.decode(raw)
|
|
44
|
+
has_data = True
|
|
45
|
+
self.last_data_time = asyncio.get_running_loop().time()
|
|
46
|
+
self.new_data_event.set()
|
|
47
|
+
|
|
48
|
+
is_eof = (raw == b"")
|
|
49
|
+
|
|
50
|
+
if is_eof or await self.channel.is_finished():
|
|
51
|
+
self.exit_code = await self.channel.get_exit_code()
|
|
52
|
+
# 排空 read() 和 is_finished() 之间可能积累的数据(SSH 必需)
|
|
53
|
+
while True:
|
|
54
|
+
remaining = await self.channel.read()
|
|
55
|
+
if remaining is None or remaining == b"":
|
|
56
|
+
break
|
|
57
|
+
self.buffer += self.decoder.decode(remaining)
|
|
58
|
+
tail = self.decoder.decode(b'', final=True)
|
|
59
|
+
if tail:
|
|
60
|
+
self.buffer += tail
|
|
61
|
+
break
|
|
62
|
+
elif not has_data:
|
|
63
|
+
await asyncio.sleep(0.05)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.warning("_read_loop error: %s", e)
|
|
66
|
+
# Best-effort exit code so the caller doesn't see
|
|
67
|
+
# status="completed" + exit_code=None for an abnormal exit.
|
|
68
|
+
try:
|
|
69
|
+
self.exit_code = await self.channel.get_exit_code()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
break
|
|
73
|
+
finally:
|
|
74
|
+
self.running = False
|
|
75
|
+
self.new_data_event.set()
|
|
76
|
+
|
|
77
|
+
def read_output(self) -> str:
|
|
78
|
+
data = self.buffer
|
|
79
|
+
self.buffer = ""
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
async def write_input(self, text: str) -> None:
|
|
83
|
+
await self.channel.write(text.encode('utf-8'))
|
|
84
|
+
|
|
85
|
+
def check_status(self) -> tuple[bool, int | None]:
|
|
86
|
+
return not self.running, self.exit_code
|
|
87
|
+
|
|
88
|
+
async def close(self):
|
|
89
|
+
self.running = False
|
|
90
|
+
# Wake any waiter on new_data_event immediately — the read_task's
|
|
91
|
+
# finally will set it too, but doing it here means waiters see
|
|
92
|
+
# running=False without having to wait for the task to actually
|
|
93
|
+
# tear down.
|
|
94
|
+
self.new_data_event.set()
|
|
95
|
+
# Close the channel first so any blocking read (notably Windows PTY,
|
|
96
|
+
# which sits in asyncio.to_thread(pty.read) and can't be cancelled)
|
|
97
|
+
# unblocks. Only then await the read task — otherwise the to_thread
|
|
98
|
+
# future will hold us until the thread itself exits, which it can't
|
|
99
|
+
# do while the PTY is still open.
|
|
100
|
+
await self.channel.close()
|
|
101
|
+
self.read_task.cancel()
|
|
102
|
+
try:
|
|
103
|
+
await self.read_task
|
|
104
|
+
except (asyncio.CancelledError, Exception):
|
|
105
|
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-interminal
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
License-File: LICENSE
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: mcp[cli]
|
|
7
|
+
Requires-Dist: paramiko
|
|
8
|
+
Provides-Extra: ansi
|
|
9
|
+
Requires-Dist: pyte; extra == 'ansi'
|
|
10
|
+
Provides-Extra: pty
|
|
11
|
+
Requires-Dist: pywinpty; extra == 'pty'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
./.gitignore,sha256=DlBhwRJ9WUXGdF00q9fnNQ3riS7TGtKhHktfMkG5bnM,51
|
|
2
|
+
./CLAUDE.md,sha256=NlnfS8rDjWdtRH38pxloQihprKiu6WEjSaRbjbkw9h8,7246
|
|
3
|
+
./LICENSE,sha256=6aL4Mou5r2mk4DFSCYQt2uV-TiLl7xevwzC1QV1FGRI,1068
|
|
4
|
+
./PKG-INFO,sha256=8VjJ_F3g9rZY-USoTRncGyYKnI2l4x9RuuBV8FXcf18,270
|
|
5
|
+
./channel.py,sha256=pLdi8oQCFe3M_uez-l4yu80432BQkTIdwVXWlB8iEpM,8794
|
|
6
|
+
./command.py,sha256=eE-DJ1s1ZVF-IbAqlPp0G_AhOmXFcBJ81DByDff2nQ8,4484
|
|
7
|
+
./mcp_server.py,sha256=B8g0SPABws3SwPLmwkJv_ICW5-jf0NAfWobWoREfYOM,8886
|
|
8
|
+
./proj.md,sha256=CoaYtAH7W5qXJTAJG62A90_ty8ejYYpJyJKp5eDPMaE,6017
|
|
9
|
+
./pyproject.toml,sha256=SIFVscOhniOZlU2sUUFY1Gp4g5uzzWtrTDfF_u-ZjsQ,363
|
|
10
|
+
./readme.md,sha256=IDbGA6rmmXuhL6ksCNSd_y67ymksNxd9upw8eAbfrDU,1910
|
|
11
|
+
./session_manager.py,sha256=7j4Jr5qG5huj49Qe_Gh4iZaVx19g-r9KwBhTtBoXJbs,17304
|
|
12
|
+
mcp_interminal-0.2.0.dist-info/METADATA,sha256=8VjJ_F3g9rZY-USoTRncGyYKnI2l4x9RuuBV8FXcf18,270
|
|
13
|
+
mcp_interminal-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
mcp_interminal-0.2.0.dist-info/entry_points.txt,sha256=987RO99TvRjdj00Fdp9jGrQp3B6eLyzZgkwFuijdLYw,47
|
|
15
|
+
mcp_interminal-0.2.0.dist-info/licenses/LICENSE,sha256=6aL4Mou5r2mk4DFSCYQt2uV-TiLl7xevwzC1QV1FGRI,1068
|
|
16
|
+
mcp_interminal-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 QiuwenZheng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|