fid-mcp 0.1.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.

Potentially problematic release.


This version of fid-mcp might be problematic. Click here for more details.

fid_mcp/shell.py ADDED
@@ -0,0 +1,745 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Interactive Shell Function
4
+
5
+ Provides a generalized interactive shell interface using pexpect for
6
+ managing long-running shell sessions and executing commands with
7
+ real-time output handling.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import re
13
+ import time
14
+ from typing import Dict, Any, Optional, List, Union
15
+ import pexpect
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class InteractiveShell:
21
+ """Manages an interactive shell session using pexpect"""
22
+
23
+ def __init__(
24
+ self,
25
+ shell_cmd: str = "bash",
26
+ shell_args: List[str] = None,
27
+ cwd: str = None,
28
+ timeout: int = 300,
29
+ prompt_pattern: str = None,
30
+ custom_prompt: str = None,
31
+ encoding: str = "utf-8",
32
+ ):
33
+ """
34
+ Initialize an interactive shell session
35
+
36
+ Args:
37
+ shell_cmd: Command to start the shell (default: "bash")
38
+ shell_args: Arguments for the shell command
39
+ cwd: Working directory for the shell
40
+ timeout: Default timeout for operations in seconds
41
+ prompt_pattern: Regex pattern to match shell prompts
42
+ custom_prompt: Custom prompt to set (helps avoid ANSI sequences)
43
+ encoding: Text encoding for the shell
44
+ """
45
+ self.shell_cmd = shell_cmd
46
+ self.shell_args = shell_args or ["--norc", "--noprofile"]
47
+ self.cwd = cwd or os.getcwd()
48
+ self.timeout = timeout
49
+ self.prompt_pattern = prompt_pattern or [r"\$", r"#", r">", r"nsh>", r"px4>"]
50
+ self.custom_prompt = custom_prompt
51
+ self.encoding = encoding
52
+
53
+ self.process = None
54
+ self.is_active = False
55
+ self.created_at = time.time()
56
+ self.command_history = []
57
+
58
+ def start(self) -> Dict[str, Any]:
59
+ """Start the interactive shell session"""
60
+ try:
61
+ # Start the shell process
62
+ self.process = pexpect.spawn(
63
+ self.shell_cmd,
64
+ self.shell_args,
65
+ cwd=self.cwd,
66
+ timeout=self.timeout,
67
+ encoding=self.encoding,
68
+ )
69
+
70
+ # Wait for initial shell prompt
71
+ initial_timeout = 10
72
+ try:
73
+ self.process.expect(self.prompt_pattern, timeout=initial_timeout)
74
+ except pexpect.TIMEOUT:
75
+ output = self.process.before or ""
76
+ return {
77
+ "success": False,
78
+ "error": f"Timeout waiting for shell prompt after {initial_timeout}s",
79
+ "output": output,
80
+ }
81
+
82
+ # Set custom prompt if specified
83
+ if self.custom_prompt:
84
+ self._set_custom_prompt()
85
+
86
+ # Clear any remaining output
87
+ self._clear_buffer()
88
+
89
+ self.is_active = True
90
+ logger.info(f"Started interactive shell: {self.shell_cmd} in {self.cwd}")
91
+
92
+ return {
93
+ "success": True,
94
+ "shell_cmd": self.shell_cmd,
95
+ "cwd": self.cwd,
96
+ "message": "Interactive shell started successfully",
97
+ }
98
+
99
+ except Exception as e:
100
+ logger.error(f"Failed to start shell: {e}")
101
+ self.is_active = False
102
+ return {
103
+ "success": False,
104
+ "error": f"Shell startup failed: {str(e)}",
105
+ "shell_cmd": self.shell_cmd,
106
+ "shell_args": self.shell_args,
107
+ "cwd": self.cwd,
108
+ }
109
+
110
+ def execute_command(
111
+ self,
112
+ command: str,
113
+ wait: int = 0,
114
+ expect_patterns: List[str] = None,
115
+ capture_output: bool = True,
116
+ continue_on_error: bool = False,
117
+ ) -> Dict[str, Any]:
118
+ """
119
+ Execute a command in the interactive shell
120
+
121
+ Args:
122
+ command: Command to execute
123
+ wait: Wait time in seconds. If 0, complete as soon as prompt appears.
124
+ If > 0, always wait this long and return output (ignore prompts)
125
+ expect_patterns: Additional patterns to expect (beyond prompt)
126
+ capture_output: Whether to capture and return output
127
+ continue_on_error: Whether to continue if command fails
128
+
129
+ Returns:
130
+ Dictionary with execution results
131
+ """
132
+ if not self.is_active or not self.process:
133
+ return {
134
+ "success": False,
135
+ "error": "Shell session is not active",
136
+ "command": command,
137
+ }
138
+
139
+ command_start_time = time.time()
140
+
141
+ try:
142
+ # Clear buffer before sending command
143
+ self._clear_buffer()
144
+
145
+ # Send the command
146
+ logger.info(f"Executing command: {command}")
147
+ self.process.sendline(command)
148
+
149
+ # If wait > 0, use simple time-based approach
150
+ if wait > 0:
151
+ return self._execute_with_fixed_wait(
152
+ command, wait, capture_output, command_start_time
153
+ )
154
+
155
+ # Determine what patterns to expect
156
+ expect_patterns = expect_patterns or []
157
+ all_patterns = (
158
+ self.prompt_pattern + expect_patterns + [pexpect.TIMEOUT, pexpect.EOF]
159
+ )
160
+
161
+ # For wait=0, wait for prompt to appear
162
+ return self._execute_with_prompt_wait(
163
+ command, expect_patterns, capture_output, command_start_time
164
+ )
165
+
166
+ except Exception as e:
167
+ logger.error(f"Error executing command '{command}': {e}")
168
+ duration = time.time() - command_start_time
169
+ return {
170
+ "success": False,
171
+ "error": f"Command execution failed: {str(e)}",
172
+ "command": command,
173
+ "duration": duration,
174
+ "session_active": self.is_active,
175
+ "process_alive": self.process.isalive() if self.process else False,
176
+ }
177
+
178
+ def _execute_with_fixed_wait(
179
+ self, command: str, wait: int, capture_output: bool, command_start_time: float
180
+ ) -> Dict[str, Any]:
181
+ """Execute command and wait for fixed time, ignoring prompts"""
182
+ logger.info(f"Waiting {wait} seconds for command to complete")
183
+
184
+ all_output = ""
185
+
186
+ # Collect output for the specified wait time
187
+ end_time = command_start_time + wait
188
+ while time.time() < end_time:
189
+ try:
190
+ remaining = end_time - time.time()
191
+ chunk = self.process.read_nonblocking(
192
+ size=1000, timeout=min(remaining, 1)
193
+ )
194
+ if capture_output and chunk:
195
+ all_output += chunk
196
+ except pexpect.TIMEOUT:
197
+ continue # Keep waiting
198
+ except pexpect.EOF:
199
+ break
200
+
201
+ # Clean up output
202
+ clean_output = self._clean_output(all_output, command) if capture_output else ""
203
+ duration = time.time() - command_start_time
204
+
205
+ # Always consider fixed-wait commands successful
206
+ result = {
207
+ "success": True,
208
+ "command": command,
209
+ "output": clean_output,
210
+ "duration": duration,
211
+ "cwd": self.cwd,
212
+ }
213
+
214
+ self.command_history.append(result)
215
+ return result
216
+
217
+ def _execute_with_prompt_wait(
218
+ self,
219
+ command: str,
220
+ expect_patterns: List[str],
221
+ capture_output: bool,
222
+ command_start_time: float,
223
+ ) -> Dict[str, Any]:
224
+ """Execute command and wait for prompt to appear"""
225
+ all_patterns = (
226
+ self.prompt_pattern
227
+ + (expect_patterns or [])
228
+ + [pexpect.TIMEOUT, pexpect.EOF]
229
+ )
230
+ all_output = ""
231
+ command_completed = False
232
+
233
+ # Use a reasonable default timeout for prompt detection
234
+ max_wait = 60 # 60 seconds max
235
+
236
+ while not command_completed:
237
+ try:
238
+ elapsed = time.time() - command_start_time
239
+ if elapsed > max_wait:
240
+ logger.warning(f"Command '{command}' exceeded maximum wait time")
241
+ break
242
+
243
+ # Wait for patterns
244
+ index = self.process.expect(all_patterns, timeout=5)
245
+
246
+ # Collect output
247
+ if capture_output and self.process.before:
248
+ all_output += self.process.before
249
+
250
+ # Check what we matched
251
+ if index < len(self.prompt_pattern):
252
+ # Found a prompt - command is done
253
+ if self._is_real_prompt_match(
254
+ all_output, self.prompt_pattern[index]
255
+ ):
256
+ command_completed = True
257
+ logger.info(f"Command completed after {elapsed:.2f}s")
258
+
259
+ elif index < len(self.prompt_pattern) + len(expect_patterns or []):
260
+ # Matched a custom pattern - continue reading
261
+ continue
262
+
263
+ elif index >= len(all_patterns) - 2: # TIMEOUT or EOF
264
+ # Continue unless we've exceeded max wait
265
+ continue
266
+
267
+ except pexpect.TIMEOUT:
268
+ continue
269
+ except pexpect.EOF:
270
+ if self.process.before and capture_output:
271
+ all_output += self.process.before
272
+ break
273
+
274
+ # Clean up output and determine success
275
+ clean_output = self._clean_output(all_output, command) if capture_output else ""
276
+ success = self._check_command_success(clean_output, command, False)
277
+ duration = time.time() - command_start_time
278
+
279
+ # Store result
280
+ result = {
281
+ "success": success,
282
+ "command": command,
283
+ "output": clean_output,
284
+ "duration": duration,
285
+ "cwd": self.cwd,
286
+ }
287
+
288
+ # Add error details if command failed
289
+ if not success:
290
+ error_info = self._extract_error_details(clean_output, command)
291
+ result["error"] = error_info
292
+
293
+ self.command_history.append(result)
294
+ return result
295
+
296
+ def send_input(self, text: str) -> Dict[str, Any]:
297
+ """Send input to the interactive shell without expecting a prompt"""
298
+ if not self.is_active or not self.process:
299
+ return {"success": False, "error": "Shell session is not active"}
300
+
301
+ try:
302
+ self.process.send(text)
303
+ return {"success": True, "message": f"Sent input: {repr(text)}"}
304
+ except Exception as e:
305
+ return {"success": False, "error": str(e)}
306
+
307
+ def read_output(self, timeout: int = 5) -> Dict[str, Any]:
308
+ """Read available output from the shell"""
309
+ if not self.is_active or not self.process:
310
+ return {"success": False, "error": "Shell session is not active"}
311
+
312
+ try:
313
+ output = ""
314
+ try:
315
+ while True:
316
+ chunk = self.process.read_nonblocking(size=1000, timeout=timeout)
317
+ if not chunk:
318
+ break
319
+ output += chunk
320
+ timeout = 0.1 # Reduce timeout for subsequent reads
321
+ except pexpect.TIMEOUT:
322
+ pass # Normal - no more data available
323
+
324
+ return {"success": True, "output": output}
325
+
326
+ except Exception as e:
327
+ return {"success": False, "error": str(e)}
328
+
329
+ def close(self) -> Dict[str, Any]:
330
+ """Close the interactive shell session and kill any child processes"""
331
+ try:
332
+ if self.process and self.is_active:
333
+ # Send exit command to shell first
334
+ try:
335
+ self.process.sendline("exit")
336
+ self.process.expect(pexpect.EOF, timeout=5)
337
+ except (pexpect.TIMEOUT, pexpect.EOF):
338
+ pass # Shell may have already exited
339
+
340
+ # Terminate the process and its children
341
+ if self.process.isalive():
342
+ self.process.terminate()
343
+ try:
344
+ self.process.wait(timeout=5)
345
+ except pexpect.TIMEOUT:
346
+ # Force kill if it doesn't terminate gracefully
347
+ self.process.kill()
348
+
349
+ # Kill any remaining child processes (like gazebo)
350
+ self._kill_child_processes()
351
+
352
+ self.process.close()
353
+ self.is_active = False
354
+ logger.info(
355
+ "Closed interactive shell session and cleaned up child processes"
356
+ )
357
+ return {
358
+ "success": True,
359
+ "message": "Shell session closed and child processes terminated",
360
+ }
361
+ return {"success": True, "message": "Shell session was already closed"}
362
+ except Exception as e:
363
+ logger.error(f"Error closing shell session: {e}")
364
+ return {"success": False, "error": str(e)}
365
+
366
+ def _kill_child_processes(self):
367
+ """Kill child processes spawned by the shell"""
368
+ try:
369
+ import psutil
370
+ import signal
371
+
372
+ if not self.process or not hasattr(self.process, "pid"):
373
+ return
374
+
375
+ try:
376
+ parent = psutil.Process(self.process.pid)
377
+ children = parent.children(recursive=True)
378
+
379
+ # First try to terminate children gracefully
380
+ for child in children:
381
+ try:
382
+ child.terminate()
383
+ logger.info(
384
+ f"Terminated child process: {child.pid} ({child.name()})"
385
+ )
386
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
387
+ pass
388
+
389
+ # Wait a bit for graceful termination
390
+ import time
391
+
392
+ time.sleep(2)
393
+
394
+ # Force kill any remaining children
395
+ for child in children:
396
+ try:
397
+ if child.is_running():
398
+ child.kill()
399
+ logger.info(
400
+ f"Force killed child process: {child.pid} ({child.name()})"
401
+ )
402
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
403
+ pass
404
+
405
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
406
+ pass
407
+
408
+ except ImportError:
409
+ # psutil not available, try basic approach
410
+ logger.warning("psutil not available, using basic process cleanup")
411
+ try:
412
+ import os
413
+ import signal
414
+
415
+ if self.process and hasattr(self.process, "pid"):
416
+ # Try to kill the process group
417
+ os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
418
+ time.sleep(2)
419
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
420
+ except Exception as e:
421
+ logger.debug(f"Basic process cleanup failed: {e}")
422
+ except Exception as e:
423
+ logger.debug(f"Child process cleanup failed: {e}")
424
+
425
+ def _extract_error_details(self, output: str, command: str) -> str:
426
+ """Extract detailed error information from command output, focusing on the tail"""
427
+ if not output:
428
+ return f"Command '{command}' failed with no output"
429
+
430
+ lines = output.split("\n")
431
+ non_empty_lines = [line.strip() for line in lines if line.strip()]
432
+
433
+ if not non_empty_lines:
434
+ return f"Command '{command}' failed with no readable output"
435
+
436
+ # Always return the last 10 lines of output for context
437
+ # This is most useful for build errors, compilation failures, etc.
438
+ tail_lines = non_empty_lines[-10:]
439
+
440
+ # Also look for critical error patterns in the tail
441
+ error_patterns = [
442
+ "error:",
443
+ "failed",
444
+ "cannot",
445
+ "permission denied",
446
+ "no such file or directory",
447
+ "command not found",
448
+ "syntax error",
449
+ "make: ***",
450
+ "fatal:",
451
+ "abort:",
452
+ "exception:",
453
+ "compilation terminated",
454
+ "build failed",
455
+ ]
456
+
457
+ # Find error lines in the tail
458
+ error_lines = []
459
+ for line in tail_lines:
460
+ line_lower = line.lower()
461
+ if any(pattern in line_lower for pattern in error_patterns):
462
+ error_lines.append(line)
463
+
464
+ # If we found specific errors in tail, prioritize those
465
+ if error_lines:
466
+ return f"Errors found: {' | '.join(error_lines[:5])}"
467
+
468
+ # Otherwise return the complete tail for context
469
+ return f"Command failed - Last 10 lines: {' | '.join(tail_lines)}"
470
+
471
+ def _cleanup_on_error(self):
472
+ """Clean up processes when command fails or times out"""
473
+ logger.info("Cleaning up processes due to error/timeout")
474
+ try:
475
+ # Kill child processes but keep the shell session alive if possible
476
+ self._kill_child_processes()
477
+ except Exception as e:
478
+ logger.debug(f"Error during cleanup: {e}")
479
+
480
+ def get_status(self) -> Dict[str, Any]:
481
+ """Get current status of the shell session"""
482
+ return {
483
+ "is_active": self.is_active,
484
+ "shell_cmd": self.shell_cmd,
485
+ "cwd": self.cwd,
486
+ "created_at": self.created_at,
487
+ "commands_executed": len(self.command_history),
488
+ "process_alive": self.process.isalive() if self.process else False,
489
+ }
490
+
491
+ def _set_custom_prompt(self):
492
+ """Set a custom prompt to avoid ANSI escape sequences"""
493
+ try:
494
+ if self.custom_prompt:
495
+ self.process.sendline(f'export PS1="{self.custom_prompt}"')
496
+ # Update prompt pattern to match custom prompt
497
+ self.prompt_pattern = [re.escape(self.custom_prompt)]
498
+ # Wait for new prompt
499
+ self.process.expect(self.prompt_pattern, timeout=10)
500
+ # Clear any remaining output
501
+ self.process.sendline("")
502
+ self.process.expect(self.prompt_pattern, timeout=5)
503
+ logger.info(f"Set custom prompt: {self.custom_prompt}")
504
+ except Exception as e:
505
+ logger.warning(f"Failed to set custom prompt: {e}")
506
+
507
+ def _clear_buffer(self):
508
+ """Clear any accumulated output in the process buffer"""
509
+ if not self.process or not self.is_active:
510
+ return
511
+
512
+ try:
513
+ while True:
514
+ try:
515
+ discarded = self.process.read_nonblocking(size=1000, timeout=0.1)
516
+ if not discarded:
517
+ break
518
+ logger.debug(
519
+ f"Discarded buffer content: {repr(discarded[:100])}..."
520
+ )
521
+ except pexpect.TIMEOUT:
522
+ break
523
+ except pexpect.EOF:
524
+ break
525
+ except Exception as e:
526
+ logger.debug(f"Error clearing buffer: {e}")
527
+
528
+ def _clean_output(self, raw_output: str, command: str) -> str:
529
+ """Clean up raw pexpect output"""
530
+ if not raw_output:
531
+ return ""
532
+
533
+ # Remove ANSI escape sequences
534
+ cleaned = re.sub(r"\x1b\[[?]?[0-9;]*[a-zA-Z]", "", raw_output)
535
+
536
+ # Remove carriage returns
537
+ cleaned = cleaned.replace("\r", "")
538
+
539
+ # For commands that produce important status output, preserve more content
540
+ # This can be configured by caller through command patterns or other means
541
+
542
+ # For other commands, use the original cleaning logic
543
+ lines = cleaned.split("\n")
544
+ clean_lines = []
545
+
546
+ skip_command_echo = False
547
+
548
+ for line in lines:
549
+ # Skip the echoed command line
550
+ if command in line and not skip_command_echo:
551
+ skip_command_echo = True
552
+ continue
553
+ elif skip_command_echo:
554
+ clean_lines.append(line)
555
+
556
+ # If we didn't find the command echo, return all lines
557
+ if not skip_command_echo:
558
+ clean_lines = lines
559
+
560
+ # Remove empty lines at start and end
561
+ while clean_lines and not clean_lines[0].strip():
562
+ clean_lines.pop(0)
563
+ while clean_lines and not clean_lines[-1].strip():
564
+ clean_lines.pop()
565
+
566
+ return "\n".join(clean_lines)
567
+
568
+ def _is_real_prompt_match(self, output: str, prompt_pattern: str) -> bool:
569
+ """Check if a prompt match is real or just appears in log output"""
570
+ if not output:
571
+ return False
572
+
573
+ # Get the last few lines of output
574
+ lines = output.split("\n")
575
+ last_lines = lines[-3:] # Check last 3 lines
576
+
577
+ # Look for the prompt pattern at the start or end of recent lines
578
+ import re
579
+
580
+ for line in last_lines:
581
+ line = line.strip()
582
+ # Check if prompt appears at the end of line (most common)
583
+ if line.endswith(prompt_pattern.replace("\\", "")):
584
+ return True
585
+ # Check if prompt is the only thing on the line
586
+ if line == prompt_pattern.replace("\\", "") or re.match(
587
+ prompt_pattern, line
588
+ ):
589
+ return True
590
+
591
+ return False
592
+
593
+ def _check_command_success(
594
+ self, output: str, command: str, continue_on_error: bool = False
595
+ ) -> bool:
596
+ """Check if command was successful based on output"""
597
+ if continue_on_error:
598
+ return True
599
+
600
+ if not output:
601
+ return True # No output might be success for some commands
602
+
603
+ output_lower = output.lower()
604
+
605
+ # Common error indicators
606
+ error_indicators = [
607
+ "error:",
608
+ "failed",
609
+ "cannot",
610
+ "permission denied",
611
+ "no such file or directory",
612
+ "command not found",
613
+ "syntax error",
614
+ "make: *** [",
615
+ "compilation terminated",
616
+ "build failed",
617
+ "fatal error",
618
+ "abort",
619
+ "segmentation fault",
620
+ ]
621
+
622
+ for indicator in error_indicators:
623
+ if indicator in output_lower:
624
+ return False
625
+
626
+ return True
627
+
628
+
629
+ class ShellSessionManager:
630
+ """Manages multiple interactive shell sessions"""
631
+
632
+ def __init__(self):
633
+ self.sessions: Dict[str, InteractiveShell] = {}
634
+
635
+ def create_session(
636
+ self, session_id: str, shell_cmd: str = "bash", **kwargs
637
+ ) -> Dict[str, Any]:
638
+ """Create a new shell session"""
639
+ if session_id in self.sessions:
640
+ return {"success": False, "error": f"Session '{session_id}' already exists"}
641
+
642
+ try:
643
+ session = InteractiveShell(shell_cmd=shell_cmd, **kwargs)
644
+ result = session.start()
645
+
646
+ if result["success"]:
647
+ self.sessions[session_id] = session
648
+ result["session_id"] = session_id
649
+ else:
650
+ result["error"] = (
651
+ f"Failed to create session '{session_id}': {result.get('error', 'Unknown error')}"
652
+ )
653
+
654
+ return result
655
+ except Exception as e:
656
+ return {
657
+ "success": False,
658
+ "error": f"Exception creating session '{session_id}': {str(e)}",
659
+ "shell_cmd": shell_cmd,
660
+ "kwargs": kwargs,
661
+ }
662
+
663
+ def get_session(self, session_id: str) -> Optional[InteractiveShell]:
664
+ """Get an existing shell session"""
665
+ return self.sessions.get(session_id)
666
+
667
+ def execute_in_session(
668
+ self, session_id: str, command: str, **kwargs
669
+ ) -> Dict[str, Any]:
670
+ """Execute command in a specific session"""
671
+ session = self.get_session(session_id)
672
+ if not session:
673
+ return {
674
+ "success": False,
675
+ "error": f"Session '{session_id}' not found in active sessions: {list(self.sessions.keys())}",
676
+ }
677
+
678
+ try:
679
+ result = session.execute_command(command, **kwargs)
680
+ result["session_id"] = session_id
681
+ return result
682
+ except Exception as e:
683
+ return {
684
+ "success": False,
685
+ "error": f"Exception executing command in session '{session_id}': {str(e)}",
686
+ "command": command,
687
+ "session_id": session_id,
688
+ }
689
+
690
+ def close_session(self, session_id: str) -> Dict[str, Any]:
691
+ """Close and remove a session"""
692
+ session = self.sessions.pop(session_id, None)
693
+ if not session:
694
+ return {"success": False, "error": f"Session '{session_id}' not found"}
695
+
696
+ result = session.close()
697
+ result["session_id"] = session_id
698
+ return result
699
+
700
+ def list_sessions(self) -> Dict[str, Any]:
701
+ """List all active sessions"""
702
+ sessions_info = {}
703
+ for session_id, session in self.sessions.items():
704
+ sessions_info[session_id] = session.get_status()
705
+
706
+ return {"success": True, "sessions": sessions_info}
707
+
708
+ def cleanup_inactive_sessions(self) -> Dict[str, Any]:
709
+ """Remove inactive sessions"""
710
+ inactive_sessions = []
711
+ for session_id, session in list(self.sessions.items()):
712
+ if not session.is_active or (
713
+ session.process and not session.process.isalive()
714
+ ):
715
+ inactive_sessions.append(session_id)
716
+ session.close()
717
+ del self.sessions[session_id]
718
+
719
+ return {
720
+ "success": True,
721
+ "cleaned_sessions": inactive_sessions,
722
+ "message": f"Cleaned up {len(inactive_sessions)} inactive sessions",
723
+ }
724
+
725
+ def cleanup_all_sessions(self) -> Dict[str, Any]:
726
+ """Force close all sessions and clean up their processes"""
727
+ closed_sessions = []
728
+ for session_id, session in list(self.sessions.items()):
729
+ try:
730
+ session.close()
731
+ closed_sessions.append(session_id)
732
+ except Exception as e:
733
+ logger.error(f"Error closing session {session_id}: {e}")
734
+
735
+ self.sessions.clear()
736
+
737
+ return {
738
+ "success": True,
739
+ "closed_sessions": closed_sessions,
740
+ "message": f"Force closed {len(closed_sessions)} sessions",
741
+ }
742
+
743
+
744
+ # Global session manager
745
+ session_manager = ShellSessionManager()