hanzo-mcp 0.3.8__py3-none-any.whl → 0.5.1__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.

Files changed (93) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +118 -170
  3. hanzo_mcp/cli_enhanced.py +438 -0
  4. hanzo_mcp/config/__init__.py +19 -0
  5. hanzo_mcp/config/settings.py +449 -0
  6. hanzo_mcp/config/tool_config.py +197 -0
  7. hanzo_mcp/prompts/__init__.py +117 -0
  8. hanzo_mcp/prompts/compact_conversation.py +77 -0
  9. hanzo_mcp/prompts/create_release.py +38 -0
  10. hanzo_mcp/prompts/project_system.py +120 -0
  11. hanzo_mcp/prompts/project_todo_reminder.py +111 -0
  12. hanzo_mcp/prompts/utils.py +286 -0
  13. hanzo_mcp/server.py +117 -99
  14. hanzo_mcp/tools/__init__.py +121 -33
  15. hanzo_mcp/tools/agent/__init__.py +8 -11
  16. hanzo_mcp/tools/agent/agent_tool.py +290 -224
  17. hanzo_mcp/tools/agent/prompt.py +16 -13
  18. hanzo_mcp/tools/agent/tool_adapter.py +9 -9
  19. hanzo_mcp/tools/common/__init__.py +17 -16
  20. hanzo_mcp/tools/common/base.py +79 -110
  21. hanzo_mcp/tools/common/batch_tool.py +330 -0
  22. hanzo_mcp/tools/common/config_tool.py +396 -0
  23. hanzo_mcp/tools/common/context.py +26 -292
  24. hanzo_mcp/tools/common/permissions.py +12 -12
  25. hanzo_mcp/tools/common/thinking_tool.py +153 -0
  26. hanzo_mcp/tools/common/validation.py +1 -63
  27. hanzo_mcp/tools/filesystem/__init__.py +97 -57
  28. hanzo_mcp/tools/filesystem/base.py +32 -24
  29. hanzo_mcp/tools/filesystem/content_replace.py +114 -107
  30. hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
  31. hanzo_mcp/tools/filesystem/edit.py +279 -0
  32. hanzo_mcp/tools/filesystem/grep.py +458 -0
  33. hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
  34. hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
  35. hanzo_mcp/tools/filesystem/read.py +255 -0
  36. hanzo_mcp/tools/filesystem/unified_search.py +689 -0
  37. hanzo_mcp/tools/filesystem/write.py +156 -0
  38. hanzo_mcp/tools/jupyter/__init__.py +41 -29
  39. hanzo_mcp/tools/jupyter/base.py +66 -57
  40. hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
  41. hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
  42. hanzo_mcp/tools/shell/__init__.py +29 -20
  43. hanzo_mcp/tools/shell/base.py +87 -45
  44. hanzo_mcp/tools/shell/bash_session.py +731 -0
  45. hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
  46. hanzo_mcp/tools/shell/command_executor.py +435 -384
  47. hanzo_mcp/tools/shell/run_command.py +284 -131
  48. hanzo_mcp/tools/shell/run_command_windows.py +328 -0
  49. hanzo_mcp/tools/shell/session_manager.py +196 -0
  50. hanzo_mcp/tools/shell/session_storage.py +325 -0
  51. hanzo_mcp/tools/todo/__init__.py +66 -0
  52. hanzo_mcp/tools/todo/base.py +319 -0
  53. hanzo_mcp/tools/todo/todo_read.py +148 -0
  54. hanzo_mcp/tools/todo/todo_write.py +378 -0
  55. hanzo_mcp/tools/vector/__init__.py +99 -0
  56. hanzo_mcp/tools/vector/ast_analyzer.py +459 -0
  57. hanzo_mcp/tools/vector/git_ingester.py +482 -0
  58. hanzo_mcp/tools/vector/infinity_store.py +731 -0
  59. hanzo_mcp/tools/vector/mock_infinity.py +162 -0
  60. hanzo_mcp/tools/vector/project_manager.py +361 -0
  61. hanzo_mcp/tools/vector/vector_index.py +116 -0
  62. hanzo_mcp/tools/vector/vector_search.py +225 -0
  63. hanzo_mcp-0.5.1.dist-info/METADATA +276 -0
  64. hanzo_mcp-0.5.1.dist-info/RECORD +68 -0
  65. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/WHEEL +1 -1
  66. hanzo_mcp/tools/agent/base_provider.py +0 -73
  67. hanzo_mcp/tools/agent/litellm_provider.py +0 -45
  68. hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
  69. hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
  70. hanzo_mcp/tools/agent/provider_registry.py +0 -120
  71. hanzo_mcp/tools/common/error_handling.py +0 -86
  72. hanzo_mcp/tools/common/logging_config.py +0 -115
  73. hanzo_mcp/tools/common/session.py +0 -91
  74. hanzo_mcp/tools/common/think_tool.py +0 -123
  75. hanzo_mcp/tools/common/version_tool.py +0 -120
  76. hanzo_mcp/tools/filesystem/edit_file.py +0 -287
  77. hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
  78. hanzo_mcp/tools/filesystem/read_files.py +0 -199
  79. hanzo_mcp/tools/filesystem/search_content.py +0 -275
  80. hanzo_mcp/tools/filesystem/write_file.py +0 -162
  81. hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
  82. hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
  83. hanzo_mcp/tools/project/__init__.py +0 -64
  84. hanzo_mcp/tools/project/analysis.py +0 -886
  85. hanzo_mcp/tools/project/base.py +0 -66
  86. hanzo_mcp/tools/project/project_analyze.py +0 -173
  87. hanzo_mcp/tools/shell/run_script.py +0 -215
  88. hanzo_mcp/tools/shell/script_tool.py +0 -244
  89. hanzo_mcp-0.3.8.dist-info/METADATA +0 -196
  90. hanzo_mcp-0.3.8.dist-info/RECORD +0 -53
  91. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/entry_points.txt +0 -0
  92. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.dist-info}/licenses/LICENSE +0 -0
  93. {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.1.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