fid-mcp 0.1.5__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.
fid_mcp/shell.py ADDED
@@ -0,0 +1,883 @@
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: Optional[List[str]] = None,
115
+ responses: Optional[List[str]] = None,
116
+ capture_output: bool = True,
117
+ continue_on_error: bool = False,
118
+ ) -> Dict[str, Any]:
119
+ """
120
+ Execute a command in the interactive shell
121
+
122
+ Args:
123
+ command: Command to execute
124
+ wait: Wait time in seconds. If 0, complete as soon as prompt appears.
125
+ If > 0, always wait this long and return output (ignore prompts)
126
+ expect_patterns: Additional patterns to expect (beyond prompt)
127
+ responses: Responses to send when patterns are matched (must match length of expect_patterns if provided)
128
+ capture_output: Whether to capture and return output
129
+ continue_on_error: Whether to continue if command fails
130
+
131
+ Returns:
132
+ Dictionary with execution results
133
+ """
134
+ if not self.is_active or not self.process:
135
+ return {
136
+ "success": False,
137
+ "error": "Shell session is not active",
138
+ "command": command,
139
+ }
140
+
141
+ command_start_time = time.time()
142
+
143
+ try:
144
+ # Validate responses match expect_patterns if both provided
145
+ if responses is not None and expect_patterns is not None:
146
+ if len(responses) != len(expect_patterns):
147
+ return {
148
+ "success": False,
149
+ "error": f"Number of responses ({len(responses)}) must match number of expect_patterns ({len(expect_patterns)})",
150
+ "command": command,
151
+ }
152
+
153
+ # Clear buffer before sending command
154
+ self._clear_buffer()
155
+
156
+ # Send the command
157
+ logger.info(f"Executing command: {command}")
158
+ self.process.sendline(command)
159
+
160
+ # If we have expect patterns with responses, use interactive response handling
161
+ if expect_patterns and responses:
162
+ return self._execute_with_interactive_responses(
163
+ command, expect_patterns, responses, capture_output, command_start_time
164
+ )
165
+
166
+ # If wait > 0, use simple time-based approach
167
+ if wait > 0:
168
+ return self._execute_with_fixed_wait(
169
+ command, wait, capture_output, command_start_time
170
+ )
171
+
172
+ # Determine what patterns to expect
173
+ expect_patterns = expect_patterns or []
174
+ all_patterns = (
175
+ self.prompt_pattern + expect_patterns + [pexpect.TIMEOUT, pexpect.EOF]
176
+ )
177
+
178
+ # For wait=0, wait for prompt to appear
179
+ return self._execute_with_prompt_wait(
180
+ command, expect_patterns, capture_output, command_start_time
181
+ )
182
+
183
+ except Exception as e:
184
+ logger.error(f"Error executing command '{command}': {e}")
185
+ duration = time.time() - command_start_time
186
+ return {
187
+ "success": False,
188
+ "error": f"Command execution failed: {str(e)}",
189
+ "command": command,
190
+ "duration": duration,
191
+ "session_active": self.is_active,
192
+ "process_alive": self.process.isalive() if self.process else False,
193
+ }
194
+
195
+ def _execute_with_fixed_wait(
196
+ self, command: str, wait: int, capture_output: bool, command_start_time: float
197
+ ) -> Dict[str, Any]:
198
+ """Execute command and wait for fixed time, ignoring prompts"""
199
+ logger.info(f"Waiting {wait} seconds for command to complete")
200
+
201
+ all_output = ""
202
+
203
+ # Collect output for the specified wait time
204
+ end_time = command_start_time + wait
205
+ while time.time() < end_time:
206
+ try:
207
+ remaining = end_time - time.time()
208
+ chunk = self.process.read_nonblocking(
209
+ size=1000, timeout=min(remaining, 1)
210
+ )
211
+ if capture_output and chunk:
212
+ all_output += chunk
213
+ except pexpect.TIMEOUT:
214
+ continue # Keep waiting
215
+ except pexpect.EOF:
216
+ break
217
+
218
+ # Clean up output
219
+ clean_output = self._clean_output(all_output, command) if capture_output else ""
220
+ duration = time.time() - command_start_time
221
+
222
+ # Always consider fixed-wait commands successful
223
+ result = {
224
+ "success": True,
225
+ "command": command,
226
+ "output": clean_output,
227
+ "duration": duration,
228
+ "cwd": self.cwd,
229
+ }
230
+
231
+ self.command_history.append(result)
232
+ return result
233
+
234
+ def _execute_with_prompt_wait(
235
+ self,
236
+ command: str,
237
+ expect_patterns: List[str],
238
+ capture_output: bool,
239
+ command_start_time: float,
240
+ ) -> Dict[str, Any]:
241
+ """Execute command and wait for prompt to appear"""
242
+ all_patterns = (
243
+ self.prompt_pattern
244
+ + (expect_patterns or [])
245
+ + [pexpect.TIMEOUT, pexpect.EOF]
246
+ )
247
+ all_output = ""
248
+ command_completed = False
249
+
250
+ # Use a reasonable default timeout for prompt detection
251
+ max_wait = 60 # 60 seconds max
252
+
253
+ while not command_completed:
254
+ try:
255
+ elapsed = time.time() - command_start_time
256
+ if elapsed > max_wait:
257
+ logger.warning(f"Command '{command}' exceeded maximum wait time")
258
+ break
259
+
260
+ # Wait for patterns
261
+ index = self.process.expect(all_patterns, timeout=5)
262
+
263
+ # Collect output
264
+ if capture_output and self.process.before:
265
+ all_output += self.process.before
266
+
267
+ # Check what we matched
268
+ if index < len(self.prompt_pattern):
269
+ # Found a prompt - command is done
270
+ if self._is_real_prompt_match(
271
+ all_output, self.prompt_pattern[index]
272
+ ):
273
+ command_completed = True
274
+ logger.info(f"Command completed after {elapsed:.2f}s")
275
+
276
+ elif index < len(self.prompt_pattern) + len(expect_patterns or []):
277
+ # Matched a custom pattern - continue reading
278
+ continue
279
+
280
+ elif index >= len(all_patterns) - 2: # TIMEOUT or EOF
281
+ # Continue unless we've exceeded max wait
282
+ continue
283
+
284
+ except pexpect.TIMEOUT:
285
+ continue
286
+ except pexpect.EOF:
287
+ if self.process.before and capture_output:
288
+ all_output += self.process.before
289
+ break
290
+
291
+ # Clean up output and determine success
292
+ clean_output = self._clean_output(all_output, command) if capture_output else ""
293
+ success = self._check_command_success(clean_output, command, False)
294
+ duration = time.time() - command_start_time
295
+
296
+ # Store result
297
+ result = {
298
+ "success": success,
299
+ "command": command,
300
+ "output": clean_output,
301
+ "duration": duration,
302
+ "cwd": self.cwd,
303
+ }
304
+
305
+ # Add error details if command failed
306
+ if not success:
307
+ error_info = self._extract_error_details(clean_output, command)
308
+ result["error"] = error_info
309
+
310
+ self.command_history.append(result)
311
+ return result
312
+
313
+ def _execute_with_interactive_responses(
314
+ self,
315
+ command: str,
316
+ expect_patterns: List[str],
317
+ responses: List[str],
318
+ capture_output: bool,
319
+ command_start_time: float,
320
+ ) -> Dict[str, Any]:
321
+ """Execute command and send responses when patterns are matched"""
322
+ all_output = ""
323
+ max_wait = 120 # 2 minutes max for interactive commands
324
+
325
+ patterns_matched = set()
326
+
327
+ # Ensure prompt_pattern is a list
328
+ prompt_patterns = self.prompt_pattern if isinstance(self.prompt_pattern, list) else [self.prompt_pattern]
329
+
330
+ # Build the list of all patterns to watch for (including prompts)
331
+ all_patterns = (
332
+ expect_patterns
333
+ + prompt_patterns
334
+ + [pexpect.TIMEOUT, pexpect.EOF]
335
+ )
336
+
337
+ logger.info(f"Interactive command with {len(expect_patterns)} expected patterns")
338
+
339
+ while True:
340
+ try:
341
+ elapsed = time.time() - command_start_time
342
+ if elapsed > max_wait:
343
+ logger.warning(f"Command '{command}' exceeded maximum wait time")
344
+ break
345
+
346
+ # Wait for any pattern
347
+ index = self.process.expect(all_patterns, timeout=10)
348
+
349
+ # Collect output
350
+ if capture_output and self.process.before:
351
+ all_output += self.process.before
352
+
353
+ # Check what we matched
354
+ if index < len(expect_patterns):
355
+ # Matched one of our expected patterns
356
+ matched_pattern = expect_patterns[index]
357
+ response = responses[index]
358
+
359
+ logger.info(f"Matched pattern {index}: '{matched_pattern}', sending response")
360
+
361
+ # Send the response
362
+ self.process.sendline(response)
363
+ patterns_matched.add(matched_pattern)
364
+
365
+ # If we've matched all patterns, we might be done, but continue
366
+ # until we see a prompt
367
+ if len(patterns_matched) == len(expect_patterns):
368
+ logger.info("All patterns matched, waiting for final prompt")
369
+
370
+ elif index < len(expect_patterns) + len(prompt_patterns):
371
+ # Found a prompt - command is done
372
+ # Trust the pexpect match for interactive commands - the prompt
373
+ # was matched, so we're at a shell prompt
374
+ logger.info(f"Command completed after {elapsed:.2f}s")
375
+ break
376
+
377
+ elif index >= len(all_patterns) - 2: # TIMEOUT or EOF
378
+ # Continue unless we've exceeded max wait
379
+ if elapsed > max_wait:
380
+ break
381
+ continue
382
+
383
+ except pexpect.TIMEOUT:
384
+ elapsed = time.time() - command_start_time
385
+ if elapsed > max_wait:
386
+ break
387
+ continue
388
+ except pexpect.EOF:
389
+ if self.process.before and capture_output:
390
+ all_output += self.process.before
391
+ break
392
+
393
+ # Clean up output and determine success
394
+ clean_output = self._clean_output(all_output, command) if capture_output else ""
395
+
396
+ # Check if all expected patterns were matched
397
+ all_matched = len(patterns_matched) == len(expect_patterns)
398
+ success = all_matched and self._check_command_success(clean_output, command, False)
399
+
400
+ duration = time.time() - command_start_time
401
+
402
+ # Store result
403
+ result = {
404
+ "success": success,
405
+ "command": command,
406
+ "output": clean_output,
407
+ "duration": duration,
408
+ "cwd": self.cwd,
409
+ "patterns_matched": list(patterns_matched),
410
+ "expected_patterns": expect_patterns,
411
+ }
412
+
413
+ # Add error details if command failed
414
+ if not success:
415
+ if not all_matched:
416
+ missing = set(expect_patterns) - patterns_matched
417
+ result["error"] = f"Not all patterns matched. Missing: {missing}"
418
+ else:
419
+ error_info = self._extract_error_details(clean_output, command)
420
+ result["error"] = error_info
421
+
422
+ self.command_history.append(result)
423
+ return result
424
+
425
+ def send_input(self, text: str) -> Dict[str, Any]:
426
+ """Send input to the interactive shell without expecting a prompt"""
427
+ if not self.is_active or not self.process:
428
+ return {"success": False, "error": "Shell session is not active"}
429
+
430
+ try:
431
+ self.process.send(text)
432
+ return {"success": True, "message": f"Sent input: {repr(text)}"}
433
+ except Exception as e:
434
+ return {"success": False, "error": str(e)}
435
+
436
+ def read_output(self, timeout: int = 5) -> Dict[str, Any]:
437
+ """Read available output from the shell"""
438
+ if not self.is_active or not self.process:
439
+ return {"success": False, "error": "Shell session is not active"}
440
+
441
+ try:
442
+ output = ""
443
+ try:
444
+ while True:
445
+ chunk = self.process.read_nonblocking(size=1000, timeout=timeout)
446
+ if not chunk:
447
+ break
448
+ output += chunk
449
+ timeout = 0.1 # Reduce timeout for subsequent reads
450
+ except pexpect.TIMEOUT:
451
+ pass # Normal - no more data available
452
+
453
+ return {"success": True, "output": output}
454
+
455
+ except Exception as e:
456
+ return {"success": False, "error": str(e)}
457
+
458
+ def close(self) -> Dict[str, Any]:
459
+ """Close the interactive shell session and kill any child processes"""
460
+ try:
461
+ if self.process and self.is_active:
462
+ # Send exit command to shell first
463
+ try:
464
+ self.process.sendline("exit")
465
+ self.process.expect(pexpect.EOF, timeout=5)
466
+ except (pexpect.TIMEOUT, pexpect.EOF):
467
+ pass # Shell may have already exited
468
+
469
+ # Terminate the process and its children
470
+ if self.process.isalive():
471
+ self.process.terminate()
472
+ try:
473
+ self.process.wait(timeout=5)
474
+ except pexpect.TIMEOUT:
475
+ # Force kill if it doesn't terminate gracefully
476
+ self.process.kill()
477
+
478
+ # Kill any remaining child processes (like gazebo)
479
+ self._kill_child_processes()
480
+
481
+ self.process.close()
482
+ self.is_active = False
483
+ logger.info(
484
+ "Closed interactive shell session and cleaned up child processes"
485
+ )
486
+ return {
487
+ "success": True,
488
+ "message": "Shell session closed and child processes terminated",
489
+ }
490
+ return {"success": True, "message": "Shell session was already closed"}
491
+ except Exception as e:
492
+ logger.error(f"Error closing shell session: {e}")
493
+ return {"success": False, "error": str(e)}
494
+
495
+ def _kill_child_processes(self):
496
+ """Kill child processes spawned by the shell"""
497
+ try:
498
+ import psutil
499
+ import signal
500
+
501
+ if not self.process or not hasattr(self.process, "pid"):
502
+ return
503
+
504
+ try:
505
+ parent = psutil.Process(self.process.pid)
506
+ children = parent.children(recursive=True)
507
+
508
+ # First try to terminate children gracefully
509
+ for child in children:
510
+ try:
511
+ child.terminate()
512
+ logger.info(
513
+ f"Terminated child process: {child.pid} ({child.name()})"
514
+ )
515
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
516
+ pass
517
+
518
+ # Wait a bit for graceful termination
519
+ import time
520
+
521
+ time.sleep(2)
522
+
523
+ # Force kill any remaining children
524
+ for child in children:
525
+ try:
526
+ if child.is_running():
527
+ child.kill()
528
+ logger.info(
529
+ f"Force killed child process: {child.pid} ({child.name()})"
530
+ )
531
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
532
+ pass
533
+
534
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
535
+ pass
536
+
537
+ except ImportError:
538
+ # psutil not available, try basic approach
539
+ logger.warning("psutil not available, using basic process cleanup")
540
+ try:
541
+ import os
542
+ import signal
543
+
544
+ if self.process and hasattr(self.process, "pid"):
545
+ # Try to kill the process group
546
+ os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
547
+ time.sleep(2)
548
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
549
+ except Exception as e:
550
+ logger.debug(f"Basic process cleanup failed: {e}")
551
+ except Exception as e:
552
+ logger.debug(f"Child process cleanup failed: {e}")
553
+
554
+ def _extract_error_details(self, output: str, command: str) -> str:
555
+ """Extract detailed error information from command output, focusing on the tail"""
556
+ if not output:
557
+ return f"Command '{command}' failed with no output"
558
+
559
+ lines = output.split("\n")
560
+ non_empty_lines = [line.strip() for line in lines if line.strip()]
561
+
562
+ if not non_empty_lines:
563
+ return f"Command '{command}' failed with no readable output"
564
+
565
+ # Always return the last 10 lines of output for context
566
+ # This is most useful for build errors, compilation failures, etc.
567
+ tail_lines = non_empty_lines[-10:]
568
+
569
+ # Also look for critical error patterns in the tail
570
+ error_patterns = [
571
+ "error:",
572
+ "failed",
573
+ "cannot",
574
+ "permission denied",
575
+ "no such file or directory",
576
+ "command not found",
577
+ "syntax error",
578
+ "make: ***",
579
+ "fatal:",
580
+ "abort:",
581
+ "exception:",
582
+ "compilation terminated",
583
+ "build failed",
584
+ ]
585
+
586
+ # Find error lines in the tail
587
+ error_lines = []
588
+ for line in tail_lines:
589
+ line_lower = line.lower()
590
+ if any(pattern in line_lower for pattern in error_patterns):
591
+ error_lines.append(line)
592
+
593
+ # If we found specific errors in tail, prioritize those
594
+ if error_lines:
595
+ return f"Errors found: {' | '.join(error_lines[:5])}"
596
+
597
+ # Otherwise return the complete tail for context
598
+ return f"Command failed - Last 10 lines: {' | '.join(tail_lines)}"
599
+
600
+ def _cleanup_on_error(self):
601
+ """Clean up processes when command fails or times out"""
602
+ logger.info("Cleaning up processes due to error/timeout")
603
+ try:
604
+ # Kill child processes but keep the shell session alive if possible
605
+ self._kill_child_processes()
606
+ except Exception as e:
607
+ logger.debug(f"Error during cleanup: {e}")
608
+
609
+ def get_status(self) -> Dict[str, Any]:
610
+ """Get current status of the shell session"""
611
+ return {
612
+ "is_active": self.is_active,
613
+ "shell_cmd": self.shell_cmd,
614
+ "cwd": self.cwd,
615
+ "created_at": self.created_at,
616
+ "commands_executed": len(self.command_history),
617
+ "process_alive": self.process.isalive() if self.process else False,
618
+ }
619
+
620
+ def _set_custom_prompt(self):
621
+ """Set a custom prompt to avoid ANSI escape sequences"""
622
+ try:
623
+ if self.custom_prompt:
624
+ self.process.sendline(f'export PS1="{self.custom_prompt}"')
625
+ # Add custom prompt to existing patterns (don't replace them)
626
+ # This allows detecting both the custom prompt and other common prompts (useful for nested shells like SSH)
627
+ custom_pattern = re.escape(self.custom_prompt)
628
+
629
+ # Ensure prompt_pattern is a list
630
+ if isinstance(self.prompt_pattern, str):
631
+ self.prompt_pattern = [self.prompt_pattern]
632
+
633
+ if custom_pattern not in self.prompt_pattern:
634
+ self.prompt_pattern.insert(0, custom_pattern) # Add at beginning for priority matching
635
+
636
+ # Wait for new prompt
637
+ self.process.expect([custom_pattern], timeout=10)
638
+ # Clear any remaining output
639
+ self.process.sendline("")
640
+ self.process.expect([custom_pattern], timeout=5)
641
+ logger.info(f"Set custom prompt: {self.custom_prompt}")
642
+ except Exception as e:
643
+ logger.warning(f"Failed to set custom prompt: {e}")
644
+
645
+ def _clear_buffer(self):
646
+ """Clear any accumulated output in the process buffer"""
647
+ if not self.process or not self.is_active:
648
+ return
649
+
650
+ try:
651
+ while True:
652
+ try:
653
+ discarded = self.process.read_nonblocking(size=1000, timeout=0.1)
654
+ if not discarded:
655
+ break
656
+ logger.debug(
657
+ f"Discarded buffer content: {repr(discarded[:100])}..."
658
+ )
659
+ except pexpect.TIMEOUT:
660
+ break
661
+ except pexpect.EOF:
662
+ break
663
+ except Exception as e:
664
+ logger.debug(f"Error clearing buffer: {e}")
665
+
666
+ def _clean_output(self, raw_output: str, command: str) -> str:
667
+ """Clean up raw pexpect output"""
668
+ if not raw_output:
669
+ return ""
670
+
671
+ # Remove ANSI escape sequences
672
+ cleaned = re.sub(r"\x1b\[[?]?[0-9;]*[a-zA-Z]", "", raw_output)
673
+
674
+ # Remove carriage returns
675
+ cleaned = cleaned.replace("\r", "")
676
+
677
+ # For commands that produce important status output, preserve more content
678
+ # This can be configured by caller through command patterns or other means
679
+
680
+ # For other commands, use the original cleaning logic
681
+ lines = cleaned.split("\n")
682
+ clean_lines = []
683
+
684
+ skip_command_echo = False
685
+
686
+ for line in lines:
687
+ # Skip the echoed command line
688
+ if command in line and not skip_command_echo:
689
+ skip_command_echo = True
690
+ continue
691
+ elif skip_command_echo:
692
+ clean_lines.append(line)
693
+
694
+ # If we didn't find the command echo, return all lines
695
+ if not skip_command_echo:
696
+ clean_lines = lines
697
+
698
+ # Remove empty lines at start and end
699
+ while clean_lines and not clean_lines[0].strip():
700
+ clean_lines.pop(0)
701
+ while clean_lines and not clean_lines[-1].strip():
702
+ clean_lines.pop()
703
+
704
+ return "\n".join(clean_lines)
705
+
706
+ def _is_real_prompt_match(self, output: str, prompt_pattern: str) -> bool:
707
+ """Check if a prompt match is real or just appears in log output"""
708
+ if not output:
709
+ return False
710
+
711
+ # Get the last few lines of output
712
+ lines = output.split("\n")
713
+ last_lines = lines[-3:] # Check last 3 lines
714
+
715
+ # Look for the prompt pattern at the start or end of recent lines
716
+ import re
717
+
718
+ for line in last_lines:
719
+ line = line.strip()
720
+ # Check if prompt appears at the end of line (most common)
721
+ if line.endswith(prompt_pattern.replace("\\", "")):
722
+ return True
723
+ # Check if prompt is the only thing on the line
724
+ if line == prompt_pattern.replace("\\", "") or re.match(
725
+ prompt_pattern, line
726
+ ):
727
+ return True
728
+
729
+ return False
730
+
731
+ def _check_command_success(
732
+ self, output: str, command: str, continue_on_error: bool = False
733
+ ) -> bool:
734
+ """Check if command was successful based on output"""
735
+ if continue_on_error:
736
+ return True
737
+
738
+ if not output:
739
+ return True # No output might be success for some commands
740
+
741
+ output_lower = output.lower()
742
+
743
+ # Common error indicators
744
+ error_indicators = [
745
+ "error:",
746
+ "failed",
747
+ "cannot",
748
+ "permission denied",
749
+ "no such file or directory",
750
+ "command not found",
751
+ "syntax error",
752
+ "make: *** [",
753
+ "compilation terminated",
754
+ "build failed",
755
+ "fatal error",
756
+ "abort",
757
+ "segmentation fault",
758
+ ]
759
+
760
+ for indicator in error_indicators:
761
+ if indicator in output_lower:
762
+ return False
763
+
764
+ return True
765
+
766
+
767
+ class ShellSessionManager:
768
+ """Manages multiple interactive shell sessions"""
769
+
770
+ def __init__(self):
771
+ self.sessions: Dict[str, InteractiveShell] = {}
772
+
773
+ def create_session(
774
+ self, session_id: str, shell_cmd: str = "bash", **kwargs
775
+ ) -> Dict[str, Any]:
776
+ """Create a new shell session"""
777
+ if session_id in self.sessions:
778
+ return {"success": False, "error": f"Session '{session_id}' already exists"}
779
+
780
+ try:
781
+ session = InteractiveShell(shell_cmd=shell_cmd, **kwargs)
782
+ result = session.start()
783
+
784
+ if result["success"]:
785
+ self.sessions[session_id] = session
786
+ result["session_id"] = session_id
787
+ else:
788
+ result["error"] = (
789
+ f"Failed to create session '{session_id}': {result.get('error', 'Unknown error')}"
790
+ )
791
+
792
+ return result
793
+ except Exception as e:
794
+ return {
795
+ "success": False,
796
+ "error": f"Exception creating session '{session_id}': {str(e)}",
797
+ "shell_cmd": shell_cmd,
798
+ "kwargs": kwargs,
799
+ }
800
+
801
+ def get_session(self, session_id: str) -> Optional[InteractiveShell]:
802
+ """Get an existing shell session"""
803
+ return self.sessions.get(session_id)
804
+
805
+ def execute_in_session(
806
+ self, session_id: str, command: str, **kwargs
807
+ ) -> Dict[str, Any]:
808
+ """Execute command in a specific session"""
809
+ session = self.get_session(session_id)
810
+ if not session:
811
+ return {
812
+ "success": False,
813
+ "error": f"Session '{session_id}' not found in active sessions: {list(self.sessions.keys())}",
814
+ }
815
+
816
+ try:
817
+ result = session.execute_command(command, **kwargs)
818
+ result["session_id"] = session_id
819
+ return result
820
+ except Exception as e:
821
+ return {
822
+ "success": False,
823
+ "error": f"Exception executing command in session '{session_id}': {str(e)}",
824
+ "command": command,
825
+ "session_id": session_id,
826
+ }
827
+
828
+ def close_session(self, session_id: str) -> Dict[str, Any]:
829
+ """Close and remove a session"""
830
+ session = self.sessions.pop(session_id, None)
831
+ if not session:
832
+ return {"success": False, "error": f"Session '{session_id}' not found"}
833
+
834
+ result = session.close()
835
+ result["session_id"] = session_id
836
+ return result
837
+
838
+ def list_sessions(self) -> Dict[str, Any]:
839
+ """List all active sessions"""
840
+ sessions_info = {}
841
+ for session_id, session in self.sessions.items():
842
+ sessions_info[session_id] = session.get_status()
843
+
844
+ return {"success": True, "sessions": sessions_info}
845
+
846
+ def cleanup_inactive_sessions(self) -> Dict[str, Any]:
847
+ """Remove inactive sessions"""
848
+ inactive_sessions = []
849
+ for session_id, session in list(self.sessions.items()):
850
+ if not session.is_active or (
851
+ session.process and not session.process.isalive()
852
+ ):
853
+ inactive_sessions.append(session_id)
854
+ session.close()
855
+ del self.sessions[session_id]
856
+
857
+ return {
858
+ "success": True,
859
+ "cleaned_sessions": inactive_sessions,
860
+ "message": f"Cleaned up {len(inactive_sessions)} inactive sessions",
861
+ }
862
+
863
+ def cleanup_all_sessions(self) -> Dict[str, Any]:
864
+ """Force close all sessions and clean up their processes"""
865
+ closed_sessions = []
866
+ for session_id, session in list(self.sessions.items()):
867
+ try:
868
+ session.close()
869
+ closed_sessions.append(session_id)
870
+ except Exception as e:
871
+ logger.error(f"Error closing session {session_id}: {e}")
872
+
873
+ self.sessions.clear()
874
+
875
+ return {
876
+ "success": True,
877
+ "closed_sessions": closed_sessions,
878
+ "message": f"Force closed {len(closed_sessions)} sessions",
879
+ }
880
+
881
+
882
+ # Global session manager
883
+ session_manager = ShellSessionManager()