wcgw 5.5.4__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.
@@ -0,0 +1,1426 @@
1
+ import datetime
2
+ import json
3
+ import os
4
+ import platform
5
+ import random
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from dataclasses import dataclass
14
+ from hashlib import md5, sha256
15
+ from typing import (
16
+ Any,
17
+ Literal,
18
+ Optional,
19
+ ParamSpec,
20
+ TypeVar,
21
+ )
22
+ from uuid import uuid4
23
+
24
+ import pexpect
25
+ import psutil
26
+ import pyte
27
+
28
+ from ...types_ import (
29
+ BashCommand,
30
+ Command,
31
+ Console,
32
+ Modes,
33
+ SendAscii,
34
+ SendSpecials,
35
+ SendText,
36
+ StatusCheck,
37
+ )
38
+ from ..encoder import EncoderDecoder
39
+ from ..modes import BashCommandMode, FileEditMode, WriteIfEmptyMode
40
+ from .parser.bash_statement_parser import BashStatementParser
41
+
42
+ PROMPT_CONST = re.compile(r"◉ ([^\n]*)──➤")
43
+ PROMPT_COMMAND = "printf '◉ '\"$(pwd)\"'──➤'' \r\\e[2K'"
44
+ PROMPT_STATEMENT = ""
45
+ BASH_CLF_OUTPUT = Literal["repl", "pending"]
46
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
47
+
48
+
49
+ @dataclass
50
+ class Config:
51
+ timeout: float = 5
52
+ timeout_while_output: float = 20
53
+ output_wait_patience: float = 3
54
+
55
+ def update(
56
+ self, timeout: float, timeout_while_output: float, output_wait_patience: float
57
+ ) -> None:
58
+ self.timeout = timeout
59
+ self.timeout_while_output = timeout_while_output
60
+ self.output_wait_patience = output_wait_patience
61
+
62
+
63
+ CONFIG = Config()
64
+
65
+
66
+ def is_mac() -> bool:
67
+ return platform.system() == "Darwin"
68
+
69
+
70
+ def get_tmpdir() -> str:
71
+ current_tmpdir = os.environ.get("TMPDIR", "")
72
+ if current_tmpdir or not is_mac():
73
+ return tempfile.gettempdir()
74
+ try:
75
+ # Fix issue while running ocrmypdf -> tesseract -> leptonica, set TMPDIR
76
+ # https://github.com/tesseract-ocr/tesseract/issues/4333
77
+ result = subprocess.check_output(
78
+ ["getconf", "DARWIN_USER_TEMP_DIR"],
79
+ text=True,
80
+ timeout=CONFIG.timeout,
81
+ ).strip()
82
+ return result
83
+ except (subprocess.CalledProcessError, FileNotFoundError):
84
+ return "//tmp"
85
+ except Exception:
86
+ return tempfile.gettempdir()
87
+
88
+
89
+ def check_if_screen_command_available() -> bool:
90
+ try:
91
+ subprocess.run(
92
+ ["which", "screen"],
93
+ capture_output=True,
94
+ check=True,
95
+ timeout=CONFIG.timeout,
96
+ )
97
+
98
+ # Check if screenrc exists, create it if it doesn't
99
+ home_dir = os.path.expanduser("~")
100
+ screenrc_path = os.path.join(home_dir, ".screenrc")
101
+
102
+ if not os.path.exists(screenrc_path):
103
+ screenrc_content = """defscrollback 10000
104
+ termcapinfo xterm* ti@:te@
105
+ """
106
+ with open(screenrc_path, "w") as f:
107
+ f.write(screenrc_content)
108
+
109
+ return True
110
+ except (subprocess.CalledProcessError, FileNotFoundError, TimeoutError):
111
+ return False
112
+
113
+
114
+ def get_wcgw_screen_sessions() -> list[str]:
115
+ """
116
+ Get a list of all WCGW screen session IDs.
117
+
118
+ Returns:
119
+ List of screen session IDs that match the wcgw pattern.
120
+ """
121
+ screen_sessions = []
122
+
123
+ try:
124
+ # Get list of all screen sessions
125
+ result = subprocess.run(
126
+ ["screen", "-ls"],
127
+ capture_output=True,
128
+ text=True,
129
+ check=False, # Don't raise exception on non-zero exit code
130
+ timeout=0.5,
131
+ )
132
+ output = result.stdout or result.stderr or ""
133
+
134
+ # Parse screen output to get session IDs
135
+ for line in output.splitlines():
136
+ line = line.strip()
137
+ if not line or not line[0].isdigit():
138
+ continue
139
+
140
+ # Extract session info (e.g., "1234.wcgw.123456 (Detached)")
141
+ session_parts = line.split()
142
+ if not session_parts:
143
+ continue
144
+
145
+ session_id = session_parts[0].strip()
146
+
147
+ # Check if it's a WCGW session
148
+ if ".wcgw." in session_id:
149
+ screen_sessions.append(session_id)
150
+ except Exception:
151
+ # If anything goes wrong, just return empty list
152
+ pass
153
+
154
+ return screen_sessions
155
+
156
+
157
+ def get_orphaned_wcgw_screens() -> list[str]:
158
+ """
159
+ Identify orphaned WCGW screen sessions where the parent process has PID 1
160
+ or doesn't exist.
161
+
162
+ Returns:
163
+ List of screen session IDs that are orphaned and match the wcgw pattern.
164
+ """
165
+ orphaned_screens = []
166
+
167
+ try:
168
+ # Get list of all WCGW screen sessions
169
+ screen_sessions = get_wcgw_screen_sessions()
170
+
171
+ for session_id in screen_sessions:
172
+ # Extract PID from session ID (first part before the dot)
173
+ try:
174
+ pid = int(session_id.split(".")[0])
175
+
176
+ # Check if process exists and if its parent is PID 1
177
+ try:
178
+ process = psutil.Process(pid)
179
+ parent_pid = process.ppid()
180
+
181
+ if parent_pid == 1:
182
+ # This is an orphaned process
183
+ orphaned_screens.append(session_id)
184
+ except psutil.NoSuchProcess:
185
+ # Process doesn't exist anymore, consider it orphaned
186
+ orphaned_screens.append(session_id)
187
+ except (ValueError, IndexError):
188
+ # Couldn't parse PID, skip
189
+ continue
190
+ except Exception:
191
+ # If anything goes wrong, just return empty list
192
+ pass
193
+
194
+ return orphaned_screens
195
+
196
+
197
+ def cleanup_orphaned_wcgw_screens(console: Console) -> None:
198
+ """
199
+ Clean up all orphaned WCGW screen sessions.
200
+
201
+ Args:
202
+ console: Console for logging.
203
+ """
204
+ orphaned_sessions = get_orphaned_wcgw_screens()
205
+
206
+ if not orphaned_sessions:
207
+ return
208
+
209
+ console.log(
210
+ f"Found {len(orphaned_sessions)} orphaned WCGW screen sessions to clean up"
211
+ )
212
+
213
+ for session in orphaned_sessions:
214
+ try:
215
+ subprocess.run(
216
+ ["screen", "-S", session, "-X", "quit"],
217
+ check=False,
218
+ timeout=CONFIG.timeout,
219
+ )
220
+ except Exception as e:
221
+ console.log(f"Failed to kill orphaned screen session: {session}\n{e}")
222
+
223
+
224
+ def cleanup_all_screens_with_name(name: str, console: Console) -> None:
225
+ """
226
+ There could be in worst case multiple screens with same name, clear them if any.
227
+ Clearing just using "screen -X -S {name} quit" doesn't work because screen complains
228
+ that there are several suitable screens.
229
+ """
230
+ try:
231
+ # Try to get the list of screens.
232
+ result = subprocess.run(
233
+ ["screen", "-ls"],
234
+ capture_output=True,
235
+ text=True,
236
+ check=True,
237
+ timeout=CONFIG.timeout,
238
+ )
239
+ output = result.stdout
240
+ except subprocess.CalledProcessError as e:
241
+ # When no screens exist, screen may return a non-zero exit code.
242
+ output = (e.stdout or "") + (e.stderr or "")
243
+ except FileNotFoundError:
244
+ return
245
+ except Exception as e:
246
+ console.log(f"{e}: exception while clearing running screens.")
247
+ return
248
+
249
+ sessions_to_kill = []
250
+
251
+ # Parse each line of the output. The lines containing sessions typically start with a digit.
252
+ for line in output.splitlines():
253
+ line = line.strip()
254
+ if not line or not line[0].isdigit():
255
+ continue
256
+
257
+ # Each session is usually shown as "1234.my_screen (Detached)".
258
+ # We extract the first part, then split on the period to get the session name.
259
+ session_info = line.split()[0].strip() # e.g., "1234.my_screen"
260
+ if session_info.endswith(f".{name}"):
261
+ sessions_to_kill.append(session_info)
262
+ # Now, for every session we found, tell screen to quit it.
263
+ for session in sessions_to_kill:
264
+ try:
265
+ subprocess.run(
266
+ ["screen", "-S", session, "-X", "quit"],
267
+ check=True,
268
+ timeout=CONFIG.timeout,
269
+ )
270
+ except Exception as e:
271
+ console.log(f"Failed to kill screen session: {session}\n{e}")
272
+
273
+
274
+ def get_rc_file_path(shell_path: str) -> Optional[str]:
275
+ """
276
+ Get the rc file path for the given shell.
277
+
278
+ Args:
279
+ shell_path: Path to the shell executable
280
+
281
+ Returns:
282
+ Path to the rc file or None if not supported
283
+ """
284
+ shell_name = os.path.basename(shell_path)
285
+ home_dir = os.path.expanduser("~")
286
+
287
+ if shell_name == "zsh":
288
+ return os.path.join(home_dir, ".zshrc")
289
+ elif shell_name == "bash":
290
+ return os.path.join(home_dir, ".bashrc")
291
+ else:
292
+ return None
293
+
294
+
295
+ def ensure_wcgw_block_in_rc_file(shell_path: str, console: Console) -> None:
296
+ """
297
+ Ensure the WCGW environment block exists in the appropriate rc file.
298
+
299
+ Args:
300
+ shell_path: Path to the shell executable
301
+ console: Console for logging
302
+ """
303
+ rc_file_path = get_rc_file_path(shell_path)
304
+ if not rc_file_path:
305
+ return
306
+
307
+ shell_name = os.path.basename(shell_path)
308
+
309
+ # Define the WCGW block with marker comments
310
+ marker_start = "# --WCGW_ENVIRONMENT_START--"
311
+ marker_end = "# --WCGW_ENVIRONMENT_END--"
312
+
313
+ if shell_name == "zsh":
314
+ wcgw_block = f"""{marker_start}
315
+ if [ -n "$IN_WCGW_ENVIRONMENT" ]; then
316
+ PROMPT_COMMAND='printf "◉ $(pwd)──➤ \\r\\e[2K"'
317
+ prmptcmdwcgw() {{ eval "$PROMPT_COMMAND" }}
318
+ add-zsh-hook -d precmd prmptcmdwcgw
319
+ precmd_functions+=prmptcmdwcgw
320
+ fi
321
+ {marker_end}
322
+ """
323
+ elif shell_name == "bash":
324
+ wcgw_block = f"""{marker_start}
325
+ if [ -n "$IN_WCGW_ENVIRONMENT" ]; then
326
+ PROMPT_COMMAND='printf "◉ $(pwd)──➤ \\r\\e[2K"'
327
+ fi
328
+ {marker_end}
329
+ """
330
+ else:
331
+ return
332
+
333
+ # Check if rc file exists
334
+ if not os.path.exists(rc_file_path):
335
+ # Create the rc file with the WCGW block
336
+ try:
337
+ with open(rc_file_path, "w") as f:
338
+ f.write(wcgw_block)
339
+ console.log(f"Created {rc_file_path} with WCGW environment block")
340
+ except Exception as e:
341
+ console.log(f"Failed to create {rc_file_path}: {e}")
342
+ return
343
+
344
+ # Check if the block already exists
345
+ try:
346
+ with open(rc_file_path) as f:
347
+ content = f.read()
348
+
349
+ if marker_start in content:
350
+ # Block already exists
351
+ return
352
+
353
+ # Append the block to the file
354
+ with open(rc_file_path, "a") as f:
355
+ f.write("\n" + wcgw_block)
356
+ console.log(f"Added WCGW environment block to {rc_file_path}")
357
+ except Exception as e:
358
+ console.log(f"Failed to update {rc_file_path}: {e}")
359
+
360
+
361
+ def start_shell(
362
+ is_restricted_mode: bool,
363
+ initial_dir: str,
364
+ console: Console,
365
+ over_screen: bool,
366
+ shell_path: str,
367
+ ) -> tuple["pexpect.spawn[str]", str]:
368
+ cmd = shell_path
369
+ if is_restricted_mode and cmd.split("/")[-1] == "bash":
370
+ cmd += " -r"
371
+
372
+ overrideenv = {
373
+ **os.environ,
374
+ "PROMPT_COMMAND": PROMPT_COMMAND,
375
+ "TMPDIR": get_tmpdir(),
376
+ "TERM": "xterm-256color",
377
+ "IN_WCGW_ENVIRONMENT": "1",
378
+ "GIT_PAGER": "cat",
379
+ "PAGER": "cat",
380
+ }
381
+ try:
382
+ shell = pexpect.spawn(
383
+ cmd,
384
+ env=overrideenv, # type: ignore[arg-type]
385
+ echo=True,
386
+ encoding="utf-8",
387
+ timeout=CONFIG.timeout,
388
+ cwd=initial_dir,
389
+ codec_errors="backslashreplace",
390
+ dimensions=(500, 160),
391
+ )
392
+ shell.sendline(PROMPT_STATEMENT) # Unset prompt command to avoid interfering
393
+ shell.expect(PROMPT_CONST, timeout=CONFIG.timeout)
394
+ except Exception as e:
395
+ console.print(traceback.format_exc())
396
+ console.log(f"Error starting shell: {e}. Retrying without rc ...")
397
+
398
+ shell = pexpect.spawn(
399
+ "/bin/bash --noprofile --norc",
400
+ env=overrideenv, # type: ignore[arg-type]
401
+ echo=True,
402
+ encoding="utf-8",
403
+ timeout=CONFIG.timeout,
404
+ codec_errors="backslashreplace",
405
+ )
406
+ shell.sendline(PROMPT_STATEMENT)
407
+ shell.expect(PROMPT_CONST, timeout=CONFIG.timeout)
408
+
409
+ initialdir_hash = md5(
410
+ os.path.normpath(os.path.abspath(initial_dir)).encode()
411
+ ).hexdigest()[:5]
412
+ shellid = shlex.quote(
413
+ "wcgw."
414
+ + time.strftime("%d-%Hh%Mm%Ss")
415
+ + f".{initialdir_hash[:3]}."
416
+ + os.path.basename(initial_dir)
417
+ )
418
+ if over_screen:
419
+ if not check_if_screen_command_available():
420
+ raise ValueError("Screen command not available")
421
+ # shellid is just hour, minute, second number
422
+ while True:
423
+ output = shell.expect([PROMPT_CONST, pexpect.TIMEOUT], timeout=0.1)
424
+ if output == 1:
425
+ break
426
+ shell.sendline(f"screen -q -S {shellid} {shell_path}")
427
+ shell.expect(PROMPT_CONST, timeout=CONFIG.timeout)
428
+
429
+ return shell, shellid
430
+
431
+
432
+ def render_terminal_output(text: str) -> list[str]:
433
+ screen = pyte.Screen(160, 500)
434
+ screen.set_mode(pyte.modes.LNM)
435
+ stream = pyte.Stream(screen)
436
+ stream.feed(text)
437
+ # Filter out empty lines
438
+ dsp = screen.display[::-1]
439
+ for i, line in enumerate(dsp):
440
+ if line.strip():
441
+ break
442
+ else:
443
+ i = len(dsp)
444
+ lines = screen.display[: len(dsp) - i]
445
+ return lines
446
+
447
+
448
+ P = ParamSpec("P")
449
+ R = TypeVar("R")
450
+
451
+
452
+ def get_bash_state_dir_xdg() -> str:
453
+ """Get the XDG directory for storing bash state."""
454
+ xdg_data_dir = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
455
+ bash_state_dir = os.path.join(xdg_data_dir, "wcgw", "bash_state")
456
+ os.makedirs(bash_state_dir, exist_ok=True)
457
+ return bash_state_dir
458
+
459
+
460
+ def generate_thread_id() -> str:
461
+ """Generate a random 4-digit thread_id."""
462
+ return f"i{random.randint(1000, 9999)}"
463
+
464
+
465
+ def save_bash_state_by_id(thread_id: str, bash_state_dict: dict[str, Any]) -> None:
466
+ """Save bash state to XDG directory with the given thread_id."""
467
+ if not thread_id:
468
+ return
469
+
470
+ bash_state_dir = get_bash_state_dir_xdg()
471
+ state_file = os.path.join(bash_state_dir, f"{thread_id}_bash_state.json")
472
+
473
+ with open(state_file, "w") as f:
474
+ json.dump(bash_state_dict, f, indent=2)
475
+
476
+
477
+ def load_bash_state_by_id(thread_id: str) -> Optional[dict[str, Any]]:
478
+ """Load bash state from XDG directory with the given thread_id."""
479
+ if not thread_id:
480
+ return None
481
+
482
+ bash_state_dir = get_bash_state_dir_xdg()
483
+ state_file = os.path.join(bash_state_dir, f"{thread_id}_bash_state.json")
484
+
485
+ if not os.path.exists(state_file):
486
+ return None
487
+
488
+ with open(state_file) as f:
489
+ return json.load(f) # type: ignore
490
+
491
+
492
+ class BashState:
493
+ _use_screen: bool
494
+ _current_thread_id: str
495
+
496
+ def __init__(
497
+ self,
498
+ console: Console,
499
+ working_dir: str,
500
+ bash_command_mode: Optional[BashCommandMode],
501
+ file_edit_mode: Optional[FileEditMode],
502
+ write_if_empty_mode: Optional[WriteIfEmptyMode],
503
+ mode: Optional[Modes],
504
+ use_screen: bool,
505
+ whitelist_for_overwrite: Optional[dict[str, "FileWhitelistData"]] = None,
506
+ thread_id: Optional[str] = None,
507
+ shell_path: Optional[str] = None,
508
+ ) -> None:
509
+ self.last_command: str = ""
510
+ self.console = console
511
+ self._cwd = working_dir or os.getcwd()
512
+ # Store the workspace root separately from the current working directory
513
+ self._workspace_root = working_dir or os.getcwd()
514
+ self._bash_command_mode: BashCommandMode = bash_command_mode or BashCommandMode(
515
+ "normal_mode", "all"
516
+ )
517
+ self._file_edit_mode: FileEditMode = file_edit_mode or FileEditMode("all")
518
+ self._write_if_empty_mode: WriteIfEmptyMode = (
519
+ write_if_empty_mode or WriteIfEmptyMode("all")
520
+ )
521
+ self._mode: Modes = mode or "wcgw"
522
+ self._whitelist_for_overwrite: dict[str, FileWhitelistData] = (
523
+ whitelist_for_overwrite or {}
524
+ )
525
+ # Always ensure we have a thread_id
526
+ self._current_thread_id = (
527
+ thread_id if thread_id is not None else generate_thread_id()
528
+ )
529
+ self._bg_expect_thread: Optional[threading.Thread] = None
530
+ self._bg_expect_thread_stop_event = threading.Event()
531
+ self._use_screen = use_screen
532
+ # Ensure shell_path is always a str, never None
533
+ self._shell_path: str = (
534
+ shell_path if shell_path else os.environ.get("SHELL", "/bin/bash")
535
+ )
536
+ if get_rc_file_path(self._shell_path) is None:
537
+ console.log(
538
+ f"Warning: Unsupported shell: {self._shell_path}, defaulting to /bin/bash"
539
+ )
540
+ self._shell_path = "/bin/bash"
541
+
542
+ self.background_shells = dict[str, BashState]()
543
+ self._init_shell()
544
+
545
+ def start_new_bg_shell(self, working_dir: str) -> "BashState":
546
+ cid = uuid4().hex[:10]
547
+ state = BashState(
548
+ self.console,
549
+ working_dir=working_dir,
550
+ bash_command_mode=self.bash_command_mode,
551
+ file_edit_mode=self.file_edit_mode,
552
+ write_if_empty_mode=self.write_if_empty_mode,
553
+ mode=self.mode,
554
+ use_screen=self.over_screen,
555
+ whitelist_for_overwrite=None,
556
+ thread_id=cid,
557
+ shell_path=self._shell_path,
558
+ )
559
+ self.background_shells[cid] = state
560
+ return state
561
+
562
+ def expect(
563
+ self, pattern: Any, timeout: Optional[float] = -1, flush_rem_prompt: bool = True
564
+ ) -> int:
565
+ self.close_bg_expect_thread()
566
+ try:
567
+ output = self._shell.expect(pattern, timeout)
568
+ if isinstance(self._shell.match, re.Match) and self._shell.match.groups():
569
+ cwd = self._shell.match.group(1)
570
+ if cwd.strip():
571
+ self._cwd = cwd
572
+ # We can safely flush current prompt
573
+ if flush_rem_prompt:
574
+ temp_before = self._shell.before
575
+ self.flush_prompt()
576
+ self._shell.before = temp_before
577
+ except pexpect.TIMEOUT:
578
+ # Edge case: gets raised when the child fd is not ready in some timeout
579
+ # pexpect/utils.py:143
580
+ return 1
581
+ return output
582
+
583
+ def flush_prompt(self) -> None:
584
+ # Flush remaining prompt
585
+ for _ in range(200):
586
+ try:
587
+ output = self.expect([" ", pexpect.TIMEOUT], 0.1)
588
+ if output == 1:
589
+ return
590
+ except pexpect.TIMEOUT:
591
+ return
592
+
593
+ def send(self, s: str | bytes, set_as_command: Optional[str]) -> int:
594
+ if set_as_command is not None:
595
+ self.last_command = set_as_command
596
+ # if s == "\n":
597
+ # return self._shell.sendcontrol("m")
598
+ output = self._shell.send(s)
599
+ return output
600
+
601
+ def sendline(self, s: str | bytes, set_as_command: Optional[str]) -> int:
602
+ if set_as_command is not None:
603
+ self.last_command = set_as_command
604
+ output = self._shell.sendline(s)
605
+ return output
606
+
607
+ @property
608
+ def linesep(self) -> Any:
609
+ return self._shell.linesep
610
+
611
+ def sendintr(self) -> None:
612
+ self.close_bg_expect_thread()
613
+ self._shell.sendintr()
614
+
615
+ @property
616
+ def before(self) -> Optional[str]:
617
+ before = self._shell.before
618
+ if before and before.startswith(self.last_command):
619
+ return before[len(self.last_command) :]
620
+ return before
621
+
622
+ def run_bg_expect_thread(self) -> None:
623
+ """
624
+ Run background expect thread for handling shell interactions.
625
+ """
626
+
627
+ def _bg_expect_thread_handler() -> None:
628
+ while True:
629
+ if self._bg_expect_thread_stop_event.is_set():
630
+ break
631
+ output = self._shell.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=0.1)
632
+ if output == 0:
633
+ break
634
+
635
+ if self._bg_expect_thread:
636
+ self.close_bg_expect_thread()
637
+
638
+ self._bg_expect_thread = threading.Thread(
639
+ target=_bg_expect_thread_handler,
640
+ )
641
+ self._bg_expect_thread.start()
642
+ for k, v in self.background_shells.items():
643
+ v.run_bg_expect_thread()
644
+
645
+ def close_bg_expect_thread(self) -> None:
646
+ if self._bg_expect_thread:
647
+ self._bg_expect_thread_stop_event.set()
648
+ self._bg_expect_thread.join()
649
+ self._bg_expect_thread = None
650
+ self._bg_expect_thread_stop_event = threading.Event()
651
+ for k, v in self.background_shells.items():
652
+ v.close_bg_expect_thread()
653
+
654
+ def cleanup(self) -> None:
655
+ self.close_bg_expect_thread()
656
+ self._shell.close(True)
657
+
658
+ def __enter__(self) -> "BashState":
659
+ return self
660
+
661
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
662
+ self.cleanup()
663
+
664
+ @property
665
+ def mode(self) -> Modes:
666
+ return self._mode
667
+
668
+ @property
669
+ def bash_command_mode(self) -> BashCommandMode:
670
+ return self._bash_command_mode
671
+
672
+ @property
673
+ def file_edit_mode(self) -> FileEditMode:
674
+ return self._file_edit_mode
675
+
676
+ @property
677
+ def write_if_empty_mode(self) -> WriteIfEmptyMode:
678
+ return self._write_if_empty_mode
679
+
680
+ def _init_shell(self) -> None:
681
+ self._state: Literal["repl"] | datetime.datetime = "repl"
682
+ self.last_command = ""
683
+ # Ensure self._cwd exists
684
+ os.makedirs(self._cwd, exist_ok=True)
685
+
686
+ # Ensure WCGW block exists in rc file
687
+ ensure_wcgw_block_in_rc_file(self._shell_path, self.console)
688
+
689
+ # Clean up orphaned WCGW screen sessions
690
+ if check_if_screen_command_available():
691
+ cleanup_orphaned_wcgw_screens(self.console)
692
+
693
+ try:
694
+ self._shell, self._shell_id = start_shell(
695
+ self._bash_command_mode.bash_mode == "restricted_mode",
696
+ self._cwd,
697
+ self.console,
698
+ over_screen=self._use_screen,
699
+ shell_path=self._shell_path,
700
+ )
701
+ self.over_screen = self._use_screen
702
+ except Exception as e:
703
+ if not isinstance(e, ValueError):
704
+ self.console.log(traceback.format_exc())
705
+ self.console.log("Retrying without using screen")
706
+ # Try without over_screen
707
+ self._shell, self._shell_id = start_shell(
708
+ self._bash_command_mode.bash_mode == "restricted_mode",
709
+ self._cwd,
710
+ self.console,
711
+ over_screen=False,
712
+ shell_path=self._shell_path,
713
+ )
714
+ self.over_screen = False
715
+
716
+ self._pending_output = ""
717
+
718
+ self.run_bg_expect_thread()
719
+
720
+ def set_pending(self, last_pending_output: str) -> None:
721
+ if not isinstance(self._state, datetime.datetime):
722
+ self._state = datetime.datetime.now()
723
+ self._pending_output = last_pending_output
724
+
725
+ def set_repl(self) -> None:
726
+ self._state = "repl"
727
+ self._pending_output = ""
728
+ self.last_command = ""
729
+
730
+ def clear_to_run(self) -> None:
731
+ """Check if prompt is clear to enter new command otherwise send ctrl c"""
732
+ # First clear
733
+ starttime = time.time()
734
+ self.close_bg_expect_thread()
735
+ try:
736
+ while True:
737
+ try:
738
+ output = self.expect(
739
+ [PROMPT_CONST, pexpect.TIMEOUT], 0.1, flush_rem_prompt=False
740
+ )
741
+ if output == 1:
742
+ break
743
+ except pexpect.TIMEOUT:
744
+ break
745
+ if time.time() - starttime > CONFIG.timeout:
746
+ self.console.log(
747
+ f"Error: could not clear output in {CONFIG.timeout} seconds. Resetting"
748
+ )
749
+ self.reset_shell()
750
+ return
751
+ output = self.expect([" ", pexpect.TIMEOUT], 0.1)
752
+ if output != 1:
753
+ # Then we got something new send ctrl-c
754
+ self.send("\x03", None)
755
+
756
+ output = self.expect([PROMPT_CONST, pexpect.TIMEOUT], CONFIG.timeout)
757
+ if output == 1:
758
+ self.console.log("Error: could not clear output. Resetting")
759
+ self.reset_shell()
760
+ finally:
761
+ self.run_bg_expect_thread()
762
+
763
+ @property
764
+ def state(self) -> BASH_CLF_OUTPUT:
765
+ if self._state == "repl":
766
+ return "repl"
767
+ return "pending"
768
+
769
+ @property
770
+ def cwd(self) -> str:
771
+ return self._cwd
772
+
773
+ @property
774
+ def workspace_root(self) -> str:
775
+ """Return the workspace root directory."""
776
+ return self._workspace_root
777
+
778
+ def set_workspace_root(self, workspace_root: str) -> None:
779
+ """Set the workspace root directory."""
780
+ self._workspace_root = workspace_root
781
+
782
+ @property
783
+ def prompt(self) -> re.Pattern[str]:
784
+ return PROMPT_CONST
785
+
786
+ def reset_shell(self) -> None:
787
+ self.cleanup()
788
+ self._init_shell()
789
+
790
+ @property
791
+ def current_thread_id(self) -> str:
792
+ """Get the current thread_id."""
793
+ return self._current_thread_id
794
+
795
+ def load_state_from_thread_id(self, thread_id: str) -> bool:
796
+ """
797
+ Load bash state from a thread_id.
798
+
799
+ Args:
800
+ thread_id: The thread_id to load state from
801
+
802
+ Returns:
803
+ bool: True if state was successfully loaded, False otherwise
804
+ """
805
+ # Try to load state from disk
806
+ loaded_state = load_bash_state_by_id(thread_id)
807
+ if not loaded_state:
808
+ return False
809
+
810
+ # Parse and load the state
811
+ parsed_state = BashState.parse_state(loaded_state)
812
+ self.load_state(
813
+ parsed_state[0],
814
+ parsed_state[1],
815
+ parsed_state[2],
816
+ parsed_state[3],
817
+ parsed_state[4],
818
+ parsed_state[5],
819
+ parsed_state[5],
820
+ thread_id,
821
+ )
822
+ return True
823
+
824
+ def serialize(self) -> dict[str, Any]:
825
+ """Serialize BashState to a dictionary for saving"""
826
+ return {
827
+ "bash_command_mode": self._bash_command_mode.serialize(),
828
+ "file_edit_mode": self._file_edit_mode.serialize(),
829
+ "write_if_empty_mode": self._write_if_empty_mode.serialize(),
830
+ "whitelist_for_overwrite": {
831
+ k: v.serialize() for k, v in self._whitelist_for_overwrite.items()
832
+ },
833
+ "mode": self._mode,
834
+ "workspace_root": self._workspace_root,
835
+ "chat_id": self._current_thread_id,
836
+ }
837
+
838
+ def save_state_to_disk(self) -> None:
839
+ """Save the current bash state to disk using the thread_id."""
840
+ state_dict = self.serialize()
841
+ save_bash_state_by_id(self._current_thread_id, state_dict)
842
+
843
+ @staticmethod
844
+ def parse_state(
845
+ state: dict[str, Any],
846
+ ) -> tuple[
847
+ BashCommandMode,
848
+ FileEditMode,
849
+ WriteIfEmptyMode,
850
+ Modes,
851
+ dict[str, "FileWhitelistData"],
852
+ str,
853
+ str,
854
+ ]:
855
+ whitelist_state = state["whitelist_for_overwrite"]
856
+ # Convert serialized whitelist data back to FileWhitelistData objects
857
+ whitelist_dict = {}
858
+ if isinstance(whitelist_state, dict):
859
+ for file_path, data in whitelist_state.items():
860
+ if isinstance(data, dict) and "file_hash" in data:
861
+ # New format
862
+ whitelist_dict[file_path] = FileWhitelistData.deserialize(data)
863
+ else:
864
+ # Legacy format (just a hash string)
865
+ # Try to get line count from file if it exists, otherwise use a large default
866
+ whitelist_dict[file_path] = FileWhitelistData(
867
+ file_hash=data if isinstance(data, str) else "",
868
+ line_ranges_read=[(1, 1000000)], # Assume entire file was read
869
+ total_lines=1000000,
870
+ )
871
+ else:
872
+ # Handle really old format if needed
873
+ whitelist_dict = {
874
+ k: FileWhitelistData(
875
+ file_hash="", line_ranges_read=[(1, 1000000)], total_lines=1000000
876
+ )
877
+ for k in whitelist_state
878
+ }
879
+
880
+ # Get the thread_id from state, or generate a new one if not present
881
+ thread_id = state.get("chat_id")
882
+ if thread_id is None:
883
+ thread_id = generate_thread_id()
884
+
885
+ return (
886
+ BashCommandMode.deserialize(state["bash_command_mode"]),
887
+ FileEditMode.deserialize(state["file_edit_mode"]),
888
+ WriteIfEmptyMode.deserialize(state["write_if_empty_mode"]),
889
+ state["mode"],
890
+ whitelist_dict,
891
+ state.get("workspace_root", ""),
892
+ thread_id,
893
+ )
894
+
895
+ def load_state(
896
+ self,
897
+ bash_command_mode: BashCommandMode,
898
+ file_edit_mode: FileEditMode,
899
+ write_if_empty_mode: WriteIfEmptyMode,
900
+ mode: Modes,
901
+ whitelist_for_overwrite: dict[str, "FileWhitelistData"],
902
+ cwd: str,
903
+ workspace_root: str,
904
+ thread_id: str,
905
+ ) -> None:
906
+ """Create a new BashState instance from a serialized state dictionary"""
907
+ self._bash_command_mode = bash_command_mode
908
+ self._cwd = cwd or self._cwd
909
+ self._workspace_root = workspace_root or cwd or self._workspace_root
910
+ self._file_edit_mode = file_edit_mode
911
+ self._write_if_empty_mode = write_if_empty_mode
912
+ self._whitelist_for_overwrite = dict(whitelist_for_overwrite)
913
+ self._mode = mode
914
+ self._current_thread_id = thread_id
915
+ self.reset_shell()
916
+
917
+ # Save state to disk after loading
918
+ self.save_state_to_disk()
919
+
920
+ def get_pending_for(self) -> str:
921
+ if isinstance(self._state, datetime.datetime):
922
+ timedelta = datetime.datetime.now() - self._state
923
+ return (
924
+ str(
925
+ int(
926
+ (
927
+ timedelta + datetime.timedelta(seconds=CONFIG.timeout)
928
+ ).total_seconds()
929
+ )
930
+ )
931
+ + " seconds"
932
+ )
933
+
934
+ return "Not pending"
935
+
936
+ @property
937
+ def whitelist_for_overwrite(self) -> dict[str, "FileWhitelistData"]:
938
+ return self._whitelist_for_overwrite
939
+
940
+ def add_to_whitelist_for_overwrite(
941
+ self, file_paths_with_ranges: dict[str, list[tuple[int, int]]]
942
+ ) -> None:
943
+ """
944
+ Add files to the whitelist for overwrite.
945
+
946
+ Args:
947
+ file_paths_with_ranges: Dictionary mapping file paths to sequences of
948
+ (start_line, end_line) tuples representing
949
+ the ranges that have been read.
950
+ """
951
+ for file_path, ranges in file_paths_with_ranges.items():
952
+ # Read the file to get its hash and count lines
953
+ with open(file_path, "rb") as f:
954
+ file_content = f.read()
955
+ file_hash = sha256(file_content).hexdigest()
956
+ total_lines = file_content.count(b"\n") + 1
957
+
958
+ # Update or create whitelist entry
959
+ if file_path in self._whitelist_for_overwrite:
960
+ # Update existing entry
961
+ whitelist_data = self._whitelist_for_overwrite[file_path]
962
+ whitelist_data.file_hash = file_hash
963
+ whitelist_data.total_lines = total_lines
964
+ for range_start, range_end in ranges:
965
+ whitelist_data.add_range(range_start, range_end)
966
+ else:
967
+ # Create new entry
968
+ self._whitelist_for_overwrite[file_path] = FileWhitelistData(
969
+ file_hash=file_hash,
970
+ line_ranges_read=list(ranges),
971
+ total_lines=total_lines,
972
+ )
973
+
974
+ @property
975
+ def pending_output(self) -> str:
976
+ return self._pending_output
977
+
978
+
979
+ @dataclass
980
+ class FileWhitelistData:
981
+ """Data about a file that has been read and can be modified."""
982
+
983
+ file_hash: str
984
+ # List of line ranges that have been read (inclusive start, inclusive end)
985
+ # E.g., [(1, 10), (20, 30)] means lines 1-10 and 20-30 have been read
986
+ line_ranges_read: list[tuple[int, int]]
987
+ # Total number of lines in the file
988
+ total_lines: int
989
+
990
+ def get_percentage_read(self) -> float:
991
+ """Calculate percentage of file read based on line ranges."""
992
+ if self.total_lines == 0:
993
+ return 100.0
994
+
995
+ # Count unique lines read
996
+ lines_read: set[int] = set()
997
+ for start, end in self.line_ranges_read:
998
+ lines_read.update(range(start, end + 1))
999
+
1000
+ return (len(lines_read) / self.total_lines) * 100.0
1001
+
1002
+ def is_read_enough(self) -> bool:
1003
+ """Check if enough of the file has been read (>=99%)"""
1004
+ return self.get_percentage_read() >= 99
1005
+
1006
+ def get_unread_ranges(self) -> list[tuple[int, int]]:
1007
+ """Return a list of line ranges (start, end) that haven't been read yet.
1008
+
1009
+ Returns line ranges as tuples of (start_line, end_line) in 1-indexed format.
1010
+ If the whole file has been read, returns an empty list.
1011
+ """
1012
+ if self.total_lines == 0:
1013
+ return []
1014
+
1015
+ # First collect all lines that have been read
1016
+ lines_read: set[int] = set()
1017
+ for start, end in self.line_ranges_read:
1018
+ lines_read.update(range(start, end + 1))
1019
+
1020
+ # Generate unread ranges from the gaps
1021
+ unread_ranges: list[tuple[int, int]] = []
1022
+ start_range = None
1023
+
1024
+ for i in range(1, self.total_lines + 1):
1025
+ if i not in lines_read:
1026
+ if start_range is None:
1027
+ start_range = i
1028
+ elif start_range is not None:
1029
+ # End of an unread range
1030
+ unread_ranges.append((start_range, i - 1))
1031
+ start_range = None
1032
+
1033
+ # Don't forget the last range if it extends to the end of the file
1034
+ if start_range is not None:
1035
+ unread_ranges.append((start_range, self.total_lines))
1036
+
1037
+ return unread_ranges
1038
+
1039
+ def add_range(self, start: int, end: int) -> None:
1040
+ """Add a new range of lines that have been read."""
1041
+ # Merge with existing ranges if possible
1042
+ self.line_ranges_read.append((start, end))
1043
+ # Could add range merging logic here for optimization
1044
+
1045
+ def serialize(self) -> dict[str, Any]:
1046
+ """Convert to a serializable dictionary."""
1047
+ return {
1048
+ "file_hash": self.file_hash,
1049
+ "line_ranges_read": self.line_ranges_read,
1050
+ "total_lines": self.total_lines,
1051
+ }
1052
+
1053
+ @classmethod
1054
+ def deserialize(cls, data: dict[str, Any]) -> "FileWhitelistData":
1055
+ """Create from a serialized dictionary."""
1056
+ return cls(
1057
+ file_hash=data.get("file_hash", ""),
1058
+ line_ranges_read=data.get("line_ranges_read", []),
1059
+ total_lines=data.get("total_lines", 0),
1060
+ )
1061
+
1062
+
1063
+ WAITING_INPUT_MESSAGE = """A command is already running. NOTE: You can't run multiple shell commands in main shell, likely a previous program hasn't exited.
1064
+ 1. Get its output using status check.
1065
+ 2. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1066
+ 3. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1067
+ 4. Interrupt and run the process in background
1068
+ """
1069
+
1070
+
1071
+ def get_incremental_output(old_output: list[str], new_output: list[str]) -> list[str]:
1072
+ nold = len(old_output)
1073
+ nnew = len(new_output)
1074
+ if not old_output:
1075
+ return new_output
1076
+ for i in range(nnew - 1, -1, -1):
1077
+ if new_output[i] != old_output[-1]:
1078
+ continue
1079
+ for j in range(i - 1, -1, -1):
1080
+ if (nold - 1 + j - i) < 0:
1081
+ break
1082
+ if new_output[j] != old_output[-1 + j - i]:
1083
+ break
1084
+ else:
1085
+ return new_output[i + 1 :]
1086
+ return new_output
1087
+
1088
+
1089
+ def rstrip(lines: list[str]) -> str:
1090
+ return "\n".join([line.rstrip() for line in lines])
1091
+
1092
+
1093
+ def _incremental_text(text: str, last_pending_output: str) -> str:
1094
+ # text = render_terminal_output(text[-100_000:])
1095
+ text = text[-100_000:]
1096
+
1097
+ if not last_pending_output:
1098
+ # This is the first call. We need to offset the position where this program
1099
+ # is being rendered for the new screen versions
1100
+ # Caveat: no difference in output between a program with leading whitespace and one without.
1101
+ return rstrip(render_terminal_output(text)).lstrip()
1102
+ last_rendered_lines = render_terminal_output(last_pending_output)
1103
+ last_pending_output_rendered = "\n".join(last_rendered_lines)
1104
+ if not last_rendered_lines:
1105
+ return rstrip(render_terminal_output(text))
1106
+
1107
+ text = text[len(last_pending_output) :]
1108
+ old_rendered_applied = render_terminal_output(last_pending_output_rendered + text)
1109
+ # True incremental is then
1110
+ rendered = get_incremental_output(last_rendered_lines[:-1], old_rendered_applied)
1111
+
1112
+ if not rendered:
1113
+ return ""
1114
+
1115
+ if rendered[0] == last_rendered_lines[-1]:
1116
+ rendered = rendered[1:]
1117
+ return rstrip(rendered)
1118
+
1119
+
1120
+ def get_status(bash_state: BashState, is_bg: bool) -> str:
1121
+ status = "\n\n---\n\n"
1122
+ if is_bg:
1123
+ status += f"bg_command_id = {bash_state.current_thread_id}\n"
1124
+ if bash_state.state == "pending":
1125
+ status += "status = still running\n"
1126
+ status += "running for = " + bash_state.get_pending_for() + "\n"
1127
+ status += "cwd = " + bash_state.cwd + "\n"
1128
+ else:
1129
+ bg_desc = ""
1130
+ status += "status = process exited" + bg_desc + "\n"
1131
+ status += "cwd = " + bash_state.cwd + "\n"
1132
+
1133
+ if not is_bg:
1134
+ status += "This is the main shell. " + get_bg_running_commandsinfo(bash_state)
1135
+
1136
+ return status.rstrip()
1137
+
1138
+
1139
+ def is_status_check(arg: BashCommand) -> bool:
1140
+ return (
1141
+ isinstance(arg.action_json, StatusCheck)
1142
+ or (
1143
+ isinstance(arg.action_json, SendSpecials)
1144
+ and arg.action_json.send_specials == ["Enter"]
1145
+ )
1146
+ or (
1147
+ isinstance(arg.action_json, SendAscii)
1148
+ and arg.action_json.send_ascii == [10]
1149
+ )
1150
+ )
1151
+
1152
+
1153
+ def execute_bash(
1154
+ bash_state: BashState,
1155
+ enc: EncoderDecoder[int],
1156
+ bash_arg: BashCommand,
1157
+ max_tokens: Optional[int], # This will be noncoding_max_tokens
1158
+ timeout_s: Optional[float],
1159
+ ) -> tuple[str, float]:
1160
+ try:
1161
+ # Check if the thread_id matches current
1162
+ if bash_arg.thread_id != bash_state.current_thread_id:
1163
+ # Try to load state from the thread_id
1164
+ if not bash_state.load_state_from_thread_id(bash_arg.thread_id):
1165
+ return (
1166
+ f"Error: No saved bash state found for thread_id `{bash_arg.thread_id}`. Please initialize first with this ID.",
1167
+ 0.0,
1168
+ )
1169
+
1170
+ output, cost = _execute_bash(bash_state, enc, bash_arg, max_tokens, timeout_s)
1171
+
1172
+ # Remove echo if it's a command
1173
+ if isinstance(bash_arg.action_json, Command):
1174
+ command = bash_arg.action_json.command.strip()
1175
+ if output.startswith(command):
1176
+ output = output[len(command) :]
1177
+
1178
+ finally:
1179
+ bash_state.run_bg_expect_thread()
1180
+ if bash_state.over_screen:
1181
+ thread = threading.Thread(
1182
+ target=cleanup_orphaned_wcgw_screens,
1183
+ args=(bash_state.console,),
1184
+ daemon=True,
1185
+ )
1186
+ thread.start()
1187
+ return output, cost
1188
+
1189
+
1190
+ def assert_single_statement(command: str) -> None:
1191
+ # Check for multiple statements using the bash statement parser
1192
+ if "\n" in command:
1193
+ try:
1194
+ parser = BashStatementParser()
1195
+ statements = parser.parse_string(command)
1196
+ except Exception:
1197
+ # Fall back to simple newline check if something goes wrong
1198
+ raise ValueError(
1199
+ "Command should not contain newline character in middle. Run only one command at a time."
1200
+ )
1201
+ if len(statements) > 1:
1202
+ raise ValueError(
1203
+ "Error: Command contains multiple statements. Please run only one bash statement at a time."
1204
+ )
1205
+
1206
+
1207
+ def get_bg_running_commandsinfo(bash_state: BashState) -> str:
1208
+ msg = ""
1209
+ running = []
1210
+ for id_, state in bash_state.background_shells.items():
1211
+ running.append(f"Command: {state.last_command}, bg_command_id: {id_}")
1212
+ if running:
1213
+ msg = (
1214
+ "Following background commands are attached:\n" + "\n".join(running) + "\n"
1215
+ )
1216
+ else:
1217
+ msg = "No command running in background.\n"
1218
+ return msg
1219
+
1220
+
1221
+ def _execute_bash(
1222
+ bash_state: BashState,
1223
+ enc: EncoderDecoder[int],
1224
+ bash_arg: BashCommand,
1225
+ max_tokens: Optional[int], # This will be noncoding_max_tokens
1226
+ timeout_s: Optional[float],
1227
+ ) -> tuple[str, float]:
1228
+ try:
1229
+ is_interrupt = False
1230
+ command_data = bash_arg.action_json
1231
+ is_bg = False
1232
+ og_bash_state = bash_state
1233
+
1234
+ if not isinstance(command_data, Command) and command_data.bg_command_id:
1235
+ if command_data.bg_command_id not in bash_state.background_shells:
1236
+ error = f"No shell found running with command id {command_data.bg_command_id}.\n"
1237
+ if bash_state.background_shells:
1238
+ error += get_bg_running_commandsinfo(bash_state)
1239
+ if bash_state.state == "pending":
1240
+ error += f"On the main thread a command is already running ({bash_state.last_command})"
1241
+ else:
1242
+ error += "On the main thread no command is running."
1243
+ raise Exception(error)
1244
+ bash_state = bash_state.background_shells[command_data.bg_command_id]
1245
+ is_bg = True
1246
+
1247
+ if isinstance(command_data, Command):
1248
+ if bash_state.bash_command_mode.allowed_commands == "none":
1249
+ return "Error: BashCommand not allowed in current mode", 0.0
1250
+
1251
+ bash_state.console.print(f"$ {command_data.command}")
1252
+
1253
+ command = command_data.command.strip()
1254
+
1255
+ assert_single_statement(command)
1256
+
1257
+ if command_data.is_background:
1258
+ bash_state = bash_state.start_new_bg_shell(bash_state.cwd)
1259
+ is_bg = True
1260
+
1261
+ if bash_state.state == "pending":
1262
+ raise ValueError(WAITING_INPUT_MESSAGE)
1263
+
1264
+ bash_state.clear_to_run()
1265
+ for i in range(0, len(command), 64):
1266
+ bash_state.send(command[i : i + 64], set_as_command=None)
1267
+ bash_state.send(bash_state.linesep, set_as_command=command)
1268
+ elif isinstance(command_data, StatusCheck):
1269
+ bash_state.console.print("Checking status")
1270
+ if bash_state.state != "pending":
1271
+ error = "No running command to check status of.\n"
1272
+ error += get_bg_running_commandsinfo(bash_state)
1273
+ return error, 0.0
1274
+
1275
+ elif isinstance(command_data, SendText):
1276
+ if not command_data.send_text:
1277
+ return "Failure: send_text cannot be empty", 0.0
1278
+
1279
+ bash_state.console.print(f"Interact text: {command_data.send_text}")
1280
+ for i in range(0, len(command_data.send_text), 128):
1281
+ bash_state.send(
1282
+ command_data.send_text[i : i + 128], set_as_command=None
1283
+ )
1284
+ bash_state.send(bash_state.linesep, set_as_command=None)
1285
+
1286
+ elif isinstance(command_data, SendSpecials):
1287
+ if not command_data.send_specials:
1288
+ return "Failure: send_specials cannot be empty", 0.0
1289
+
1290
+ bash_state.console.print(
1291
+ f"Sending special sequence: {command_data.send_specials}"
1292
+ )
1293
+ for char in command_data.send_specials:
1294
+ if char == "Key-up":
1295
+ bash_state.send("\033[A", set_as_command=None)
1296
+ elif char == "Key-down":
1297
+ bash_state.send("\033[B", set_as_command=None)
1298
+ elif char == "Key-left":
1299
+ bash_state.send("\033[D", set_as_command=None)
1300
+ elif char == "Key-right":
1301
+ bash_state.send("\033[C", set_as_command=None)
1302
+ elif char == "Enter":
1303
+ bash_state.send("\x0d", set_as_command=None)
1304
+ elif char == "Ctrl-c":
1305
+ bash_state.sendintr()
1306
+ is_interrupt = True
1307
+ elif char == "Ctrl-d":
1308
+ bash_state.sendintr()
1309
+ is_interrupt = True
1310
+ elif char == "Ctrl-z":
1311
+ bash_state.send("\x1a", set_as_command=None)
1312
+ else:
1313
+ raise Exception(f"Unknown special character: {char}")
1314
+
1315
+ elif isinstance(command_data, SendAscii):
1316
+ if not command_data.send_ascii:
1317
+ return "Failure: send_ascii cannot be empty", 0.0
1318
+
1319
+ bash_state.console.print(
1320
+ f"Sending ASCII sequence: {command_data.send_ascii}"
1321
+ )
1322
+ for ascii_char in command_data.send_ascii:
1323
+ bash_state.send(chr(ascii_char), set_as_command=None)
1324
+ if ascii_char == 3:
1325
+ is_interrupt = True
1326
+ else:
1327
+ raise ValueError(f"Unknown command type: {type(command_data)}")
1328
+
1329
+ except KeyboardInterrupt:
1330
+ bash_state.sendintr()
1331
+ bash_state.expect(bash_state.prompt)
1332
+ return "---\n\nFailure: user interrupted the execution", 0.0
1333
+
1334
+ wait = min(timeout_s or CONFIG.timeout, CONFIG.timeout_while_output)
1335
+ index = bash_state.expect([bash_state.prompt, pexpect.TIMEOUT], timeout=wait)
1336
+ if index == 1:
1337
+ text = bash_state.before or ""
1338
+ incremental_text = _incremental_text(text, bash_state.pending_output)
1339
+
1340
+ second_wait_success = False
1341
+ if is_status_check(bash_arg):
1342
+ # There's some text in BashInteraction mode wait for TIMEOUT_WHILE_OUTPUT
1343
+ remaining = CONFIG.timeout_while_output - wait
1344
+ patience = CONFIG.output_wait_patience
1345
+ if not incremental_text:
1346
+ patience -= 1
1347
+ itext = incremental_text
1348
+ while remaining > 0 and patience > 0:
1349
+ index = bash_state.expect(
1350
+ [bash_state.prompt, pexpect.TIMEOUT], timeout=wait
1351
+ )
1352
+ if index == 0:
1353
+ second_wait_success = True
1354
+ break
1355
+ else:
1356
+ _itext = bash_state.before or ""
1357
+ _itext = _incremental_text(_itext, bash_state.pending_output)
1358
+ if _itext != itext:
1359
+ patience = 3
1360
+ else:
1361
+ patience -= 1
1362
+ itext = _itext
1363
+
1364
+ remaining = remaining - wait
1365
+
1366
+ if not second_wait_success:
1367
+ text = bash_state.before or ""
1368
+ incremental_text = _incremental_text(text, bash_state.pending_output)
1369
+
1370
+ if not second_wait_success:
1371
+ bash_state.set_pending(text)
1372
+
1373
+ tokens = enc.encoder(incremental_text)
1374
+
1375
+ if max_tokens and len(tokens) >= max_tokens:
1376
+ incremental_text = "(...truncated)\n" + enc.decoder(
1377
+ tokens[-(max_tokens - 1) :]
1378
+ )
1379
+
1380
+ if is_interrupt:
1381
+ incremental_text = (
1382
+ incremental_text
1383
+ + """---
1384
+ ----
1385
+ Failure interrupting.
1386
+ You may want to try Ctrl-c again or program specific exit interactive commands.
1387
+ """
1388
+ )
1389
+
1390
+ exit_status = get_status(bash_state, is_bg)
1391
+ incremental_text += exit_status
1392
+ if is_bg and bash_state.state == "repl":
1393
+ try:
1394
+ bash_state.cleanup()
1395
+ og_bash_state.background_shells.pop(bash_state.current_thread_id)
1396
+ except Exception as e:
1397
+ bash_state.console.log(f"error while cleaning up {e}")
1398
+
1399
+ return incremental_text, 0
1400
+
1401
+ before = str(bash_state.before)
1402
+
1403
+ output = _incremental_text(before, bash_state.pending_output)
1404
+ bash_state.set_repl()
1405
+
1406
+ tokens = enc.encoder(output)
1407
+ if max_tokens and len(tokens) >= max_tokens:
1408
+ output = "(...truncated)\n" + enc.decoder(tokens[-(max_tokens - 1) :])
1409
+
1410
+ try:
1411
+ exit_status = get_status(bash_state, is_bg)
1412
+ output += exit_status
1413
+ if is_bg and bash_state.state == "repl":
1414
+ try:
1415
+ bash_state.cleanup()
1416
+ og_bash_state.background_shells.pop(bash_state.current_thread_id)
1417
+ except Exception as e:
1418
+ bash_state.console.log(f"error while cleaning up {e}")
1419
+ except ValueError:
1420
+ bash_state.console.print(output)
1421
+ bash_state.console.print(traceback.format_exc())
1422
+ bash_state.console.print("Malformed output, restarting shell", style="red")
1423
+ # Malformed output, restart shell
1424
+ bash_state.reset_shell()
1425
+ output = "(exit shell has restarted)"
1426
+ return output, 0