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.
- wcgw/__init__.py +4 -0
- wcgw/client/__init__.py +0 -0
- wcgw/client/bash_state/bash_state.py +1426 -0
- wcgw/client/bash_state/parser/__init__.py +7 -0
- wcgw/client/bash_state/parser/bash_statement_parser.py +181 -0
- wcgw/client/common.py +51 -0
- wcgw/client/diff-instructions.txt +73 -0
- wcgw/client/encoder/__init__.py +47 -0
- wcgw/client/file_ops/diff_edit.py +619 -0
- wcgw/client/file_ops/extensions.py +137 -0
- wcgw/client/file_ops/search_replace.py +212 -0
- wcgw/client/mcp_server/Readme.md +3 -0
- wcgw/client/mcp_server/__init__.py +32 -0
- wcgw/client/mcp_server/server.py +184 -0
- wcgw/client/memory.py +103 -0
- wcgw/client/modes.py +240 -0
- wcgw/client/repo_ops/display_tree.py +116 -0
- wcgw/client/repo_ops/file_stats.py +152 -0
- wcgw/client/repo_ops/path_prob.py +58 -0
- wcgw/client/repo_ops/paths_model.vocab +20000 -0
- wcgw/client/repo_ops/paths_tokens.model +80042 -0
- wcgw/client/repo_ops/repo_context.py +289 -0
- wcgw/client/schema_generator.py +63 -0
- wcgw/client/tool_prompts.py +98 -0
- wcgw/client/tools.py +1432 -0
- wcgw/py.typed +0 -0
- wcgw/types_.py +318 -0
- wcgw-5.5.4.dist-info/METADATA +339 -0
- wcgw-5.5.4.dist-info/RECORD +38 -0
- wcgw-5.5.4.dist-info/WHEEL +4 -0
- wcgw-5.5.4.dist-info/entry_points.txt +4 -0
- wcgw-5.5.4.dist-info/licenses/LICENSE +213 -0
- wcgw_cli/__init__.py +1 -0
- wcgw_cli/__main__.py +3 -0
- wcgw_cli/anthropic_client.py +486 -0
- wcgw_cli/cli.py +40 -0
- wcgw_cli/openai_client.py +404 -0
- wcgw_cli/openai_utils.py +67 -0
|
@@ -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
|