mcp-ssh-vps 0.4.1__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.
- mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
- mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
- mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
- mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
- mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
- mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
- sshmcp/__init__.py +3 -0
- sshmcp/cli.py +473 -0
- sshmcp/config.py +155 -0
- sshmcp/core/__init__.py +5 -0
- sshmcp/core/container.py +291 -0
- sshmcp/models/__init__.py +15 -0
- sshmcp/models/command.py +69 -0
- sshmcp/models/file.py +102 -0
- sshmcp/models/machine.py +139 -0
- sshmcp/monitoring/__init__.py +0 -0
- sshmcp/monitoring/alerts.py +464 -0
- sshmcp/prompts/__init__.py +7 -0
- sshmcp/prompts/backup.py +151 -0
- sshmcp/prompts/deploy.py +115 -0
- sshmcp/prompts/monitor.py +146 -0
- sshmcp/resources/__init__.py +7 -0
- sshmcp/resources/logs.py +99 -0
- sshmcp/resources/metrics.py +204 -0
- sshmcp/resources/status.py +160 -0
- sshmcp/security/__init__.py +7 -0
- sshmcp/security/audit.py +314 -0
- sshmcp/security/rate_limiter.py +221 -0
- sshmcp/security/totp.py +392 -0
- sshmcp/security/validator.py +234 -0
- sshmcp/security/whitelist.py +169 -0
- sshmcp/server.py +632 -0
- sshmcp/ssh/__init__.py +6 -0
- sshmcp/ssh/async_client.py +247 -0
- sshmcp/ssh/client.py +464 -0
- sshmcp/ssh/executor.py +79 -0
- sshmcp/ssh/forwarding.py +368 -0
- sshmcp/ssh/pool.py +343 -0
- sshmcp/ssh/shell.py +518 -0
- sshmcp/ssh/transfer.py +461 -0
- sshmcp/tools/__init__.py +13 -0
- sshmcp/tools/commands.py +226 -0
- sshmcp/tools/files.py +220 -0
- sshmcp/tools/helpers.py +321 -0
- sshmcp/tools/history.py +372 -0
- sshmcp/tools/processes.py +214 -0
- sshmcp/tools/servers.py +484 -0
sshmcp/ssh/shell.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Interactive SSH shell sessions."""
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import paramiko
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from sshmcp.ssh.client import SSHClient, SSHConnectionError
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ShellError(Exception):
|
|
19
|
+
"""Error with shell session."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShellNotConnected(ShellError):
|
|
25
|
+
"""Shell is not connected."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShellTimeout(ShellError):
|
|
31
|
+
"""Shell operation timed out."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ShellOutput:
|
|
38
|
+
"""Output from shell session."""
|
|
39
|
+
|
|
40
|
+
data: str
|
|
41
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
42
|
+
is_stderr: bool = False
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Convert to dictionary."""
|
|
46
|
+
return {
|
|
47
|
+
"data": self.data,
|
|
48
|
+
"timestamp": self.timestamp.isoformat(),
|
|
49
|
+
"is_stderr": self.is_stderr,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ShellSession:
|
|
55
|
+
"""Information about a shell session."""
|
|
56
|
+
|
|
57
|
+
session_id: str
|
|
58
|
+
host: str
|
|
59
|
+
started_at: datetime
|
|
60
|
+
terminal: str
|
|
61
|
+
width: int
|
|
62
|
+
height: int
|
|
63
|
+
is_active: bool = True
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
"""Convert to dictionary."""
|
|
67
|
+
return {
|
|
68
|
+
"session_id": self.session_id,
|
|
69
|
+
"host": self.host,
|
|
70
|
+
"started_at": self.started_at.isoformat(),
|
|
71
|
+
"terminal": self.terminal,
|
|
72
|
+
"width": self.width,
|
|
73
|
+
"height": self.height,
|
|
74
|
+
"is_active": self.is_active,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InteractiveShell:
|
|
79
|
+
"""Interactive SSH shell with PTY support."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
ssh_client: SSHClient,
|
|
84
|
+
term: str = "xterm",
|
|
85
|
+
width: int = 80,
|
|
86
|
+
height: int = 24,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Initialize interactive shell.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
ssh_client: Connected SSH client.
|
|
93
|
+
term: Terminal type.
|
|
94
|
+
width: Terminal width in characters.
|
|
95
|
+
height: Terminal height in characters.
|
|
96
|
+
"""
|
|
97
|
+
self.ssh_client = ssh_client
|
|
98
|
+
self.term = term
|
|
99
|
+
self.width = width
|
|
100
|
+
self.height = height
|
|
101
|
+
|
|
102
|
+
self._channel: paramiko.Channel | None = None
|
|
103
|
+
self._output_queue: queue.Queue[ShellOutput] = queue.Queue()
|
|
104
|
+
self._reader_thread: threading.Thread | None = None
|
|
105
|
+
self._running = False
|
|
106
|
+
self._session_id = f"{ssh_client.machine.name}_{int(time.time())}"
|
|
107
|
+
self._started_at: datetime | None = None
|
|
108
|
+
self._callbacks: list[Callable[[ShellOutput], None]] = []
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_active(self) -> bool:
|
|
113
|
+
"""Check if shell is active."""
|
|
114
|
+
if self._channel is None:
|
|
115
|
+
return False
|
|
116
|
+
return not self._channel.closed and self._running
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def session_info(self) -> ShellSession | None:
|
|
120
|
+
"""Get session information."""
|
|
121
|
+
if not self._started_at:
|
|
122
|
+
return None
|
|
123
|
+
return ShellSession(
|
|
124
|
+
session_id=self._session_id,
|
|
125
|
+
host=self.ssh_client.machine.name,
|
|
126
|
+
started_at=self._started_at,
|
|
127
|
+
terminal=self.term,
|
|
128
|
+
width=self.width,
|
|
129
|
+
height=self.height,
|
|
130
|
+
is_active=self.is_active,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def start(self) -> ShellSession:
|
|
134
|
+
"""
|
|
135
|
+
Start interactive shell session.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ShellSession with session details.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ShellError: If shell cannot be started.
|
|
142
|
+
"""
|
|
143
|
+
if self.is_active:
|
|
144
|
+
raise ShellError("Shell already active")
|
|
145
|
+
|
|
146
|
+
if not self.ssh_client.is_connected:
|
|
147
|
+
try:
|
|
148
|
+
self.ssh_client.connect()
|
|
149
|
+
except SSHConnectionError as e:
|
|
150
|
+
raise ShellError(f"Cannot connect: {e}")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
transport = self.ssh_client._client.get_transport()
|
|
154
|
+
if transport is None:
|
|
155
|
+
raise ShellError("No transport available")
|
|
156
|
+
|
|
157
|
+
self._channel = transport.open_session()
|
|
158
|
+
self._channel.get_pty(
|
|
159
|
+
term=self.term,
|
|
160
|
+
width=self.width,
|
|
161
|
+
height=self.height,
|
|
162
|
+
)
|
|
163
|
+
self._channel.invoke_shell()
|
|
164
|
+
self._channel.settimeout(0.1)
|
|
165
|
+
|
|
166
|
+
self._running = True
|
|
167
|
+
self._started_at = datetime.now(timezone.utc)
|
|
168
|
+
|
|
169
|
+
# Start reader thread
|
|
170
|
+
self._reader_thread = threading.Thread(
|
|
171
|
+
target=self._read_output,
|
|
172
|
+
daemon=True,
|
|
173
|
+
name=f"shell-reader-{self._session_id}",
|
|
174
|
+
)
|
|
175
|
+
self._reader_thread.start()
|
|
176
|
+
|
|
177
|
+
logger.info(
|
|
178
|
+
"shell_started",
|
|
179
|
+
host=self.ssh_client.machine.name,
|
|
180
|
+
session_id=self._session_id,
|
|
181
|
+
terminal=self.term,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return self.session_info # type: ignore
|
|
185
|
+
|
|
186
|
+
except paramiko.SSHException as e:
|
|
187
|
+
raise ShellError(f"Failed to start shell: {e}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
raise ShellError(f"Shell error: {e}")
|
|
190
|
+
|
|
191
|
+
def stop(self) -> None:
|
|
192
|
+
"""Stop shell session."""
|
|
193
|
+
self._running = False
|
|
194
|
+
|
|
195
|
+
if self._channel:
|
|
196
|
+
try:
|
|
197
|
+
self._channel.close()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
self._channel = None
|
|
201
|
+
|
|
202
|
+
if self._reader_thread and self._reader_thread.is_alive():
|
|
203
|
+
self._reader_thread.join(timeout=2.0)
|
|
204
|
+
|
|
205
|
+
logger.info(
|
|
206
|
+
"shell_stopped",
|
|
207
|
+
host=self.ssh_client.machine.name,
|
|
208
|
+
session_id=self._session_id,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def send(self, data: str) -> None:
|
|
212
|
+
"""
|
|
213
|
+
Send data to shell.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
data: Data to send.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ShellNotConnected: If shell is not active.
|
|
220
|
+
"""
|
|
221
|
+
if not self.is_active or self._channel is None:
|
|
222
|
+
raise ShellNotConnected("Shell not active")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
self._channel.send(data)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
raise ShellError(f"Failed to send: {e}")
|
|
228
|
+
|
|
229
|
+
def send_line(self, line: str) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Send line to shell (with newline).
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
line: Line to send.
|
|
235
|
+
"""
|
|
236
|
+
self.send(line + "\n")
|
|
237
|
+
|
|
238
|
+
def recv(self, timeout: float = 1.0) -> list[ShellOutput]:
|
|
239
|
+
"""
|
|
240
|
+
Receive available output from shell.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
timeout: Maximum time to wait for output.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of ShellOutput items.
|
|
247
|
+
"""
|
|
248
|
+
outputs: list[ShellOutput] = []
|
|
249
|
+
deadline = time.time() + timeout
|
|
250
|
+
|
|
251
|
+
while time.time() < deadline:
|
|
252
|
+
try:
|
|
253
|
+
output = self._output_queue.get(timeout=0.1)
|
|
254
|
+
outputs.append(output)
|
|
255
|
+
except queue.Empty:
|
|
256
|
+
if outputs:
|
|
257
|
+
break
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
return outputs
|
|
261
|
+
|
|
262
|
+
def recv_until(
|
|
263
|
+
self,
|
|
264
|
+
pattern: str,
|
|
265
|
+
timeout: float = 30.0,
|
|
266
|
+
include_pattern: bool = True,
|
|
267
|
+
) -> str:
|
|
268
|
+
"""
|
|
269
|
+
Receive output until pattern is found.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
pattern: Pattern to wait for.
|
|
273
|
+
timeout: Maximum time to wait.
|
|
274
|
+
include_pattern: Include pattern in result.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Collected output.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ShellTimeout: If pattern not found in time.
|
|
281
|
+
"""
|
|
282
|
+
collected = ""
|
|
283
|
+
deadline = time.time() + timeout
|
|
284
|
+
|
|
285
|
+
while time.time() < deadline:
|
|
286
|
+
outputs = self.recv(timeout=0.5)
|
|
287
|
+
for output in outputs:
|
|
288
|
+
collected += output.data
|
|
289
|
+
if pattern in collected:
|
|
290
|
+
if include_pattern:
|
|
291
|
+
idx = collected.find(pattern) + len(pattern)
|
|
292
|
+
else:
|
|
293
|
+
idx = collected.find(pattern)
|
|
294
|
+
return collected[:idx]
|
|
295
|
+
|
|
296
|
+
raise ShellTimeout(f"Pattern '{pattern}' not found in {timeout}s")
|
|
297
|
+
|
|
298
|
+
def execute_and_wait(
|
|
299
|
+
self,
|
|
300
|
+
command: str,
|
|
301
|
+
prompt: str = "$ ",
|
|
302
|
+
timeout: float = 30.0,
|
|
303
|
+
) -> str:
|
|
304
|
+
"""
|
|
305
|
+
Execute command and wait for prompt.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
command: Command to execute.
|
|
309
|
+
prompt: Shell prompt to wait for.
|
|
310
|
+
timeout: Maximum time to wait.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Command output.
|
|
314
|
+
"""
|
|
315
|
+
self.send_line(command)
|
|
316
|
+
output = self.recv_until(prompt, timeout=timeout)
|
|
317
|
+
# Remove command echo and prompt
|
|
318
|
+
lines = output.split("\n")
|
|
319
|
+
if lines and command in lines[0]:
|
|
320
|
+
lines = lines[1:]
|
|
321
|
+
if lines and prompt in lines[-1]:
|
|
322
|
+
lines[-1] = lines[-1].replace(prompt, "")
|
|
323
|
+
return "\n".join(lines).strip()
|
|
324
|
+
|
|
325
|
+
def resize(self, width: int, height: int) -> None:
|
|
326
|
+
"""
|
|
327
|
+
Resize terminal.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
width: New width in characters.
|
|
331
|
+
height: New height in characters.
|
|
332
|
+
"""
|
|
333
|
+
if not self.is_active or self._channel is None:
|
|
334
|
+
raise ShellNotConnected("Shell not active")
|
|
335
|
+
|
|
336
|
+
self.width = width
|
|
337
|
+
self.height = height
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
self._channel.resize_pty(width=width, height=height)
|
|
341
|
+
logger.info(
|
|
342
|
+
"shell_resized",
|
|
343
|
+
session_id=self._session_id,
|
|
344
|
+
width=width,
|
|
345
|
+
height=height,
|
|
346
|
+
)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
raise ShellError(f"Failed to resize: {e}")
|
|
349
|
+
|
|
350
|
+
def register_callback(self, callback: Callable[[ShellOutput], None]) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Register callback for output.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
callback: Function called with each output.
|
|
356
|
+
"""
|
|
357
|
+
with self._lock:
|
|
358
|
+
self._callbacks.append(callback)
|
|
359
|
+
|
|
360
|
+
def _read_output(self) -> None:
|
|
361
|
+
"""Background thread to read shell output."""
|
|
362
|
+
while self._running and self._channel:
|
|
363
|
+
try:
|
|
364
|
+
if self._channel.recv_ready():
|
|
365
|
+
data = self._channel.recv(4096).decode("utf-8", errors="replace")
|
|
366
|
+
if data:
|
|
367
|
+
output = ShellOutput(data=data, is_stderr=False)
|
|
368
|
+
self._output_queue.put(output)
|
|
369
|
+
self._notify_callbacks(output)
|
|
370
|
+
|
|
371
|
+
if self._channel.recv_stderr_ready():
|
|
372
|
+
data = self._channel.recv_stderr(4096).decode(
|
|
373
|
+
"utf-8", errors="replace"
|
|
374
|
+
)
|
|
375
|
+
if data:
|
|
376
|
+
output = ShellOutput(data=data, is_stderr=True)
|
|
377
|
+
self._output_queue.put(output)
|
|
378
|
+
self._notify_callbacks(output)
|
|
379
|
+
|
|
380
|
+
if self._channel.exit_status_ready():
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
time.sleep(0.01)
|
|
384
|
+
|
|
385
|
+
except Exception as e:
|
|
386
|
+
if self._running:
|
|
387
|
+
logger.warning(
|
|
388
|
+
"shell_read_error",
|
|
389
|
+
session_id=self._session_id,
|
|
390
|
+
error=str(e),
|
|
391
|
+
)
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
self._running = False
|
|
395
|
+
|
|
396
|
+
def _notify_callbacks(self, output: ShellOutput) -> None:
|
|
397
|
+
"""Notify registered callbacks."""
|
|
398
|
+
with self._lock:
|
|
399
|
+
for callback in self._callbacks:
|
|
400
|
+
try:
|
|
401
|
+
callback(output)
|
|
402
|
+
except Exception as e:
|
|
403
|
+
logger.warning(
|
|
404
|
+
"shell_callback_error",
|
|
405
|
+
session_id=self._session_id,
|
|
406
|
+
error=str(e),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def __enter__(self) -> "InteractiveShell":
|
|
410
|
+
"""Context manager entry."""
|
|
411
|
+
self.start()
|
|
412
|
+
return self
|
|
413
|
+
|
|
414
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
415
|
+
"""Context manager exit."""
|
|
416
|
+
self.stop()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class ShellManager:
|
|
420
|
+
"""Manage multiple shell sessions."""
|
|
421
|
+
|
|
422
|
+
def __init__(self) -> None:
|
|
423
|
+
"""Initialize shell manager."""
|
|
424
|
+
self._sessions: dict[str, InteractiveShell] = {}
|
|
425
|
+
self._lock = threading.Lock()
|
|
426
|
+
|
|
427
|
+
def create_session(
|
|
428
|
+
self,
|
|
429
|
+
ssh_client: SSHClient,
|
|
430
|
+
term: str = "xterm",
|
|
431
|
+
width: int = 80,
|
|
432
|
+
height: int = 24,
|
|
433
|
+
) -> ShellSession:
|
|
434
|
+
"""
|
|
435
|
+
Create new shell session.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
ssh_client: Connected SSH client.
|
|
439
|
+
term: Terminal type.
|
|
440
|
+
width: Terminal width.
|
|
441
|
+
height: Terminal height.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
ShellSession with session details.
|
|
445
|
+
"""
|
|
446
|
+
shell = InteractiveShell(
|
|
447
|
+
ssh_client=ssh_client,
|
|
448
|
+
term=term,
|
|
449
|
+
width=width,
|
|
450
|
+
height=height,
|
|
451
|
+
)
|
|
452
|
+
session = shell.start()
|
|
453
|
+
|
|
454
|
+
with self._lock:
|
|
455
|
+
self._sessions[session.session_id] = shell
|
|
456
|
+
|
|
457
|
+
return session
|
|
458
|
+
|
|
459
|
+
def get_session(self, session_id: str) -> InteractiveShell | None:
|
|
460
|
+
"""Get shell by session ID."""
|
|
461
|
+
with self._lock:
|
|
462
|
+
return self._sessions.get(session_id)
|
|
463
|
+
|
|
464
|
+
def list_sessions(self) -> list[ShellSession]:
|
|
465
|
+
"""List all active sessions."""
|
|
466
|
+
sessions = []
|
|
467
|
+
with self._lock:
|
|
468
|
+
for shell in self._sessions.values():
|
|
469
|
+
info = shell.session_info
|
|
470
|
+
if info:
|
|
471
|
+
sessions.append(info)
|
|
472
|
+
return sessions
|
|
473
|
+
|
|
474
|
+
def close_session(self, session_id: str) -> bool:
|
|
475
|
+
"""
|
|
476
|
+
Close shell session.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
session_id: Session ID to close.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
True if session was closed.
|
|
483
|
+
"""
|
|
484
|
+
with self._lock:
|
|
485
|
+
shell = self._sessions.pop(session_id, None)
|
|
486
|
+
|
|
487
|
+
if shell:
|
|
488
|
+
shell.stop()
|
|
489
|
+
return True
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
def close_all(self) -> int:
|
|
493
|
+
"""
|
|
494
|
+
Close all sessions.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Number of sessions closed.
|
|
498
|
+
"""
|
|
499
|
+
with self._lock:
|
|
500
|
+
sessions = list(self._sessions.values())
|
|
501
|
+
self._sessions.clear()
|
|
502
|
+
|
|
503
|
+
for shell in sessions:
|
|
504
|
+
shell.stop()
|
|
505
|
+
|
|
506
|
+
return len(sessions)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# Global shell manager
|
|
510
|
+
_shell_manager: ShellManager | None = None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def get_shell_manager() -> ShellManager:
|
|
514
|
+
"""Get global shell manager."""
|
|
515
|
+
global _shell_manager
|
|
516
|
+
if _shell_manager is None:
|
|
517
|
+
_shell_manager = ShellManager()
|
|
518
|
+
return _shell_manager
|