hanzo-mcp 0.3.4__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/cli.py +123 -160
- hanzo_mcp/cli_enhanced.py +438 -0
- hanzo_mcp/config/__init__.py +19 -0
- hanzo_mcp/config/settings.py +388 -0
- hanzo_mcp/config/tool_config.py +197 -0
- hanzo_mcp/prompts/__init__.py +117 -0
- hanzo_mcp/prompts/compact_conversation.py +77 -0
- hanzo_mcp/prompts/create_release.py +38 -0
- hanzo_mcp/prompts/project_system.py +120 -0
- hanzo_mcp/prompts/project_todo_reminder.py +111 -0
- hanzo_mcp/prompts/utils.py +286 -0
- hanzo_mcp/server.py +120 -98
- hanzo_mcp/tools/__init__.py +107 -31
- hanzo_mcp/tools/agent/__init__.py +8 -11
- hanzo_mcp/tools/agent/agent_tool.py +290 -224
- hanzo_mcp/tools/agent/prompt.py +16 -13
- hanzo_mcp/tools/agent/tool_adapter.py +9 -9
- hanzo_mcp/tools/common/__init__.py +17 -16
- hanzo_mcp/tools/common/base.py +79 -110
- hanzo_mcp/tools/common/batch_tool.py +330 -0
- hanzo_mcp/tools/common/context.py +26 -292
- hanzo_mcp/tools/common/permissions.py +12 -12
- hanzo_mcp/tools/common/thinking_tool.py +153 -0
- hanzo_mcp/tools/common/validation.py +1 -63
- hanzo_mcp/tools/filesystem/__init__.py +88 -41
- hanzo_mcp/tools/filesystem/base.py +32 -24
- hanzo_mcp/tools/filesystem/content_replace.py +114 -107
- hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
- hanzo_mcp/tools/filesystem/edit.py +279 -0
- hanzo_mcp/tools/filesystem/grep.py +458 -0
- hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
- hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
- hanzo_mcp/tools/filesystem/read.py +255 -0
- hanzo_mcp/tools/filesystem/write.py +156 -0
- hanzo_mcp/tools/jupyter/__init__.py +41 -29
- hanzo_mcp/tools/jupyter/base.py +66 -57
- hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
- hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
- hanzo_mcp/tools/shell/__init__.py +29 -20
- hanzo_mcp/tools/shell/base.py +87 -45
- hanzo_mcp/tools/shell/bash_session.py +731 -0
- hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
- hanzo_mcp/tools/shell/command_executor.py +435 -384
- hanzo_mcp/tools/shell/run_command.py +284 -131
- hanzo_mcp/tools/shell/run_command_windows.py +328 -0
- hanzo_mcp/tools/shell/session_manager.py +196 -0
- hanzo_mcp/tools/shell/session_storage.py +325 -0
- hanzo_mcp/tools/todo/__init__.py +66 -0
- hanzo_mcp/tools/todo/base.py +319 -0
- hanzo_mcp/tools/todo/todo_read.py +148 -0
- hanzo_mcp/tools/todo/todo_write.py +378 -0
- hanzo_mcp/tools/vector/__init__.py +95 -0
- hanzo_mcp/tools/vector/infinity_store.py +365 -0
- hanzo_mcp/tools/vector/project_manager.py +361 -0
- hanzo_mcp/tools/vector/vector_index.py +115 -0
- hanzo_mcp/tools/vector/vector_search.py +215 -0
- {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +35 -3
- hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
- {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
- hanzo_mcp/tools/agent/base_provider.py +0 -73
- hanzo_mcp/tools/agent/litellm_provider.py +0 -45
- hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
- hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
- hanzo_mcp/tools/agent/provider_registry.py +0 -120
- hanzo_mcp/tools/common/error_handling.py +0 -86
- hanzo_mcp/tools/common/logging_config.py +0 -115
- hanzo_mcp/tools/common/session.py +0 -91
- hanzo_mcp/tools/common/think_tool.py +0 -123
- hanzo_mcp/tools/common/version_tool.py +0 -120
- hanzo_mcp/tools/filesystem/edit_file.py +0 -287
- hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
- hanzo_mcp/tools/filesystem/read_files.py +0 -198
- hanzo_mcp/tools/filesystem/search_content.py +0 -275
- hanzo_mcp/tools/filesystem/write_file.py +0 -162
- hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
- hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
- hanzo_mcp/tools/project/__init__.py +0 -64
- hanzo_mcp/tools/project/analysis.py +0 -882
- hanzo_mcp/tools/project/base.py +0 -66
- hanzo_mcp/tools/project/project_analyze.py +0 -173
- hanzo_mcp/tools/shell/run_script.py +0 -215
- hanzo_mcp/tools/shell/script_tool.py +0 -244
- hanzo_mcp-0.3.4.dist-info/RECORD +0 -53
- {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Bash session management using tmux for persistent shell environments.
|
|
2
|
+
|
|
3
|
+
This module provides the BashSession class which creates and manages persistent
|
|
4
|
+
shell sessions using tmux, inspired by OpenHands' BashSession implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, final
|
|
12
|
+
|
|
13
|
+
import bashlex # type: ignore
|
|
14
|
+
import libtmux
|
|
15
|
+
|
|
16
|
+
from hanzo_mcp.tools.shell.base import (
|
|
17
|
+
BashCommandStatus,
|
|
18
|
+
CommandResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def split_bash_commands(commands: str) -> list[str]:
|
|
23
|
+
"""Split bash commands using bashlex parser.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
commands: The command string to split
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of individual commands
|
|
30
|
+
"""
|
|
31
|
+
if not commands.strip():
|
|
32
|
+
return [""]
|
|
33
|
+
try:
|
|
34
|
+
parsed = bashlex.parse(commands)
|
|
35
|
+
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
|
|
36
|
+
# If parsing fails, return the original commands
|
|
37
|
+
return [commands]
|
|
38
|
+
|
|
39
|
+
result: list[str] = []
|
|
40
|
+
last_end = 0
|
|
41
|
+
|
|
42
|
+
for node in parsed:
|
|
43
|
+
start, end = node.pos
|
|
44
|
+
|
|
45
|
+
# Include any text between the last command and this one
|
|
46
|
+
if start > last_end:
|
|
47
|
+
between = commands[last_end:start]
|
|
48
|
+
if result:
|
|
49
|
+
result[-1] += between.rstrip()
|
|
50
|
+
elif between.strip():
|
|
51
|
+
result.append(between.rstrip())
|
|
52
|
+
|
|
53
|
+
# Extract the command, preserving original formatting
|
|
54
|
+
command = commands[start:end].rstrip()
|
|
55
|
+
result.append(command)
|
|
56
|
+
|
|
57
|
+
last_end = end
|
|
58
|
+
|
|
59
|
+
# Add any remaining text after the last command to the last command
|
|
60
|
+
remaining = commands[last_end:].rstrip()
|
|
61
|
+
if last_end < len(commands) and result:
|
|
62
|
+
result[-1] += remaining
|
|
63
|
+
elif last_end < len(commands):
|
|
64
|
+
if remaining:
|
|
65
|
+
result.append(remaining)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def escape_bash_special_chars(command: str) -> str:
|
|
70
|
+
"""Escape characters that have different interpretations in bash vs python.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
command: The command to escape
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Escaped command string
|
|
77
|
+
"""
|
|
78
|
+
if command.strip() == "":
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
parts = []
|
|
83
|
+
last_pos = 0
|
|
84
|
+
|
|
85
|
+
def visit_node(node: Any) -> None:
|
|
86
|
+
nonlocal last_pos
|
|
87
|
+
if (
|
|
88
|
+
node.kind == "redirect"
|
|
89
|
+
and hasattr(node, "heredoc")
|
|
90
|
+
and node.heredoc is not None
|
|
91
|
+
):
|
|
92
|
+
# We're entering a heredoc - preserve everything as-is until we see EOF
|
|
93
|
+
between = command[last_pos : node.pos[0]]
|
|
94
|
+
parts.append(between)
|
|
95
|
+
# Add the heredoc start marker
|
|
96
|
+
parts.append(command[node.pos[0] : node.heredoc.pos[0]])
|
|
97
|
+
# Add the heredoc content as-is
|
|
98
|
+
parts.append(command[node.heredoc.pos[0] : node.heredoc.pos[1]])
|
|
99
|
+
last_pos = node.pos[1]
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if node.kind == "word":
|
|
103
|
+
# Get the raw text between the last position and current word
|
|
104
|
+
between = command[last_pos : node.pos[0]]
|
|
105
|
+
word_text = command[node.pos[0] : node.pos[1]]
|
|
106
|
+
|
|
107
|
+
# Add the between text, escaping special characters
|
|
108
|
+
between = re.sub(r"\\([;&|><])", r"\\\\\1", between)
|
|
109
|
+
parts.append(between)
|
|
110
|
+
|
|
111
|
+
# Check if word_text is a quoted string or command substitution
|
|
112
|
+
if (
|
|
113
|
+
(word_text.startswith('"') and word_text.endswith('"'))
|
|
114
|
+
or (word_text.startswith("'") and word_text.endswith("'"))
|
|
115
|
+
or (word_text.startswith("$(") and word_text.endswith(")"))
|
|
116
|
+
or (word_text.startswith("`") and word_text.endswith("`"))
|
|
117
|
+
):
|
|
118
|
+
# Preserve quoted strings, command substitutions, and heredoc content as-is
|
|
119
|
+
parts.append(word_text)
|
|
120
|
+
else:
|
|
121
|
+
# Escape special chars in unquoted text
|
|
122
|
+
word_text = re.sub(r"\\([;&|><])", r"\\\\\1", word_text)
|
|
123
|
+
parts.append(word_text)
|
|
124
|
+
|
|
125
|
+
last_pos = node.pos[1]
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Visit child nodes
|
|
129
|
+
if hasattr(node, "parts"):
|
|
130
|
+
for part in node.parts:
|
|
131
|
+
visit_node(part)
|
|
132
|
+
|
|
133
|
+
# Process all nodes in the AST
|
|
134
|
+
nodes = list(bashlex.parse(command))
|
|
135
|
+
for node in nodes:
|
|
136
|
+
between = command[last_pos : node.pos[0]]
|
|
137
|
+
between = re.sub(r"\\([;&|><])", r"\\\\\1", between)
|
|
138
|
+
parts.append(between)
|
|
139
|
+
last_pos = node.pos[0]
|
|
140
|
+
visit_node(node)
|
|
141
|
+
|
|
142
|
+
# Handle any remaining text after the last word
|
|
143
|
+
remaining = command[last_pos:]
|
|
144
|
+
parts.append(remaining)
|
|
145
|
+
return "".join(parts)
|
|
146
|
+
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
|
|
147
|
+
return command
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _remove_command_prefix(command_output: str, command: str) -> str:
|
|
151
|
+
"""Remove the command prefix from output."""
|
|
152
|
+
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@final
|
|
156
|
+
class BashSession:
|
|
157
|
+
"""Persistent bash session using tmux.
|
|
158
|
+
|
|
159
|
+
This class provides a persistent shell environment where commands maintain
|
|
160
|
+
shared history, environment variables, and working directory state.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
HISTORY_LIMIT = 10_000
|
|
164
|
+
# Use simple PS1 for now to avoid shell compatibility issues
|
|
165
|
+
PS1 = "$ " # Simple PS1 for better compatibility
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
id: str,
|
|
170
|
+
work_dir: str,
|
|
171
|
+
username: str | None = None,
|
|
172
|
+
no_change_timeout_seconds: int = 30,
|
|
173
|
+
max_memory_mb: int | None = None,
|
|
174
|
+
poll_interval: float = 0.5,
|
|
175
|
+
):
|
|
176
|
+
"""Initialize a bash session.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
work_dir: Working directory for the session
|
|
180
|
+
username: Username to run commands as
|
|
181
|
+
no_change_timeout_seconds: Timeout for commands with no output changes
|
|
182
|
+
max_memory_mb: Memory limit (not implemented yet)
|
|
183
|
+
poll_interval: Interval between polls in seconds (default 0.5, use 0.1 for tests)
|
|
184
|
+
"""
|
|
185
|
+
self.POLL_INTERVAL = poll_interval
|
|
186
|
+
self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
|
|
187
|
+
self.id = id
|
|
188
|
+
self.work_dir = work_dir
|
|
189
|
+
self.username = username
|
|
190
|
+
self._initialized = False
|
|
191
|
+
self.max_memory_mb = max_memory_mb
|
|
192
|
+
|
|
193
|
+
# Session state
|
|
194
|
+
self.prev_status: BashCommandStatus | None = None
|
|
195
|
+
self.prev_output: str = ""
|
|
196
|
+
self._closed: bool = False
|
|
197
|
+
self._cwd = os.path.abspath(work_dir)
|
|
198
|
+
|
|
199
|
+
# tmux components
|
|
200
|
+
self.server: libtmux.Server | None = None
|
|
201
|
+
self.session: libtmux.Session | None = None
|
|
202
|
+
self.window: libtmux.Window | None = None
|
|
203
|
+
self.pane: libtmux.Pane | None = None
|
|
204
|
+
|
|
205
|
+
def initialize(self) -> None:
|
|
206
|
+
"""Initialize the tmux session."""
|
|
207
|
+
if self._initialized:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
self.server = libtmux.Server()
|
|
211
|
+
# Use the user's current shell, fallback to /bin/bash
|
|
212
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
213
|
+
_shell_command = user_shell
|
|
214
|
+
|
|
215
|
+
if self.username in ["root"]:
|
|
216
|
+
# This starts a non-login (new) shell for the given user
|
|
217
|
+
_shell_command = f"su {self.username} -"
|
|
218
|
+
|
|
219
|
+
window_command = _shell_command
|
|
220
|
+
session_name = f"hanzo-mcp-{self.username or 'default'}-{uuid.uuid4()}"
|
|
221
|
+
|
|
222
|
+
self.session = self.server.new_session(
|
|
223
|
+
session_name=session_name,
|
|
224
|
+
start_directory=self.work_dir,
|
|
225
|
+
kill_session=True,
|
|
226
|
+
x=1000,
|
|
227
|
+
y=1000,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Set history limit to a large number to avoid losing history
|
|
231
|
+
self.session.set_option("history-limit", str(self.HISTORY_LIMIT), global_=True)
|
|
232
|
+
self.session.history_limit = str(self.HISTORY_LIMIT)
|
|
233
|
+
|
|
234
|
+
# We need to create a new pane because the initial pane's history limit is (default) 2000
|
|
235
|
+
_initial_window = self.session.active_window
|
|
236
|
+
self.window = self.session.new_window(
|
|
237
|
+
window_name="bash",
|
|
238
|
+
window_shell=window_command,
|
|
239
|
+
start_directory=self.work_dir,
|
|
240
|
+
)
|
|
241
|
+
self.pane = self.window.active_pane
|
|
242
|
+
_initial_window.kill_window()
|
|
243
|
+
|
|
244
|
+
assert self.pane
|
|
245
|
+
|
|
246
|
+
# Configure bash to use simple PS1 and disable PS2
|
|
247
|
+
# Use a simpler PS1 that works reliably across different shells
|
|
248
|
+
self.pane.send_keys('export PS1="$ "')
|
|
249
|
+
# Set PS2 to empty
|
|
250
|
+
self.pane.send_keys('export PS2=""')
|
|
251
|
+
# For zsh, also set PROMPT and disable themes
|
|
252
|
+
self.pane.send_keys('export PROMPT="$ "')
|
|
253
|
+
self.pane.send_keys("unset ZSH_THEME")
|
|
254
|
+
self._clear_screen()
|
|
255
|
+
|
|
256
|
+
self._initialized = True
|
|
257
|
+
|
|
258
|
+
def __del__(self) -> None:
|
|
259
|
+
"""Ensure the session is closed when the object is destroyed."""
|
|
260
|
+
self.close()
|
|
261
|
+
|
|
262
|
+
def _get_pane_content(self) -> str:
|
|
263
|
+
"""Capture the current pane content."""
|
|
264
|
+
if not self.pane:
|
|
265
|
+
return ""
|
|
266
|
+
|
|
267
|
+
content = "\n".join(
|
|
268
|
+
map(
|
|
269
|
+
lambda line: line.rstrip(),
|
|
270
|
+
self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return content
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
"""Clean up the session."""
|
|
277
|
+
if self._closed or not self.session:
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
self.session.kill_session()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass # Ignore cleanup errors
|
|
283
|
+
self._closed = True
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def cwd(self) -> str:
|
|
287
|
+
"""Get current working directory."""
|
|
288
|
+
return self._cwd
|
|
289
|
+
|
|
290
|
+
def _is_special_key(self, command: str) -> bool:
|
|
291
|
+
"""Check if the command is a special key."""
|
|
292
|
+
_command = command.strip()
|
|
293
|
+
return _command.startswith("C-") and len(_command) == 3
|
|
294
|
+
|
|
295
|
+
def _clear_screen(self) -> None:
|
|
296
|
+
"""Clear the tmux pane screen and history."""
|
|
297
|
+
if not self.pane:
|
|
298
|
+
return
|
|
299
|
+
self.pane.send_keys("C-l", enter=False)
|
|
300
|
+
time.sleep(0.1)
|
|
301
|
+
self.pane.cmd("clear-history")
|
|
302
|
+
|
|
303
|
+
def execute(
|
|
304
|
+
self,
|
|
305
|
+
command: str,
|
|
306
|
+
is_input: bool = False,
|
|
307
|
+
blocking: bool = False,
|
|
308
|
+
timeout: float | None = None,
|
|
309
|
+
) -> CommandResult:
|
|
310
|
+
"""Execute a command in the bash session.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
command: Command to execute
|
|
314
|
+
is_input: Whether this is input to a running process
|
|
315
|
+
blocking: Whether to run in blocking mode
|
|
316
|
+
timeout: Hard timeout for command execution
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
CommandResult with execution results
|
|
320
|
+
"""
|
|
321
|
+
if not self._initialized:
|
|
322
|
+
self.initialize()
|
|
323
|
+
|
|
324
|
+
# Strip the command of any leading/trailing whitespace
|
|
325
|
+
command = command.strip()
|
|
326
|
+
|
|
327
|
+
# If the previous command is not completed, check if we can proceed
|
|
328
|
+
if self.prev_status not in {
|
|
329
|
+
BashCommandStatus.CONTINUE,
|
|
330
|
+
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
|
331
|
+
BashCommandStatus.HARD_TIMEOUT,
|
|
332
|
+
}:
|
|
333
|
+
if is_input:
|
|
334
|
+
return CommandResult(
|
|
335
|
+
return_code=1,
|
|
336
|
+
error_message="ERROR: No previous running command to interact with.",
|
|
337
|
+
command=command,
|
|
338
|
+
status=BashCommandStatus.COMPLETED,
|
|
339
|
+
session_id=self.id,
|
|
340
|
+
)
|
|
341
|
+
if command == "":
|
|
342
|
+
return CommandResult(
|
|
343
|
+
return_code=1,
|
|
344
|
+
error_message="ERROR: No previous running command to retrieve logs from.",
|
|
345
|
+
command=command,
|
|
346
|
+
status=BashCommandStatus.COMPLETED,
|
|
347
|
+
session_id=self.id,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Check if the command is a single command or multiple commands
|
|
351
|
+
splited_commands = split_bash_commands(command)
|
|
352
|
+
if len(splited_commands) > 1:
|
|
353
|
+
return CommandResult(
|
|
354
|
+
return_code=1,
|
|
355
|
+
error_message=(
|
|
356
|
+
f"ERROR: Cannot execute multiple commands at once.\n"
|
|
357
|
+
f"Please run each command separately OR chain them into a single command via && or ;\n"
|
|
358
|
+
f"Provided commands:\n{'\n'.join(f'({i + 1}) {cmd}' for i, cmd in enumerate(splited_commands))}"
|
|
359
|
+
),
|
|
360
|
+
command=command,
|
|
361
|
+
status=BashCommandStatus.COMPLETED,
|
|
362
|
+
session_id=self.id,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Get initial state before sending command
|
|
366
|
+
initial_pane_output = self._get_pane_content()
|
|
367
|
+
|
|
368
|
+
start_time = time.time()
|
|
369
|
+
last_change_time = start_time
|
|
370
|
+
last_pane_output = initial_pane_output
|
|
371
|
+
|
|
372
|
+
assert self.pane
|
|
373
|
+
|
|
374
|
+
# When prev command is still running and we're trying to send a new command
|
|
375
|
+
if (
|
|
376
|
+
self.prev_status
|
|
377
|
+
in {
|
|
378
|
+
BashCommandStatus.HARD_TIMEOUT,
|
|
379
|
+
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
|
380
|
+
}
|
|
381
|
+
and not is_input
|
|
382
|
+
and command != ""
|
|
383
|
+
):
|
|
384
|
+
return self._handle_command_conflict(command, last_pane_output)
|
|
385
|
+
|
|
386
|
+
# Send actual command/inputs to the pane
|
|
387
|
+
if command != "":
|
|
388
|
+
is_special_key = self._is_special_key(command)
|
|
389
|
+
if is_input:
|
|
390
|
+
self.pane.send_keys(command, enter=not is_special_key)
|
|
391
|
+
else:
|
|
392
|
+
# Escape command for bash
|
|
393
|
+
command_escaped = escape_bash_special_chars(command)
|
|
394
|
+
self.pane.send_keys(command_escaped, enter=not is_special_key)
|
|
395
|
+
|
|
396
|
+
# Loop until the command completes or times out
|
|
397
|
+
while True:
|
|
398
|
+
time.sleep(self.POLL_INTERVAL)
|
|
399
|
+
cur_pane_output = self._get_pane_content()
|
|
400
|
+
|
|
401
|
+
if cur_pane_output != last_pane_output:
|
|
402
|
+
last_pane_output = cur_pane_output
|
|
403
|
+
last_change_time = time.time()
|
|
404
|
+
|
|
405
|
+
# 1) Execution completed: Use broader prompt detection
|
|
406
|
+
# Check for various prompt patterns that might be used
|
|
407
|
+
prompt_patterns = [
|
|
408
|
+
"$ ", # bash default
|
|
409
|
+
"$", # bash without space
|
|
410
|
+
"% ", # zsh default
|
|
411
|
+
"%", # zsh without space
|
|
412
|
+
"❯ ", # oh-my-zsh
|
|
413
|
+
"❯", # oh-my-zsh without space
|
|
414
|
+
"> ", # generic
|
|
415
|
+
">", # generic without space
|
|
416
|
+
]
|
|
417
|
+
output_ends_with_prompt = any(
|
|
418
|
+
cur_pane_output.rstrip().endswith(pattern)
|
|
419
|
+
for pattern in prompt_patterns
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Also check for username@hostname pattern (common in many shells)
|
|
423
|
+
has_user_host_pattern = "@" in cur_pane_output and any(
|
|
424
|
+
cur_pane_output.rstrip().endswith(indicator)
|
|
425
|
+
for indicator in prompt_patterns
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if output_ends_with_prompt or has_user_host_pattern:
|
|
429
|
+
return self._fallback_completion_detection(command, cur_pane_output)
|
|
430
|
+
|
|
431
|
+
# 2) No-change timeout (only if not blocking)
|
|
432
|
+
time_since_last_change = time.time() - last_change_time
|
|
433
|
+
if (
|
|
434
|
+
not blocking
|
|
435
|
+
and time_since_last_change >= self.NO_CHANGE_TIMEOUT_SECONDS
|
|
436
|
+
):
|
|
437
|
+
# Extract current output
|
|
438
|
+
lines = cur_pane_output.strip().split("\n")
|
|
439
|
+
output = "\n".join(lines)
|
|
440
|
+
output = _remove_command_prefix(output, command)
|
|
441
|
+
|
|
442
|
+
return CommandResult(
|
|
443
|
+
return_code=-1,
|
|
444
|
+
stdout=output.strip(),
|
|
445
|
+
stderr="",
|
|
446
|
+
error_message=f"no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds",
|
|
447
|
+
command=command,
|
|
448
|
+
status=BashCommandStatus.NO_CHANGE_TIMEOUT,
|
|
449
|
+
session_id=self.id,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# 3) Hard timeout
|
|
453
|
+
elapsed_time = time.time() - start_time
|
|
454
|
+
if timeout and elapsed_time >= timeout:
|
|
455
|
+
lines = cur_pane_output.strip().split("\n")
|
|
456
|
+
output = "\n".join(lines)
|
|
457
|
+
output = _remove_command_prefix(output, command)
|
|
458
|
+
|
|
459
|
+
return CommandResult(
|
|
460
|
+
return_code=-1,
|
|
461
|
+
stdout=output.strip(),
|
|
462
|
+
stderr="",
|
|
463
|
+
error_message=f"Command timed out after {timeout} seconds",
|
|
464
|
+
command=command,
|
|
465
|
+
status=BashCommandStatus.HARD_TIMEOUT,
|
|
466
|
+
session_id=self.id,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def _handle_command_conflict(self, command: str, pane_output: str) -> CommandResult:
|
|
470
|
+
"""Handle conflicts when trying to send a new command while previous is running."""
|
|
471
|
+
# Extract current output directly
|
|
472
|
+
lines = pane_output.strip().split("\n")
|
|
473
|
+
raw_command_output = "\n".join(lines)
|
|
474
|
+
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
|
475
|
+
|
|
476
|
+
command_output = self._get_command_output(
|
|
477
|
+
command,
|
|
478
|
+
raw_command_output,
|
|
479
|
+
continue_prefix="[Below is the output of the previous command.]\n",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Add suffix message about command conflict
|
|
483
|
+
command_output += (
|
|
484
|
+
f'\n[Your command "{command}" is NOT executed. '
|
|
485
|
+
f"The previous command is still running - You CANNOT send new commands until the previous command is completed. "
|
|
486
|
+
"By setting `is_input` to `true`, you can interact with the current process: "
|
|
487
|
+
"You may wait longer to see additional output of the previous command by sending empty command '', "
|
|
488
|
+
"send other commands to interact with the current process, "
|
|
489
|
+
'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return CommandResult(
|
|
493
|
+
return_code=1,
|
|
494
|
+
stdout=command_output,
|
|
495
|
+
command=command,
|
|
496
|
+
status=BashCommandStatus.CONTINUE,
|
|
497
|
+
session_id=self.id,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _handle_nochange_timeout_command(
|
|
501
|
+
self, command: str, pane_content: str
|
|
502
|
+
) -> CommandResult:
|
|
503
|
+
"""Handle a command that timed out due to no output changes."""
|
|
504
|
+
self.prev_status = BashCommandStatus.NO_CHANGE_TIMEOUT
|
|
505
|
+
|
|
506
|
+
# Extract current output directly
|
|
507
|
+
lines = pane_content.strip().split("\n")
|
|
508
|
+
raw_command_output = "\n".join(lines)
|
|
509
|
+
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
|
510
|
+
|
|
511
|
+
command_output = self._get_command_output(
|
|
512
|
+
command,
|
|
513
|
+
raw_command_output,
|
|
514
|
+
continue_prefix="[Below is the output of the previous command.]\n",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Add timeout message
|
|
518
|
+
command_output += (
|
|
519
|
+
f"\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. "
|
|
520
|
+
"You may wait longer to see additional output by sending empty command '', "
|
|
521
|
+
"send other commands to interact with the current process, "
|
|
522
|
+
"or send keys to interrupt/kill the command.]"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return CommandResult(
|
|
526
|
+
return_code=-1,
|
|
527
|
+
stdout=command_output,
|
|
528
|
+
command=command,
|
|
529
|
+
status=BashCommandStatus.NO_CHANGE_TIMEOUT,
|
|
530
|
+
session_id=self.id,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _handle_hard_timeout_command(
|
|
534
|
+
self, command: str, pane_content: str, timeout: float
|
|
535
|
+
) -> CommandResult:
|
|
536
|
+
"""Handle a command that hit the hard timeout."""
|
|
537
|
+
self.prev_status = BashCommandStatus.HARD_TIMEOUT
|
|
538
|
+
|
|
539
|
+
# Extract current output directly
|
|
540
|
+
lines = pane_content.strip().split("\n")
|
|
541
|
+
raw_command_output = "\n".join(lines)
|
|
542
|
+
raw_command_output = _remove_command_prefix(raw_command_output, command)
|
|
543
|
+
|
|
544
|
+
command_output = self._get_command_output(
|
|
545
|
+
command,
|
|
546
|
+
raw_command_output,
|
|
547
|
+
continue_prefix="[Below is the output of the previous command.]\n",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Add timeout message
|
|
551
|
+
command_output += (
|
|
552
|
+
f"\n[The command timed out after {timeout} seconds. "
|
|
553
|
+
"You may wait longer to see additional output by sending empty command '', "
|
|
554
|
+
"send other commands to interact with the current process, "
|
|
555
|
+
"or send keys to interrupt/kill the command.]"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return CommandResult(
|
|
559
|
+
return_code=-1,
|
|
560
|
+
stdout=command_output,
|
|
561
|
+
command=command,
|
|
562
|
+
status=BashCommandStatus.HARD_TIMEOUT,
|
|
563
|
+
session_id=self.id,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _fallback_completion_detection(
|
|
567
|
+
self, command: str, pane_content: str
|
|
568
|
+
) -> CommandResult:
|
|
569
|
+
"""Fallback completion detection when PS1 metadata is not available."""
|
|
570
|
+
# Use the old logic as fallback
|
|
571
|
+
self.pane.send_keys("echo EXIT_CODE:$?", enter=True)
|
|
572
|
+
time.sleep(0.1)
|
|
573
|
+
|
|
574
|
+
exit_code_output = self._get_pane_content()
|
|
575
|
+
exit_code = 0
|
|
576
|
+
for line in exit_code_output.split("\n"):
|
|
577
|
+
if line.strip().startswith("EXIT_CODE:"):
|
|
578
|
+
try:
|
|
579
|
+
exit_code = int(line.split(":")[1].strip())
|
|
580
|
+
except (ValueError, IndexError):
|
|
581
|
+
exit_code = 0
|
|
582
|
+
break
|
|
583
|
+
|
|
584
|
+
# Improved output extraction for complex shells like oh-my-zsh
|
|
585
|
+
output = self._extract_clean_output(pane_content, command)
|
|
586
|
+
|
|
587
|
+
self.prev_status = BashCommandStatus.COMPLETED # Set prev_status
|
|
588
|
+
self.prev_output = "" # Reset previous command output
|
|
589
|
+
|
|
590
|
+
# Clear screen and history to prevent output accumulation
|
|
591
|
+
self._ready_for_next_command()
|
|
592
|
+
|
|
593
|
+
return CommandResult(
|
|
594
|
+
return_code=exit_code,
|
|
595
|
+
stdout=output.strip(),
|
|
596
|
+
stderr="",
|
|
597
|
+
command=command,
|
|
598
|
+
status=BashCommandStatus.COMPLETED,
|
|
599
|
+
session_id=self.id,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _get_command_output(
|
|
603
|
+
self,
|
|
604
|
+
command: str,
|
|
605
|
+
raw_command_output: str,
|
|
606
|
+
continue_prefix: str = "",
|
|
607
|
+
) -> str:
|
|
608
|
+
"""Get the command output with the previous command output removed."""
|
|
609
|
+
# Remove the previous command output from the new output if any
|
|
610
|
+
if self.prev_output:
|
|
611
|
+
command_output = raw_command_output.removeprefix(self.prev_output)
|
|
612
|
+
# Add continue prefix if we're continuing from previous output
|
|
613
|
+
if continue_prefix:
|
|
614
|
+
command_output = continue_prefix + command_output
|
|
615
|
+
else:
|
|
616
|
+
command_output = raw_command_output
|
|
617
|
+
|
|
618
|
+
self.prev_output = raw_command_output # Update current command output
|
|
619
|
+
command_output = _remove_command_prefix(command_output, command)
|
|
620
|
+
return command_output.rstrip()
|
|
621
|
+
|
|
622
|
+
def _ready_for_next_command(self) -> None:
|
|
623
|
+
"""Reset the content buffer for a new command."""
|
|
624
|
+
self._clear_screen()
|
|
625
|
+
|
|
626
|
+
def _extract_clean_output(self, pane_content: str, command: str) -> str:
|
|
627
|
+
"""Extract clean command output from pane content, handling complex shells like oh-my-zsh."""
|
|
628
|
+
lines = pane_content.split("\n")
|
|
629
|
+
|
|
630
|
+
# Find lines that contain the actual command execution
|
|
631
|
+
command_line_indices = []
|
|
632
|
+
for i, line in enumerate(lines):
|
|
633
|
+
# Look for lines that contain the command (after prompt symbols)
|
|
634
|
+
stripped_line = line.strip()
|
|
635
|
+
# Check if line contains the command after removing common prompt symbols
|
|
636
|
+
clean_line = stripped_line
|
|
637
|
+
for prompt_symbol in ["❯", "$", "%", ">"]:
|
|
638
|
+
if clean_line.startswith(prompt_symbol):
|
|
639
|
+
clean_line = clean_line[len(prompt_symbol) :].strip()
|
|
640
|
+
break
|
|
641
|
+
|
|
642
|
+
if clean_line == command.strip():
|
|
643
|
+
command_line_indices.append(i)
|
|
644
|
+
|
|
645
|
+
if not command_line_indices:
|
|
646
|
+
# Fallback to simple extraction if we can't find the command
|
|
647
|
+
return self._simple_output_extraction(lines, command)
|
|
648
|
+
|
|
649
|
+
# Take the output after the last command line
|
|
650
|
+
last_command_index = command_line_indices[-1]
|
|
651
|
+
output_lines = []
|
|
652
|
+
decorative_line_count = 0
|
|
653
|
+
max_decorative_lines = 2 # Allow a few decorative lines before stopping
|
|
654
|
+
|
|
655
|
+
# Look for output lines immediately after the command
|
|
656
|
+
for i in range(last_command_index + 1, len(lines)):
|
|
657
|
+
line = lines[i]
|
|
658
|
+
stripped_line = line.strip()
|
|
659
|
+
|
|
660
|
+
# Stop if we hit a new prompt line
|
|
661
|
+
if self._is_prompt_line(stripped_line):
|
|
662
|
+
break
|
|
663
|
+
|
|
664
|
+
# Handle decorative lines more intelligently
|
|
665
|
+
if self._is_decorative_line(stripped_line):
|
|
666
|
+
decorative_line_count += 1
|
|
667
|
+
# If we haven't found any output yet, or we've seen too many decorative lines, stop
|
|
668
|
+
if not output_lines or decorative_line_count > max_decorative_lines:
|
|
669
|
+
if output_lines: # We have some output, stop here
|
|
670
|
+
break
|
|
671
|
+
else: # No output yet, but too many decorative lines, give up
|
|
672
|
+
continue
|
|
673
|
+
else:
|
|
674
|
+
# We have some output and this is an occasional decorative line, include it
|
|
675
|
+
output_lines.append(line.rstrip())
|
|
676
|
+
continue
|
|
677
|
+
else:
|
|
678
|
+
# Reset decorative line count when we see non-decorative content
|
|
679
|
+
decorative_line_count = 0
|
|
680
|
+
|
|
681
|
+
# Skip empty lines at the beginning only
|
|
682
|
+
if not output_lines and not stripped_line:
|
|
683
|
+
continue
|
|
684
|
+
|
|
685
|
+
# Add the line to output (preserve original formatting)
|
|
686
|
+
output_lines.append(line.rstrip())
|
|
687
|
+
|
|
688
|
+
return "\n".join(output_lines).rstrip()
|
|
689
|
+
|
|
690
|
+
def _simple_output_extraction(self, lines: list[str], command: str) -> str:
|
|
691
|
+
"""Simple fallback output extraction."""
|
|
692
|
+
if len(lines) > 1:
|
|
693
|
+
output = "\n".join(lines[:-1])
|
|
694
|
+
return _remove_command_prefix(output, command)
|
|
695
|
+
return ""
|
|
696
|
+
|
|
697
|
+
def _is_prompt_line(self, line: str) -> bool:
|
|
698
|
+
"""Check if a line looks like a shell prompt."""
|
|
699
|
+
stripped = line.strip()
|
|
700
|
+
|
|
701
|
+
# Check for common prompt patterns
|
|
702
|
+
prompt_indicators = ["❯", "$", "%", ">"]
|
|
703
|
+
for indicator in prompt_indicators:
|
|
704
|
+
if stripped.startswith(indicator) and len(stripped) > len(indicator):
|
|
705
|
+
return True
|
|
706
|
+
|
|
707
|
+
# Check for user@host patterns
|
|
708
|
+
if "@" in stripped and any(stripped.endswith(ind) for ind in prompt_indicators):
|
|
709
|
+
return True
|
|
710
|
+
|
|
711
|
+
return False
|
|
712
|
+
|
|
713
|
+
def _is_decorative_line(self, line: str) -> bool:
|
|
714
|
+
"""Check if a line is decorative (like oh-my-zsh decorations)."""
|
|
715
|
+
stripped = line.strip()
|
|
716
|
+
|
|
717
|
+
# Check for lines that are mostly special characters or dots
|
|
718
|
+
if len(stripped) > 20: # Long lines are likely decorative
|
|
719
|
+
special_chars = sum(1 for c in stripped if c in "·─═━┌┐└┘├┤┬┴┼▄▀█░▒▓")
|
|
720
|
+
if special_chars > len(stripped) * 0.5: # More than 50% special chars
|
|
721
|
+
return True
|
|
722
|
+
|
|
723
|
+
# Check for lines containing time stamps or status info
|
|
724
|
+
if "at " in stripped and ("AM" in stripped or "PM" in stripped):
|
|
725
|
+
return True
|
|
726
|
+
|
|
727
|
+
# Check for virtual environment deactivation messages
|
|
728
|
+
if "Deactivating:" in line or "_default_venv:" in line:
|
|
729
|
+
return True
|
|
730
|
+
|
|
731
|
+
return False
|